Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08979d2079 | ||
|
|
c6efe07303 | ||
|
|
7294f0ca6d | ||
|
|
eac1c09149 | ||
|
|
1e9cd61eba | ||
|
|
7b7f341fa0 | ||
|
|
ac806b49b2 | ||
|
|
f20636a107 | ||
|
|
787a30e6f7 | ||
|
|
d1d217be18 | ||
|
|
944d0a371a | ||
|
|
0df03e0c9c | ||
|
|
7ffdf65705 | ||
|
|
89cdf91a48 | ||
|
|
43ebc27044 | ||
|
|
e6159555f3 | ||
|
|
1f2508aae9 | ||
|
|
ad13f58fa7 | ||
|
|
de4959d49f | ||
|
|
b5b75129e7 | ||
|
|
84346a486f | ||
|
|
3bdcddf5a2 | ||
|
|
98f68a5e14 | ||
|
|
2249b86af3 | ||
|
|
fd889922d8 | ||
|
|
8db7c6e320 | ||
|
|
5bc4ed6dfd | ||
|
|
22ad5f7fea | ||
|
|
c0369c1a14 | ||
|
|
322f4a3ca5 | ||
|
|
4e32453441 | ||
|
|
66725b8a64 | ||
|
|
f7bcbbca83 | ||
|
|
07a3b33040 | ||
|
|
2f9b4582f8 | ||
|
|
c3f63c58cf | ||
|
|
4a3529bc2e | ||
|
|
b0355a919f | ||
|
|
cfe1a0b4b9 | ||
|
|
b655e98f35 | ||
|
|
2d1021bc42 | ||
|
|
33d74999b9 | ||
|
|
84b7dd7a3c | ||
|
|
0c678fbda3 | ||
|
|
3486f7d050 | ||
|
|
d42a1010b8 | ||
|
|
ece6ceea45 | ||
|
|
b22ebb399e | ||
|
|
4431b10cb7 | ||
|
|
01a0c929e8 | ||
|
|
b06f6e8d09 | ||
|
|
753227acbb | ||
|
|
c7dd9091d0 | ||
|
|
bae20ce011 | ||
|
|
8da4759668 | ||
|
|
eb7c6d91e9 | ||
|
|
3c24dfe8a6 | ||
|
|
bb916daaaf | ||
|
|
3931e484c2 | ||
|
|
b67e258c31 | ||
|
|
1a7e6f5a43 | ||
|
|
437204dbe6 | ||
|
|
af105277d9 | ||
|
|
7efd327a36 | ||
|
|
0141586fa9 | ||
|
|
df1d8ccac6 | ||
|
|
10b6b95e4d | ||
|
|
a58e6f77bd | ||
|
|
fe2bd80ac6 | ||
|
|
870b44a973 | ||
|
|
48fd9ca7b2 | ||
|
|
14d03b7eb9 | ||
|
|
6f1db6c038 | ||
|
|
cd2d208e5c | ||
|
|
7d6ec72002 | ||
|
|
837cb6a978 | ||
|
|
aeeb0c08d7 | ||
|
|
72d8a7f485 | ||
|
|
5d3692c7a0 | ||
|
|
7e54231bef | ||
|
|
80a885dbf3 | ||
|
|
134c6bbb5f | ||
|
|
49a153adf7 | ||
|
|
99e15b0bda | ||
|
|
4de8a73af2 | ||
|
|
d104ba3180 | ||
|
|
abf0d4748f | ||
|
|
d2a9c44601 | ||
|
|
c269558bae | ||
|
|
cc22453a40 | ||
|
|
d525d92de4 | ||
|
|
2197dfe65c | ||
|
|
38ee00f474 | ||
|
|
8fdad41c71 | ||
|
|
f269995bb7 | ||
|
|
03a2db8c44 | ||
|
|
6d9cd3c6a8 | ||
|
|
303b2f7036 | ||
|
|
ec25c2ffd9 | ||
|
|
50ab608ddb | ||
|
|
3c76be9b81 | ||
|
|
ab7f0cf0b4 | ||
|
|
f9f590c4dc | ||
|
|
8d38fe582a | ||
|
|
dc4a26561d | ||
|
|
10c1d1f3a8 | ||
|
|
66bcf53d01 | ||
|
|
8ab4b7d693 | ||
|
|
ce2f097d32 | ||
|
|
f7575cd327 | ||
|
|
8634c6a211 | ||
|
|
b070013efc | ||
|
|
d2d9112f6c | ||
|
|
9fea18f2de | ||
|
|
74480f91ce | ||
|
|
b2e13b631f | ||
|
|
001d995c8f | ||
|
|
8cb2acea88 | ||
|
|
7c0d57d84e | ||
|
|
8cb875f449 | ||
|
|
e6bbe65723 | ||
|
|
f4a71a2476 | ||
|
|
47b9362b0a | ||
|
|
c1aad0806e | ||
|
|
4ccc90f9fb | ||
|
|
7dc63440e6 | ||
|
|
4094e8b80d | ||
|
|
e27cbaf715 | ||
|
|
1f39b27d79 | ||
|
|
f45891fd95 | ||
|
|
18fe644715 | ||
|
|
40cde8c69a | ||
|
|
4b0af47906 | ||
|
|
9365b3c8cd | ||
|
|
4b9f015ea7 | ||
|
|
c42d4a084e | ||
|
|
5bb3feb05b | ||
|
|
05f776ed8b | ||
|
|
9cec809485 | ||
|
|
429f909152 | ||
|
|
084dd23df1 | ||
|
|
e55afdd739 | ||
|
|
72128a132b | ||
|
|
92ca2cddad | ||
|
|
3db0d1dfe5 | ||
|
|
57907323e6 | ||
|
|
dbdca44c5f | ||
|
|
fe1dd2201f | ||
|
|
e0ae194cc3 | ||
|
|
6fc5700457 | ||
|
|
c4fdcf86d4 | ||
|
|
3088500c8d | ||
|
|
861f3a3624 | ||
|
|
c55783e4d9 | ||
|
|
955e284d41 | ||
|
|
fc4c47427e | ||
|
|
e2d7563faa | ||
|
|
27d69f7f8d | ||
|
|
a77bb5af44 | ||
|
|
00286261a4 | ||
|
|
0b898dccaa | ||
|
|
a1d9ac4e68 | ||
|
|
4150939e23 | ||
|
|
8f84b7f063 | ||
|
|
04b245ac64 | ||
|
|
12f7e62957 | ||
|
|
9600d310c7 | ||
|
|
dec5a2472a | ||
|
|
13eb7c6ea2 | ||
|
|
2356cfa10a | ||
|
|
3bfaefb3b0 | ||
|
|
78b8c25d96 | ||
|
|
c1d2ff2b96 | ||
|
|
24aee9446a | ||
|
|
2fb094ec31 | ||
|
|
53897c66ee | ||
|
|
ca4e266ae6 | ||
|
|
6612a1e16f | ||
|
|
55ceb65dfb | ||
|
|
6cad3d6afb | ||
|
|
151e1bdb8a | ||
|
|
44a3cfd1ff | ||
|
|
9cbc3028a7 | ||
|
|
8c30730d7b | ||
|
|
acfb870f9d | ||
|
|
3813528f50 | ||
|
|
e3bb014644 | ||
|
|
76a7afde76 | ||
|
|
1184f9f3f5 | ||
|
|
b754f8938f | ||
|
|
6b30ff04b7 | ||
|
|
1c40acca63 | ||
|
|
a5a7a8afaf | ||
|
|
583ac13a37 | ||
|
|
3e58972072 | ||
|
|
f15aa27727 | ||
|
|
2581014dbd | ||
|
|
baaaa1b57e | ||
|
|
160fbb3590 | ||
|
|
6f3253678c | ||
|
|
563ad66243 | ||
|
|
a8d002cc53 | ||
|
|
0615410fa4 | ||
|
|
fc98e065f8 | ||
|
|
66f671ffa0 | ||
|
|
69a35af456 | ||
|
|
e462bd0b4c | ||
|
|
ae6483427f | ||
|
|
ad97677104 | ||
|
|
996d15ef25 | ||
|
|
06de32ffe7 | ||
|
|
dd43074e46 | ||
|
|
93495e13db | ||
|
|
16950edae4 | ||
|
|
4af1203360 | ||
|
|
55b5bd1fd2 | ||
|
|
f0a7cf4ed0 | ||
|
|
62e7412abf | ||
|
|
275bf647d2 | ||
|
|
00af723be9 | ||
|
|
19da577836 | ||
|
|
bf3a2b469b | ||
|
|
bf31bfd099 | ||
|
|
d02fea99f2 | ||
|
|
2404bacb4e | ||
|
|
b6c274c181 | ||
|
|
f9b472aee7 | ||
|
|
45f277741b | ||
|
|
94179f59cd | ||
|
|
c7b550a3e3 | ||
|
|
fd51fd2387 | ||
|
|
23d1798ab6 | ||
|
|
90e81d0d4d | ||
|
|
6a7a19547d | ||
|
|
1550849ee2 | ||
|
|
15116e2197 | ||
|
|
63eda5179b | ||
|
|
d7b1277363 | ||
|
|
337c933b92 | ||
|
|
b01b2cc9c0 |
51
.cursor/rules/ai-features.mdc
Normal file
@@ -0,0 +1,51 @@
|
||||
# AI Features and LangChain Integration
|
||||
|
||||
## AI Components
|
||||
|
||||
- **LangChain Integration**: Uses `@langchain/core`, `@langchain/deepseek`,
|
||||
`@langchain/langgraph`, `@langchain/ollama`
|
||||
- **Whisper Transcription**: Local and online transcription via `whisper-rs` in
|
||||
Rust backend
|
||||
- **AI Agent**: Located in [src/lib/agent/](mdc:src/lib/agent/) directory
|
||||
|
||||
## Frontend AI Features
|
||||
|
||||
- **AI Page**: [src/page/AI.svelte](mdc:src/page/AI.svelte) - Main AI interface
|
||||
- **Agent Logic**: [src/lib/agent/](mdc:src/lib/agent/) - AI agent implementation
|
||||
- **Interface**: [src/lib/interface.ts](mdc:src/lib/interface.ts)
|
||||
\- AI communication layer
|
||||
|
||||
## Backend AI Features
|
||||
|
||||
- **Subtitle Generation**:
|
||||
[src-tauri/src/subtitle_generator/](mdc:src-tauri/src/subtitle_generator/) -
|
||||
AI-powered subtitle creation
|
||||
- **Whisper Integration**:
|
||||
[src-tauri/src/subtitle_generator.rs](mdc:src-tauri/src/subtitle_generator.rs)
|
||||
\- Speech-to-text processing
|
||||
- **CUDA Support**: Optional CUDA acceleration for Whisper via feature flag
|
||||
|
||||
## AI Workflows
|
||||
|
||||
- **Live Transcription**: Real-time speech-to-text during live streams
|
||||
- **Content Summarization**: AI-powered content analysis and summarization
|
||||
- **Smart Editing**: AI-assisted video editing and clip generation
|
||||
- **Danmaku Processing**: AI analysis of danmaku (bullet comments) streams
|
||||
|
||||
## Configuration
|
||||
|
||||
- **LLM Settings**: Configure AI models in [src-tauri/config.example.toml](mdc:src-tauri/config.example.toml)
|
||||
- **Whisper Models**: Local model configuration for offline transcription
|
||||
- **API Keys**: External AI service configuration for online features
|
||||
|
||||
## Development Notes
|
||||
|
||||
- AI features require proper model configuration
|
||||
- CUDA feature enables GPU acceleration for Whisper
|
||||
- LangChain integration supports multiple AI providers
|
||||
- AI agent can work with both local and cloud-based models
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
62
.cursor/rules/build-deployment.mdc
Normal file
@@ -0,0 +1,62 @@
|
||||
# Build and Deployment Configuration
|
||||
|
||||
## Build Scripts
|
||||
|
||||
- **PowerShell**: [build.ps1](mdc:build.ps1) - Windows build script
|
||||
- **FFmpeg Setup**: [ffmpeg_setup.ps1](mdc:ffmpeg_setup.ps1)
|
||||
\- FFmpeg installation script
|
||||
- **Version Bump**: [scripts/bump.cjs](mdc:scripts/bump.cjs)
|
||||
\- Version management script
|
||||
|
||||
## Package Management
|
||||
|
||||
- **Node.js**: [package.json](mdc:package.json) - Frontend dependencies and scripts
|
||||
- **Rust**: [src-tauri/Cargo.toml](mdc:src-tauri/Cargo.toml)
|
||||
\- Backend dependencies and features
|
||||
- **Lock Files**: [yarn.lock](mdc:yarn.lock) - Yarn dependency lock
|
||||
|
||||
## Build Configuration
|
||||
|
||||
- **Vite**: [vite.config.ts](mdc:vite.config.ts) - Frontend build tool configuration
|
||||
- **Tailwind**: [tailwind.config.cjs](mdc:tailwind.config.cjs) - CSS framework configuration
|
||||
- **PostCSS**: [postcss.config.cjs](mdc:postcss.config.cjs) - CSS processing configuration
|
||||
- **TypeScript**: [tsconfig.json](mdc:tsconfig.json),
|
||||
[tsconfig.node.json](mdc:tsconfig.node.json) - TypeScript configuration
|
||||
|
||||
## Tauri Configuration
|
||||
|
||||
- **Main Config**: [src-tauri/tauri.conf.json](mdc:src-tauri/tauri.conf.json)
|
||||
\- Core Tauri settings
|
||||
- **Platform Configs**:
|
||||
- [src-tauri/tauri.macos.conf.json](mdc:src-tauri/tauri.macos.conf.json)
|
||||
\- macOS specific
|
||||
- [src-tauri/tauri.linux.conf.json](mdc:src-tauri/tauri.linux.conf.json)
|
||||
\- Linux specific
|
||||
- [src-tauri/tauri.windows.conf.json](mdc:src-tauri/tauri.windows.conf.json)
|
||||
\- Windows specific
|
||||
- [src-tauri/tauri.windows.cuda.conf.json](mdc:src-tauri/tauri.windows.cuda.conf.json)
|
||||
\- Windows with CUDA
|
||||
|
||||
## Docker Support
|
||||
|
||||
- **Dockerfile**: [Dockerfile](mdc:Dockerfile) - Container deployment configuration
|
||||
- **Documentation**: [docs/](mdc:docs/) - VitePress-based documentation site
|
||||
|
||||
## Build Commands
|
||||
|
||||
- **Frontend**: `yarn build` - Build production frontend
|
||||
- **Tauri**: `yarn tauri build` - Build desktop application
|
||||
- **Documentation**: `yarn docs:build` - Build documentation site
|
||||
- **Type Check**: `yarn check` - TypeScript and Svelte validation
|
||||
|
||||
## Deployment Targets
|
||||
|
||||
- **Desktop**: Native Tauri applications for Windows, macOS, Linux
|
||||
- **Docker**: Containerized deployment option
|
||||
- **Documentation**: Static site deployment via VitePress
|
||||
- **Assets**: Static asset distribution for web components
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
61
.cursor/rules/database-data.mdc
Normal file
@@ -0,0 +1,61 @@
|
||||
# Database and Data Management
|
||||
|
||||
## Database Architecture
|
||||
|
||||
- **SQLite Database**: Primary data storage using `sqlx` with async runtime
|
||||
- **Database Module**: [src-tauri/src/database/](mdc:src-tauri/src/database/)
|
||||
\- Core database operations
|
||||
- **Migration System**: [src-tauri/src/migration.rs](mdc:src-tauri/src/migration.rs)
|
||||
\- Database schema management
|
||||
|
||||
## Data Models
|
||||
|
||||
- **Recording Data**: Stream metadata, recording sessions, and file information
|
||||
- **Room Configuration**: Stream room settings and platform credentials
|
||||
- **Task Management**: Recording task status and progress tracking
|
||||
- **User Preferences**: Application settings and user configurations
|
||||
|
||||
## Frontend Data Layer
|
||||
|
||||
- **Database Interface**: [src/lib/db.ts](mdc:src/lib/db.ts)
|
||||
\- Frontend database operations
|
||||
- **Stores**: [src/lib/stores/](mdc:src/lib/stores/) - State management for data
|
||||
- **Version Management**: [src/lib/stores/version.ts](mdc:src/lib/stores/version.ts)
|
||||
\- Version tracking
|
||||
|
||||
## Data Operations
|
||||
|
||||
- **CRUD Operations**: Create, read, update, delete for all data entities
|
||||
- **Query Optimization**: Efficient SQL queries with proper indexing
|
||||
- **Transaction Support**: ACID compliance for critical operations
|
||||
- **Data Validation**: Input validation and sanitization
|
||||
|
||||
## File Management
|
||||
|
||||
- **Cache Directory**: [src-tauri/cache/](mdc:src-tauri/cache/)
|
||||
\- Temporary file storage
|
||||
- **Upload Directory**: [src-tauri/cache/uploads/](mdc:src-tauri/cache/uploads/)
|
||||
\- User upload storage
|
||||
- **Bilibili Cache**: [src-tauri/cache/bilibili/](mdc:src-tauri/cache/bilibili/)
|
||||
\- Platform-specific cache
|
||||
|
||||
## Data Persistence
|
||||
|
||||
- **SQLite Files**: [src-tauri/data/data_v2.db](mdc:src-tauri/data/data_v2.db)
|
||||
\- Main database file
|
||||
- **Write-Ahead Logging**: WAL mode for concurrent access and performance
|
||||
- **Backup Strategy**: Database backup and recovery procedures
|
||||
- **Migration Handling**: Automatic schema updates and data migration
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- Use prepared statements to prevent SQL injection
|
||||
- Implement proper error handling for database operations
|
||||
- Use transactions for multi-step operations
|
||||
- Follow database naming conventions consistently
|
||||
- Test database operations with sample data
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
47
.cursor/rules/frontend-development.mdc
Normal file
@@ -0,0 +1,47 @@
|
||||
# Frontend Development Guidelines
|
||||
|
||||
## Svelte 3 Best Practices
|
||||
|
||||
- Use Svelte 3 syntax with `<script>` tags for component logic
|
||||
- Prefer reactive statements with `$:` for derived state
|
||||
- Use stores from [src/lib/stores/](mdc:src/lib/stores/) for global state management
|
||||
- Import components from [src/lib/components/](mdc:src/lib/components/)
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
- Follow the configuration in [tsconfig.json](mdc:tsconfig.json)
|
||||
- Use strict type checking with `checkJs: true`
|
||||
- Extends `@tsconfig/svelte` for Svelte-specific TypeScript settings
|
||||
- Base URL is set to workspace root for clean imports
|
||||
|
||||
## Component Structure
|
||||
|
||||
- **Page components**: Located in [src/page/](mdc:src/page/) directory
|
||||
- **Reusable components**: Located in [src/lib/components/](mdc:src/lib/components/)
|
||||
directory
|
||||
- **Layout components**: [src/App.svelte](mdc:src/App.svelte),
|
||||
[src/AppClip.svelte](mdc:src/AppClip.svelte), [src/AppLive.svelte](mdc:src/AppLive.svelte)
|
||||
|
||||
## Styling
|
||||
|
||||
- Use Tailwind CSS classes for styling
|
||||
- Configuration in [tailwind.config.cjs](mdc:tailwind.config.cjs)
|
||||
- PostCSS configuration in [postcss.config.cjs](mdc:postcss.config.cjs)
|
||||
- Global styles in [src/styles.css](mdc:src/styles.css)
|
||||
|
||||
## Entry Points
|
||||
|
||||
- **Main app**: [src/main.ts](mdc:src/main.ts) - Main application entry
|
||||
- **Clip mode**: [src/main_clip.ts](mdc:src/main_clip.ts) - Clip editing interface
|
||||
- **Live mode**: [src/main_live.ts](mdc:src/main_live.ts) - Live streaming interface
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- Use `yarn dev` for frontend-only development
|
||||
- Use `yarn tauri dev` for full Tauri development
|
||||
- Use `yarn check` for TypeScript and Svelte type checking
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
53
.cursor/rules/project-overview.mdc
Normal file
@@ -0,0 +1,53 @@
|
||||
# BiliBili ShadowReplay Project Overview
|
||||
|
||||
This is a Tauri-based desktop application for caching live streams and performing
|
||||
real-time editing and submission. It supports Bilibili and Douyin platforms.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Frontend (Svelte + TypeScript)
|
||||
|
||||
- **Main entry points**: [src/main.ts](mdc:src/main.ts),
|
||||
[src/main_clip.ts](mdc:src/main_clip.ts), [src/main_live.ts](mdc:src/main_live.ts)
|
||||
- **App components**: [src/App.svelte](mdc:src/App.svelte),
|
||||
[src/AppClip.svelte](mdc:src/AppClip.svelte), [src/AppLive.svelte](mdc:src/AppLive.svelte)
|
||||
- **Pages**: Located in [src/page/](mdc:src/page/) directory
|
||||
- **Components**: Located in [src/lib/components/](mdc:src/lib/components/) directory
|
||||
- **Stores**: Located in [src/lib/stores/](mdc:src/lib/stores/) directory
|
||||
|
||||
### Backend (Rust + Tauri)
|
||||
|
||||
- **Main entry**: [src-tauri/src/main.rs](mdc:src-tauri/src/main.rs)
|
||||
- **Core modules**:
|
||||
- [src-tauri/src/recorder/](mdc:src-tauri/src/recorder/) - Stream recording functionality
|
||||
- [src-tauri/src/database/](mdc:src-tauri/src/database/) - Database operations
|
||||
- [src-tauri/src/handlers/](mdc:src-tauri/src/handlers/) - Tauri command handlers
|
||||
- **Custom crate**:
|
||||
[src-tauri/crates/danmu_stream/](mdc:src-tauri/crates/danmu_stream/) -
|
||||
Danmaku stream processing
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Frontend config**: [tsconfig.json](mdc:tsconfig.json),
|
||||
[vite.config.ts](mdc:vite.config.ts), [tailwind.config.cjs](mdc:tailwind.config.cjs)
|
||||
- **Backend config**: [src-tauri/Cargo.toml](mdc:src-tauri/Cargo.toml), [src-tauri/tauri.conf.json](mdc:src-tauri/tauri.conf.json)
|
||||
- **Example config**: [src-tauri/config.example.toml](mdc:src-tauri/config.example.toml)
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Frontend**: Svelte 3, TypeScript, Tailwind CSS, Flowbite
|
||||
- **Backend**: Rust, Tauri 2, SQLite, FFmpeg
|
||||
- **AI Features**: LangChain, Whisper for transcription
|
||||
- **Build Tools**: Vite, VitePress for documentation
|
||||
|
||||
## Development Commands
|
||||
|
||||
- `yarn dev` - Start development server
|
||||
- `yarn tauri dev` - Start Tauri development
|
||||
- `yarn build` - Build frontend
|
||||
- `yarn docs:dev` - Start documentation server
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
56
.cursor/rules/rust-backend.mdc
Normal file
@@ -0,0 +1,56 @@
|
||||
# Rust Backend Development Guidelines
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **Main entry**: [src-tauri/src/main.rs](mdc:src-tauri/src/main.rs)
|
||||
\- Application entry point
|
||||
- **Core modules**:
|
||||
- [src-tauri/src/recorder/](mdc:src-tauri/src/recorder/)
|
||||
\- Stream recording and management
|
||||
- [src-tauri/src/database/](mdc:src-tauri/src/database/)
|
||||
\- SQLite database operations
|
||||
- [src-tauri/src/handlers/](mdc:src-tauri/src/handlers/)
|
||||
\- Tauri command handlers
|
||||
- [src-tauri/src/subtitle_generator/](mdc:src-tauri/src/subtitle_generator/)
|
||||
\- AI-powered subtitle generation
|
||||
|
||||
## Custom Crates
|
||||
|
||||
- **danmu_stream**: [src-tauri/crates/danmu_stream/](mdc:src-tauri/crates/danmu_stream/)
|
||||
\- Danmaku stream processing library
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Tauri 2**: Core framework for desktop app functionality
|
||||
- **FFmpeg**: Video/audio processing via `async-ffmpeg-sidecar`
|
||||
- **Whisper**: AI transcription via `whisper-rs` (CUDA support available)
|
||||
- **LangChain**: AI agent functionality
|
||||
- **SQLite**: Database via `sqlx` with async runtime
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Cargo.toml**: [src-tauri/Cargo.toml](mdc:src-tauri/Cargo.toml)
|
||||
\- Dependencies and features
|
||||
- **Tauri config**: [src-tauri/tauri.conf.json](mdc:src-tauri/tauri.conf.json)
|
||||
\- App configuration
|
||||
- **Example config**: [src-tauri/config.example.toml](mdc:src-tauri/config.example.toml)
|
||||
\- User configuration template
|
||||
|
||||
## Features
|
||||
|
||||
- **default**: Includes GUI and core functionality
|
||||
- **cuda**: Enables CUDA acceleration for Whisper transcription
|
||||
- **headless**: Headless mode without GUI
|
||||
- **custom-protocol**: Required for production builds
|
||||
|
||||
## Development Commands
|
||||
|
||||
- `yarn tauri dev` - Start Tauri development with hot reload
|
||||
- `yarn tauri build` - Build production application
|
||||
- `cargo check` - Check Rust code without building
|
||||
- `cargo test` - Run Rust tests
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
60
.cursor/rules/streaming-recording.mdc
Normal file
@@ -0,0 +1,60 @@
|
||||
# Streaming and Recording System
|
||||
|
||||
## Core Recording Components
|
||||
|
||||
- **Recorder Manager**: [src-tauri/src/recorder_manager.rs](mdc:src-tauri/src/recorder_manager.rs)
|
||||
\- Main recording orchestration
|
||||
- **Recorder**: [src-tauri/src/recorder/](mdc:src-tauri/src/recorder/)
|
||||
\- Individual stream recording logic
|
||||
- **Danmaku Stream**: [src-tauri/crates/danmu_stream/](mdc:src-tauri/crates/danmu_stream/)
|
||||
\- Custom crate for bullet comment processing
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- **Bilibili**: Main platform support with live stream caching
|
||||
- **Douyin**: TikTok's Chinese platform support
|
||||
- **Multi-stream**: Support for recording multiple streams simultaneously
|
||||
|
||||
## Recording Features
|
||||
|
||||
- **Live Caching**: Real-time stream recording and buffering
|
||||
- **Time-based Clipping**: Extract specific time segments from recorded streams
|
||||
- **Danmaku Capture**: Record bullet comments and chat messages
|
||||
- **Quality Control**: Configurable recording quality and format options
|
||||
|
||||
## Frontend Interfaces
|
||||
|
||||
- **Live Mode**: [src/AppLive.svelte](mdc:src/AppLive.svelte)
|
||||
\- Live streaming interface
|
||||
- **Clip Mode**: [src/AppClip.svelte](mdc:src/AppClip.svelte)
|
||||
\- Video editing and clipping
|
||||
- **Room Management**: [src/page/Room.svelte](mdc:src/page/Room.svelte)
|
||||
\- Stream room configuration
|
||||
- **Task Management**: [src/page/Task.svelte](mdc:src/page/Task.svelte)
|
||||
\- Recording task monitoring
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
- **FFmpeg Integration**: Video/audio processing via `async-ffmpeg-sidecar`
|
||||
- **M3U8 Support**: HLS stream processing with `m3u8-rs`
|
||||
- **Async Processing**: Non-blocking I/O with `tokio` runtime
|
||||
- **Database Storage**: SQLite for metadata and recording information
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Recording Settings**: Configure in [src-tauri/config.example.toml](mdc:src-tauri/config.example.toml)
|
||||
- **FFmpeg Path**: Set FFmpeg binary location for video processing
|
||||
- **Storage Paths**: Configure cache and output directories
|
||||
- **Quality Settings**: Adjust recording bitrate and format options
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- Use [src-tauri/src/recorder/](mdc:src-tauri/src/recorder/) for core recording logic
|
||||
- Test with [src-tauri/tests/](mdc:src-tauri/tests/) directory
|
||||
- Monitor recording progress via progress manager
|
||||
- Handle errors gracefully with custom error types
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
|
||||
---
|
||||
36
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
ARG VARIANT=bookworm-slim
|
||||
FROM debian:${VARIANT}
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Arguments
|
||||
ARG CONTAINER_USER=vscode
|
||||
ARG CONTAINER_GROUP=vscode
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
file \
|
||||
git \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
pkg-config \
|
||||
wget \
|
||||
&& apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
|
||||
|
||||
# Set users
|
||||
RUN adduser --disabled-password --gecos "" ${CONTAINER_USER}
|
||||
USER ${CONTAINER_USER}
|
||||
WORKDIR /home/${CONTAINER_USER}
|
||||
|
||||
# Install rustup
|
||||
RUN curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
ENV PATH=${PATH}:/home/${CONTAINER_USER}/.cargo/bin
|
||||
|
||||
CMD [ "/bin/bash" ]
|
||||
31
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "vscode",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"CONTAINER_USER": "vscode",
|
||||
"CONTAINER_GROUP": "vscode"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "latest"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"lldb.executable": "/usr/bin/lldb",
|
||||
"files.watcherExclude": {
|
||||
"**/target/**": true
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"vadimcn.vscode-lldb",
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml"
|
||||
]
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
7
.github/CONTRIBUTING.md
vendored
@@ -12,7 +12,8 @@
|
||||
|
||||
### Windows
|
||||
|
||||
Windows 下分为两个版本,分别是 `cpu` 和 `cuda` 版本。区别在于 Whisper 是否使用 GPU 加速。`cpu` 版本使用 CPU 进行推理,`cuda` 版本使用 GPU 进行推理。
|
||||
Windows 下分为两个版本,分别是 `cpu` 和 `cuda` 版本。区别在于 Whisper 是否使用 GPU 加速。
|
||||
`cpu` 版本使用 CPU 进行推理,`cuda` 版本使用 GPU 进行推理。
|
||||
|
||||
默认运行为 `cpu` 版本,使用 `yarn tauri dev --features cuda` 命令运行 `cuda` 版本。
|
||||
|
||||
@@ -20,7 +21,9 @@ Windows 下分为两个版本,分别是 `cpu` 和 `cuda` 版本。区别在于
|
||||
|
||||
1. 安装 LLVM 且配置相关环境变量,详情见 [LLVM Windows Setup](https://llvm.org/docs/GettingStarted.html#building-llvm-on-windows);
|
||||
|
||||
2. 安装 CUDA Toolkit,详情见 [CUDA Windows Setup](https://docs.nvidia.com/cuda/cuda-installation-guide-microsoft-windows/index.html);要注意,安装时请勾选 **VisualStudio integration**。
|
||||
2. 安装 CUDA Toolkit,详情见
|
||||
[CUDA Windows Setup](https://docs.nvidia.com/cuda/cuda-installation-guide-microsoft-windows/index.html);
|
||||
要注意,安装时请勾选 **VisualStudio integration**。
|
||||
|
||||
### 常见问题
|
||||
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: 提交一个 BUG
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: Xinrea
|
||||
---
|
||||
|
||||
**描述:**
|
||||
简要描述一下这个 BUG 的现象
|
||||
|
||||
**日志和截图:**
|
||||
如果可以的话,请尽量附上相关截图和日志文件(日志是位于安装目录下,名为 bsr.log 的文件)。
|
||||
|
||||
**相关信息:**
|
||||
|
||||
- 程序版本:
|
||||
- 系统类型:
|
||||
|
||||
**其他**
|
||||
任何其他想说的
|
||||
47
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Bug Report
|
||||
description: 提交 BUG 报告.
|
||||
title: "[bug] "
|
||||
labels: ["bug"]
|
||||
assignees:
|
||||
- Xinrea
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 提交须知
|
||||
description: 请确认以下内容
|
||||
options:
|
||||
- label: 我是在最新版本上发现的此问题
|
||||
required: true
|
||||
- label: 我已阅读 [常见问题](https://bsr.xinrea.cn/usage/faq.html) 的说明
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app_type
|
||||
attributes:
|
||||
label: 以哪种方式使用的该软件?
|
||||
multiple: false
|
||||
options:
|
||||
- Docker 镜像
|
||||
- 桌面应用
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: 运行环境
|
||||
multiple: false
|
||||
options:
|
||||
- Linux
|
||||
- Windows
|
||||
- MacOS
|
||||
- Docker
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: BUG 描述
|
||||
description: 请尽可能详细描述 BUG 的现象以及复现的方法
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 日志
|
||||
description: 请粘贴日志内容或是上传日志文件(在主窗口的设置页面,提供了一键打开日志目录所在位置的按钮;当你打开日志目录所在位置后,进入 logs 目录,找到后缀名为 log 的文件)
|
||||
validations:
|
||||
required: true
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: 提交一个新功能的建议
|
||||
title: "[feature]"
|
||||
labels: enhancement
|
||||
assignees: Xinrea
|
||||
|
||||
---
|
||||
|
||||
**遇到的问题:**
|
||||
在使用过程中遇到了什么问题让你想要提出建议
|
||||
|
||||
**想要的功能:**
|
||||
想要怎样的新功能来解决这个问题
|
||||
|
||||
**通过什么方式实现(有思路的话):**
|
||||
如果有相关的实现思路或者是参考,可以在此提供
|
||||
|
||||
**其他:**
|
||||
其他任何想说的话
|
||||
13
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: Feature Request
|
||||
description: 提交新功能的需求
|
||||
title: "[feature] "
|
||||
labels: ["feature"]
|
||||
assignees:
|
||||
- Xinrea
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 需求描述
|
||||
description: 请尽可能详细描述你想要的新功能
|
||||
validations:
|
||||
required: true
|
||||
46
.github/workflows/check.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Rust Check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "**/*.rs"
|
||||
- "src-tauri/Cargo.toml"
|
||||
- "src-tauri/Cargo.lock"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: self-linux
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt clippy
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf ffmpeg
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Check clippy
|
||||
run: cargo clippy
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Check clippy (headless)
|
||||
run: cargo clippy --no-default-features --features headless
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Check tests
|
||||
run: cargo test -v
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Check tests (headless)
|
||||
run: cargo test --no-default-features --features headless -v
|
||||
working-directory: src-tauri
|
||||
23
.github/workflows/main.yml
vendored
@@ -57,12 +57,7 @@ jobs:
|
||||
|
||||
- name: Install CUDA toolkit (Windows CUDA only)
|
||||
if: matrix.platform == 'windows-latest' && matrix.features == 'cuda'
|
||||
uses: Jimver/cuda-toolkit@master
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./src-tauri -> target"
|
||||
uses: Jimver/cuda-toolkit@v0.2.24
|
||||
|
||||
- name: Setup ffmpeg
|
||||
if: matrix.platform == 'windows-latest'
|
||||
@@ -87,6 +82,19 @@ jobs:
|
||||
Copy-Item "$cudaPath\cublas64*.dll" -Destination $targetPath
|
||||
Copy-Item "$cudaPath\cublasLt64*.dll" -Destination $targetPath
|
||||
|
||||
- name: Get previous tag
|
||||
id: get_previous_tag
|
||||
run: |
|
||||
# Get the previous tag (excluding the current one being pushed)
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "")
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
# If no previous tag found, use the first commit
|
||||
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD | head -1)
|
||||
fi
|
||||
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
||||
echo "current_tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -96,8 +104,7 @@ jobs:
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: "BiliBili ShadowReplay v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseBody: "> [!NOTE]\n> 如果你是第一次下载安装,请参考 [安装准备](https://bsr.xinrea.cn/getting-started/installation/desktop.html) 选择合适的版本。\n> Changelog: https://github.com/Xinrea/bili-shadowreplay/compare/${{ steps.get_previous_tag.outputs.previous_tag }}...${{ steps.get_previous_tag.outputs.current_tag }}"
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }} ${{ matrix.platform == 'windows-latest' && matrix.features == 'cuda' && '--config src-tauri/tauri.windows.cuda.conf.json' || '' }}
|
||||
includeDebug: true
|
||||
|
||||
6
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
/target/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -32,4 +33,7 @@ src-tauri/tests/audio/*.srt
|
||||
.env
|
||||
|
||||
docs/.vitepress/cache
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/dist
|
||||
|
||||
*.debug.js
|
||||
*.debug.map
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[[language]]
|
||||
name = "rust"
|
||||
auto-format = true
|
||||
rulers = []
|
||||
|
||||
[[language]]
|
||||
name = "svelte"
|
||||
|
||||
5
.markdownlint.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"MD033": {
|
||||
"allowed_elements": ["nobr", "sup"]
|
||||
}
|
||||
}
|
||||
51
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
fail_fast: true
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
exclude: \.json$
|
||||
|
||||
- repo: https://github.com/crate-ci/typos
|
||||
rev: v1.36.2
|
||||
hooks:
|
||||
- id: typos
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: cargo-fmt
|
||||
name: cargo fmt
|
||||
entry: cargo fmt --manifest-path src-tauri/Cargo.toml --
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false # This makes it a lot faster
|
||||
|
||||
- id: cargo-clippy
|
||||
name: cargo clippy
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
entry: cargo clippy --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
- id: cargo-clippy-headless
|
||||
name: cargo clippy headless
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
entry: cargo clippy --manifest-path src-tauri/Cargo.toml --no-default-features --features headless
|
||||
|
||||
- id: cargo-test
|
||||
name: cargo test
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
entry: cargo test --manifest-path src-tauri/Cargo.toml
|
||||
|
||||
- id: cargo-test-headless
|
||||
name: cargo test headless
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
entry: cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features headless
|
||||
22
Dockerfile
@@ -23,7 +23,7 @@ COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# Build Rust backend
|
||||
FROM rust:1.86-slim AS rust-builder
|
||||
FROM rust:1.90-slim AS rust-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -42,20 +42,15 @@ RUN apt-get update && apt-get install -y \
|
||||
# Copy Rust project files
|
||||
COPY src-tauri/Cargo.toml src-tauri/Cargo.lock ./src-tauri/
|
||||
COPY src-tauri/src ./src-tauri/src
|
||||
COPY src-tauri/crates ./src-tauri/crates
|
||||
|
||||
# Build Rust backend
|
||||
WORKDIR /app/src-tauri
|
||||
RUN rustup component add rustfmt
|
||||
RUN cargo build --no-default-features --features headless --release
|
||||
# Download and install FFmpeg static build
|
||||
RUN wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
|
||||
&& tar xf ffmpeg-release-amd64-static.tar.xz \
|
||||
&& mv ffmpeg-*-static/ffmpeg ./ \
|
||||
&& mv ffmpeg-*-static/ffprobe ./ \
|
||||
&& rm -rf ffmpeg-*-static ffmpeg-release-amd64-static.tar.xz
|
||||
|
||||
# Final stage
|
||||
FROM debian:bookworm-slim AS final
|
||||
FROM debian:trixie-slim AS final
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -64,9 +59,16 @@ RUN apt-get update && apt-get install -y \
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
fonts-wqy-microhei \
|
||||
netbase \
|
||||
nscd \
|
||||
ffmpeg \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
RUN touch /etc/netgroup
|
||||
RUN mkdir -p /var/run/nscd && chmod 755 /var/run/nscd
|
||||
|
||||
# Add /app to PATH
|
||||
ENV PATH="/app:${PATH}"
|
||||
|
||||
@@ -75,11 +77,9 @@ COPY --from=frontend-builder /app/dist ./dist
|
||||
|
||||
# Copy built Rust binary
|
||||
COPY --from=rust-builder /app/src-tauri/target/release/bili-shadowreplay .
|
||||
COPY --from=rust-builder /app/src-tauri/ffmpeg ./ffmpeg
|
||||
COPY --from=rust-builder /app/src-tauri/ffprobe ./ffprobe
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the application
|
||||
CMD ["./bili-shadowreplay"]
|
||||
CMD ["sh", "-c", "nscd && ./bili-shadowreplay"]
|
||||
|
||||
13
README.md
@@ -4,24 +4,29 @@
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
[](https://deepwiki.com/Xinrea/bili-shadowreplay)
|
||||
|
||||
BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。
|
||||
|
||||
目前仅支持 B 站和抖音平台的直播。
|
||||
|
||||

|
||||
[](https://www.star-history.com/#Xinrea/bili-shadowreplay&Date)
|
||||
|
||||
## 安装和使用
|
||||
|
||||

|
||||
|
||||
前往网站查看说明:[BiliBili ShadowReplay](https://bsr.xinrea.cn/)
|
||||
|
||||
## 参与开发
|
||||
|
||||
[Contributing](.github/CONTRIBUTING.md)
|
||||
可以通过 [DeepWiki](https://deepwiki.com/Xinrea/bili-shadowreplay) 了解本项目。
|
||||
|
||||
贡献指南:[Contributing](.github/CONTRIBUTING.md)
|
||||
|
||||
## 赞助
|
||||
|
||||

|
||||
<!-- markdownlint-disable MD033 -->
|
||||
<img src="docs/public/images/donate.png" alt="donate" width="300">
|
||||
|
||||
2
_typos.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[default.extend-identifiers]
|
||||
pull_datas = "pull_datas"
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import { withMermaid } from "vitepress-plugin-mermaid";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
export default withMermaid({
|
||||
title: "BiliBili ShadowReplay",
|
||||
description: "直播录制/实时回放/剪辑/投稿工具",
|
||||
themeConfig: {
|
||||
@@ -18,21 +19,55 @@ export default defineConfig({
|
||||
{
|
||||
text: "开始使用",
|
||||
items: [
|
||||
{ text: "安装准备", link: "/getting-started/installation" },
|
||||
{ text: "配置使用", link: "/getting-started/configuration" },
|
||||
{ text: "FFmpeg 配置", link: "/getting-started/ffmpeg" },
|
||||
{
|
||||
text: "安装准备",
|
||||
items: [
|
||||
{
|
||||
text: "桌面端安装",
|
||||
link: "/getting-started/installation/desktop",
|
||||
},
|
||||
{
|
||||
text: "Docker 安装",
|
||||
link: "/getting-started/installation/docker",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "配置使用",
|
||||
items: [
|
||||
{ text: "账号配置", link: "/getting-started/config/account" },
|
||||
{ text: "FFmpeg 配置", link: "/getting-started/config/ffmpeg" },
|
||||
{ text: "Whisper 配置", link: "/getting-started/config/whisper" },
|
||||
{ text: "LLM 配置", link: "/getting-started/config/llm" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "说明文档",
|
||||
items: [
|
||||
{ text: "功能说明", link: "/usage/features" },
|
||||
{
|
||||
text: "功能说明",
|
||||
items: [
|
||||
{ text: "工作流程", link: "/usage/features/workflow" },
|
||||
{ text: "直播间管理", link: "/usage/features/room" },
|
||||
{ text: "切片功能", link: "/usage/features/clip" },
|
||||
{ text: "字幕功能", link: "/usage/features/subtitle" },
|
||||
{ text: "弹幕功能", link: "/usage/features/danmaku" },
|
||||
{ text: "Webhook", link: "/usage/features/webhook" },
|
||||
],
|
||||
},
|
||||
{ text: "常见问题", link: "/usage/faq" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "开发文档",
|
||||
items: [{ text: "架构设计", link: "/develop/architecture" }],
|
||||
items: [
|
||||
{
|
||||
text: "DeepWiki",
|
||||
link: "https://deepwiki.com/Xinrea/bili-shadowreplay",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# 架构设计
|
||||
12
docs/getting-started/config/account.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 账号配置
|
||||
|
||||
要添加直播间,至少需要配置一个同平台的账号。在账号页面,你可以通过添加账号按钮添加一个账号。
|
||||
|
||||
- B 站账号:目前支持扫码登录和 Cookie 手动配置两种方式,推荐使用扫码登录
|
||||
- 抖音账号:目前仅支持 Cookie 手动配置登陆
|
||||
|
||||
## 抖音账号配置
|
||||
|
||||
首先确保已经登录抖音,然后打开[个人主页](https://www.douyin.com/user/self),右键单击网页,在菜单中选择 `检查(Inspect)`,打开开发者工具,切换到 `网络(Network)` 选项卡,然后刷新网页,此时能在列表中找到 `self` 请求(一般是列表中第一个),单击该请求,查看`请求标头`,在 `请求标头` 中找到 `Cookie`,复制该字段的值,粘贴到配置页面的 `Cookie` 输入框中,要注意复制完全。
|
||||
|
||||

|
||||
9
docs/getting-started/config/llm.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# LLM 配置
|
||||
|
||||

|
||||
|
||||
助手页面的 AI Agent 助手功能需要配置大模型,目前仅支持配置 OpenAI 协议兼容的大模型服务。
|
||||
|
||||
本软件并不提供大模型服务,请自行选择服务提供商。要注意,使用 AI Agent 助手需要消耗比普通对话更多的 Token,请确保有足够的 Token 余额。
|
||||
|
||||
此外,AI Agent 的功能需要大模型支持 Function Calling 功能,否则无法正常调用工具。
|
||||
46
docs/getting-started/config/whisper.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Whisper 配置
|
||||
|
||||
要使用 AI 字幕识别功能,需要在设置页面配置 Whisper。目前可以选择使用本地运行 Whisper 模型,或是使用在线的 Whisper 服务(通常需要付
|
||||
费获取 API Key)。
|
||||
|
||||
> [!NOTE]
|
||||
> 其实有许多更好的中文字幕识别解决方案,但是这类服务通常需要将文件上传到对象存储后异步处理,考虑到实现的复杂度,选择了使用本地运行 Whisper 模型或是使
|
||||
> 用在线的 Whisper 服务,在请求返回时能够直接获取字幕生成结果。
|
||||
|
||||
## 本地运行 Whisper 模型
|
||||
|
||||

|
||||
|
||||
如果需要使用本地运行 Whisper 模型进行字幕生成,需要下载 Whisper.cpp 模型,并在设置中指定模型路径。模型文件可以从网络上下载,例如:
|
||||
|
||||
- [Whisper.cpp(国内镜像,内容较旧)](https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/files)
|
||||
- [Whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main)
|
||||
|
||||
可以跟据自己的需求选择不同的模型,要注意带有 `en` 的模型是英文模型,其他模型为多语言模型。
|
||||
|
||||
模型文件的大小通常意味着其在运行时资源占用的大小,因此请根据电脑配置选择合适的模型。此外,GPU 版本与 CPU 版本在字幕生成速度上存在**巨大差异**,因此
|
||||
推荐使用 GPU 版本进行本地处理(目前仅支持 Nvidia GPU)。
|
||||
|
||||
## 使用在线 Whisper 服务
|
||||
|
||||

|
||||
|
||||
如果需要使用在线的 Whisper 服务进行字幕生成,可以在设置中切换为在线 Whisper,并配置好 API Key。提供 Whisper 服务的平台并非只有
|
||||
OpenAI 一家,许多云服务平台也提供 Whisper 服务。
|
||||
|
||||
## 字幕识别质量的调优
|
||||
|
||||
目前在设置中支持设置 Whisper 语言和 Whisper 提示词,这些设置对于本地和在线的 Whisper 服务都有效。
|
||||
|
||||
通常情况下,`auto` 语言选项能够自动识别语音语言,并生成相应语言的字幕。如果需要生成其他语言的字幕,或是生成的字幕语言不匹配,可以手动配置指定的语言。
|
||||
根据 OpenAI 官方文档中对于 `language` 参数的描述,目前支持的语言包括
|
||||
|
||||
Afrikaans, Arabic, Armenian, Azerbaijani, Belarusian, Bosnian, Bulgarian,
|
||||
Catalan, Chinese, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish,
|
||||
French, Galician, German, Greek, Hebrew, Hindi, Hungarian, Icelandic,
|
||||
Indonesian, Italian, Japanese, Kannada, Kazakh, Korean, Latvian, Lithuanian,
|
||||
Macedonian, Malay, Marathi, Maori, Nepali, Norwegian, Persian, Polish,
|
||||
Portuguese, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swahili,
|
||||
Swedish, Tagalog, Tamil, Thai, Turkish, Ukrainian, Urdu, Vietnamese, and Welsh.
|
||||
|
||||
提示词可以优化生成的字幕的风格(也会一定程度上影响质量),要注意,Whisper 无法理解复杂的提示词,你可以在提示词中使用一些简单的描述,让其在选择词汇时使用偏向于提示词所描述的领域相关的词汇,以避免出现毫不相干领域的词汇;或是让它在标点符号的使用上参照提示词的风格。
|
||||
@@ -1,21 +0,0 @@
|
||||
# 配置使用
|
||||
|
||||
## 账号配置
|
||||
|
||||
要添加直播间,至少需要配置一个同平台的账号。在账号页面,你可以通过添加账号按钮添加一个账号。
|
||||
|
||||
- B 站账号:目前支持扫码登录和 Cookie 手动配置两种方式
|
||||
- 抖音账号:目前仅支持 Cookie 手动配置登陆
|
||||
|
||||
## FFmpeg 配置
|
||||
|
||||
如果想要使用切片生成和压制功能,请确保 FFmpeg 已正确配置;除了 Windows 平台打包自带 FFfmpeg 以外,其他平台需要手动安装 FFfmpeg,请参考 [FFfmpeg 配置](/getting-started/ffmpeg)。
|
||||
|
||||
## Whisper 模型配置
|
||||
|
||||
要使用 AI 字幕识别功能,需要在设置页面配置 Whisper 模型路径,模型文件可以从网络上下载,例如:
|
||||
|
||||
- [Whisper.cpp(国内镜像,内容较旧)](https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/files)
|
||||
- [Whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main)
|
||||
|
||||
可以跟据自己的需求选择不同的模型,要注意带有 `en` 的模型是英文模型,其他模型为多语言模型。
|
||||
@@ -1,66 +0,0 @@
|
||||
# 安装准备
|
||||
|
||||
## 桌面端安装
|
||||
|
||||
桌面端目前提供了 Windows、Linux 和 MacOS 三个平台的安装包。
|
||||
|
||||
安装包分为两个版本,普通版和 debug 版,普通版适合大部分用户使用,debug 版包含了更多的调试信息,适合开发者使用;由于程序会对账号等敏感信息进行管理,请从信任的来源进行下载;所有版本均可在 [GitHub Releases](https://github.com/Xinrea/bili-shadowreplay/releases) 页面下载安装。
|
||||
|
||||
### Windows
|
||||
|
||||
由于程序内置 Whisper 字幕识别模型支持,Windows 版本分为两种:
|
||||
|
||||
- **普通版本**:内置了 Whisper GPU 加速,字幕识别较快,体积较大,只支持 Nvidia 显卡
|
||||
- **CPU 版本**: 使用 CPU 进行字幕识别推理,速度较慢
|
||||
|
||||
请根据自己的显卡情况选择合适的版本进行下载。
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 版本目前仅支持使用 CPU 推理,且测试较少,可能存在一些问题,遇到问题请及时反馈。
|
||||
|
||||
### MacOS
|
||||
|
||||
MacOS 版本内置 Metal GPU 加速;安装后首次运行,会提示无法打开从网络下载的软件,请在设置-隐私与安全性下,选择仍然打开以允许程序运行。
|
||||
|
||||
## Docker 部署
|
||||
|
||||
BiliBili ShadowReplay 提供了服务端部署的能力,提供 Web 控制界面,可以用于在服务器等无图形界面环境下部署使用。
|
||||
|
||||
### 镜像获取
|
||||
|
||||
```bash
|
||||
# 拉取最新版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
# 拉取指定版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:2.5.0
|
||||
# 速度太慢?从镜像源拉取
|
||||
docker pull ghcr.nju.edu.cn/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
### 镜像使用
|
||||
|
||||
使用方法:
|
||||
|
||||
```bash
|
||||
sudo docker run -it -d\
|
||||
-p 3000:3000 \
|
||||
-v $DATA_DIR:/app/data \
|
||||
-v $CACHE_DIR:/app/cache \
|
||||
-v $OUTPUT_DIR:/app/output \
|
||||
-v $WHISPER_MODEL:/app/whisper_model.bin \
|
||||
--name bili-shadowreplay \
|
||||
ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `$DATA_DIR`:为数据目录,对应于桌面版的数据目录,
|
||||
|
||||
Windows 下位于 `C:\Users\{用户名}\AppData\Roaming\cn.vjoi.bilishadowreplay`;
|
||||
|
||||
MacOS 下位于 `/Users/{user}/Library/Application Support/cn.vjoi.bilishadowreplay`
|
||||
|
||||
- `$CACHE_DIR`:为缓存目录,对应于桌面版的缓存目录;
|
||||
- `$OUTPUT_DIR`:为输出目录,对应于桌面版的输出目录;
|
||||
- `$WHISPER_MODEL`:为 Whisper 模型文件路径,对应于桌面版的 Whisper 模型文件路径。
|
||||
22
docs/getting-started/installation/desktop.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 桌面端安装
|
||||
|
||||
桌面端目前提供了 Windows、Linux 和 MacOS 三个平台的安装包。
|
||||
|
||||
由于程序会对账号等敏感信息进行管理,请从信任的来源进行下载;所有版本均可在 [GitHub Releases](https://github.com/Xinrea/bili-shadowreplay/releases) 页面下载安装。
|
||||
|
||||
## Windows
|
||||
|
||||
由于程序内置 Whisper 字幕识别模型支持,Windows 版本分为两种:
|
||||
|
||||
- **普通版本**:内置了 Whisper GPU 加速,字幕识别较快,体积较大,只支持 Nvidia 显卡
|
||||
- **CPU 版本**: 使用 CPU 进行字幕识别推理,速度较慢
|
||||
|
||||
请根据自己的显卡情况选择合适的版本进行下载。
|
||||
|
||||
## Linux
|
||||
|
||||
Linux 版本目前仅支持使用 CPU 推理,且测试较少,可能存在一些问题,遇到问题请及时反馈。
|
||||
|
||||
## MacOS
|
||||
|
||||
MacOS 版本内置 Metal GPU 加速;安装后首次运行,会提示无法打开从网络下载的软件,请在设置-隐私与安全性下,选择仍然打开以允许程序运行。
|
||||
41
docs/getting-started/installation/docker.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Docker 部署
|
||||
|
||||
BiliBili ShadowReplay 提供了服务端部署的能力,提供 Web 控制界面,可以用于在服务器等无图形界面环境下部署使用。
|
||||
|
||||
## 镜像获取
|
||||
|
||||
```bash
|
||||
# 拉取最新版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
# 拉取指定版本
|
||||
docker pull ghcr.io/xinrea/bili-shadowreplay:2.5.0
|
||||
# 速度太慢?从镜像源拉取
|
||||
docker pull ghcr.nju.edu.cn/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
## 镜像使用
|
||||
|
||||
使用方法:
|
||||
|
||||
```bash
|
||||
sudo docker run -it -d\
|
||||
-p 3000:3000 \
|
||||
-v $DATA_DIR:/app/data \
|
||||
-v $CACHE_DIR:/app/cache \
|
||||
-v $OUTPUT_DIR:/app/output \
|
||||
-v $WHISPER_MODEL:/app/whisper_model.bin \
|
||||
--name bili-shadowreplay \
|
||||
ghcr.io/xinrea/bili-shadowreplay:latest
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `$DATA_DIR`:为数据目录,对应于桌面版的数据目录,
|
||||
|
||||
Windows 下位于 `C:\Users\{用户名}\AppData\Roaming\cn.vjoi.bilishadowreplay`;
|
||||
|
||||
MacOS 下位于 `/Users/{user}/Library/Application Support/cn.vjoi.bilishadowreplay`
|
||||
|
||||
- `$CACHE_DIR`:为缓存目录,对应于桌面版的缓存目录;
|
||||
- `$OUTPUT_DIR`:为输出目录,对应于桌面版的输出目录;
|
||||
- `$WHISPER_MODEL`:为 Whisper 模型文件路径,对应于桌面版的 Whisper 模型文件路径。
|
||||
@@ -11,10 +11,10 @@ hero:
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 开始使用
|
||||
link: /getting-started/installation
|
||||
link: /getting-started/installation/desktop
|
||||
- theme: alt
|
||||
text: 说明文档
|
||||
link: /usage/features
|
||||
link: /usage/features/workflow
|
||||
|
||||
features:
|
||||
- icon: 📹
|
||||
@@ -38,9 +38,9 @@ features:
|
||||
- icon: 🔍
|
||||
title: 云端部署
|
||||
details: 支持 Docker 部署,提供 Web 控制界面
|
||||
- icon: 📦
|
||||
title: 多平台支持
|
||||
details: 桌面端支持 Windows/Linux/macOS
|
||||
- icon: 🤖
|
||||
title: AI Agent 支持
|
||||
details: 支持 AI 助手管理录播,分析直播内容,生成切片
|
||||
---
|
||||
|
||||
## 总览
|
||||
@@ -63,7 +63,7 @@ features:
|
||||
|
||||
## 封面编辑
|
||||
|
||||

|
||||

|
||||
|
||||
## 设置
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 555 KiB After Width: | Height: | Size: 195 KiB |
BIN
docs/public/images/ai_agent.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 434 KiB |
BIN
docs/public/images/clip_manage.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
docs/public/images/clip_preview.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
docs/public/images/cover_edit.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
BIN
docs/public/images/douyin_cookie.png
Normal file
|
After Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.1 MiB |
BIN
docs/public/images/model_config.png
Normal file
|
After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 949 KiB |
|
Before Width: | Height: | Size: 622 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 721 KiB After Width: | Height: | Size: 372 KiB |
BIN
docs/public/images/tasks.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/public/images/whisper_local.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
docs/public/images/whisper_online.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
docs/public/images/whole_clip.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/public/videos/deeplinking.mp4
Normal file
BIN
docs/public/videos/room_remove.mp4
Normal file
@@ -0,0 +1,31 @@
|
||||
# 常见问题
|
||||
|
||||
## 一、在哪里反馈问题?
|
||||
|
||||
你可以前往 [Github Issues](https://github.com/Xinrea/bili-shadowreplay/issues/new?template=bug_report.md) 提交问题,或是加入[反馈交流群](https://qm.qq.com/q/v4lrE6gyum)。
|
||||
|
||||
1. 在提交问题前,请先阅读其它常见问题,确保你的问题已有解答;
|
||||
2. 其次,请确保你的程序已更新到最新版本;
|
||||
3. 最后,你应准备好提供你的程序日志文件,以便更好地定位问题。
|
||||
|
||||
## 二、在哪里查看日志?
|
||||
|
||||
在主窗口的设置页面,提供了一键打开日志目录所在位置的按钮。当你打开日志目录所在位置后,进入 `logs` 目录,找到后缀名为 `log` 的文件,这便是你需要提供给开发者的日志文件。
|
||||
|
||||
## 三、无法预览直播或是生成切片
|
||||
|
||||
如果你是 macOS 或 Linux 用户,请确保你已安装了 `ffmpeg` 和 `ffprobe` 工具;如果不知道如何安装,请参考 [FFmpeg 配置](/getting-started/config/ffmpeg)。
|
||||
|
||||
如果你是 Windows 用户,程序目录下应当自带了 `ffmpeg` 和 `ffprobe` 工具,如果无法预览直播或是生成切片,请向开发者反馈。
|
||||
|
||||
## 四、添加 B 站直播间出现 -352 错误
|
||||
|
||||
`-352` 错误是由 B 站风控机制导致的,如果你添加了大量的 B 站直播间进行录制,可以在设置页面调整直播间状态的检查间隔,尽量避免风控;如果你在直播间数量较少的情况下出现该错误,请向开发者反馈。
|
||||
|
||||
## 五、录播为什么都是碎片文件?
|
||||
|
||||
缓存目录下的录播文件并非用于直接播放或是投稿,而是用于直播流的预览与实时回放。如果你需要录播文件用于投稿,请打开对应录播的预览界面,使用快捷键创建选区,生成所需范围的切片,切片文件为常规的 mp4 文件,位于你所设置的切片目录下。
|
||||
|
||||
如果你将 BSR 作为单纯的录播软件使用,在设置中可以开启`整场录播生成`,这样在直播结束后,BSR 会自动生成整场录播的切片。
|
||||
|
||||

|
||||
|
||||
1
docs/usage/features/clip.md
Normal file
@@ -0,0 +1 @@
|
||||
# 切片
|
||||
1
docs/usage/features/danmaku.md
Normal file
@@ -0,0 +1 @@
|
||||
# 弹幕
|
||||
40
docs/usage/features/room.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 直播间
|
||||
|
||||
> [!WARNING]
|
||||
> 在添加管理直播间前,请确保账号列表中有对应平台的可用账号。
|
||||
|
||||
## 添加直播间
|
||||
|
||||
### 手动添加直播间
|
||||
|
||||
你可以在 BSR 直播间页面,点击按钮手动添加直播间。你需要选择平台,并输入直播间号。
|
||||
|
||||
直播间号通常是直播间网页地址尾部的遗传数字,例如 `https://live.bilibili.com/123456` 中的 `123456`,或是 `https://live.douyin.com/123456` 中的 `123456`。
|
||||
|
||||
抖音直播间比较特殊,当未开播时,你无法找到直播间的入口,因此你需要当直播间开播时找到直播间网页地址,并记录其直播间号。
|
||||
|
||||
抖音直播间需要输入主播的 sec_uid,你可以在主播主页的 URL 中找到,例如 `https://www.douyin.com/user/MS4wLjABAAAA` 中的 `MS4wLjABAAAA`。
|
||||
|
||||
### 使用 DeepLinking 快速添加直播间
|
||||
|
||||
<!-- MD033 -->
|
||||
|
||||
<video src="/videos/deeplinking.mp4" loop autoplay muted style="border-radius: 10px;"></video>
|
||||
|
||||
在浏览器中观看直播时,替换地址栏中直播间地址中的 `https://` 为 `bsr://` 即可快速唤起 BSR 添加直播间。
|
||||
|
||||
## 启用/禁用直播间
|
||||
|
||||
你可以点击直播间卡片右上角的菜单按钮,选择启用/禁用直播间。
|
||||
|
||||
- 启用后,当直播间开播时,会自动开始录制
|
||||
- 禁用后,当直播间开播时,不会自动开始录制
|
||||
|
||||
## 移除直播间
|
||||
|
||||
> [!CAUTION]
|
||||
> 移除直播间后,该直播间相关的所有录播都会被删除,请谨慎操作。
|
||||
|
||||
你可以点击直播间卡片右上角的菜单按钮,选择移除直播间。
|
||||
|
||||
<video src="/videos/room_remove.mp4" loop autoplay muted style="border-radius: 10px;"></video>
|
||||
1
docs/usage/features/subtitle.md
Normal file
@@ -0,0 +1 @@
|
||||
# 字幕
|
||||
245
docs/usage/features/webhook.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Webhook
|
||||
|
||||
> [!NOTE]
|
||||
> 你可以使用 <https://webhook.site> 来测试 Webhook 功能。
|
||||
|
||||
## 设置 Webhook
|
||||
|
||||
打开 BSR 设置页面,在基础设置中设置 Webhook 地址。
|
||||
|
||||
## Webhook Events
|
||||
|
||||
### 直播间相关
|
||||
|
||||
#### 添加直播间
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a96a5e9f-9857-4c13-b889-91da2ace208a",
|
||||
"event": "recorder.added",
|
||||
"payload": {
|
||||
"room_id": 26966466,
|
||||
"created_at": "2025-09-07T03:33:14.258796+00:00",
|
||||
"platform": "bilibili",
|
||||
"auto_start": true,
|
||||
"extra": ""
|
||||
},
|
||||
"timestamp": 1757215994
|
||||
}
|
||||
```
|
||||
|
||||
#### 移除直播间
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "e33623d4-e040-4390-88f5-d351ceeeace7",
|
||||
"event": "recorder.removed",
|
||||
"payload": {
|
||||
"room_id": 27183290,
|
||||
"created_at": "2025-08-30T10:54:18.569198+00:00",
|
||||
"platform": "bilibili",
|
||||
"auto_start": true,
|
||||
"extra": ""
|
||||
},
|
||||
"timestamp": 1757217015
|
||||
}
|
||||
```
|
||||
|
||||
### 直播相关
|
||||
|
||||
> [!NOTE]
|
||||
> 直播开始和结束,不意味着录制的开始和结束。
|
||||
|
||||
#### 直播开始
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "f12f3424-f7d8-4b2f-a8b7-55477411482e",
|
||||
"event": "live.started",
|
||||
"payload": {
|
||||
"room_id": 843610,
|
||||
"room_info": {
|
||||
"room_id": 843610,
|
||||
"room_title": "登顶!",
|
||||
"room_cover": "https://i0.hdslb.com/bfs/live/new_room_cover/73aea43f4b4624c314d62fea4b424822fb506dfb.jpg"
|
||||
},
|
||||
"user_info": {
|
||||
"user_id": "475210",
|
||||
"user_name": "Xinrea",
|
||||
"user_avatar": "https://i1.hdslb.com/bfs/face/91beb3bf444b295fe12bae1f3dc6d9fc4fe4c224.jpg"
|
||||
},
|
||||
"total_length": 0,
|
||||
"current_live_id": "",
|
||||
"live_status": false,
|
||||
"is_recording": false,
|
||||
"auto_start": true,
|
||||
"platform": "bilibili"
|
||||
},
|
||||
"timestamp": 1757217190
|
||||
}
|
||||
```
|
||||
|
||||
#### 直播结束
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "e8b0756a-02f9-4655-b5ae-a170bf9547bd",
|
||||
"event": "live.ended",
|
||||
"payload": {
|
||||
"room_id": 843610,
|
||||
"room_info": {
|
||||
"room_id": 843610,
|
||||
"room_title": "登顶!",
|
||||
"room_cover": "https://i0.hdslb.com/bfs/live/new_room_cover/73aea43f4b4624c314d62fea4b424822fb506dfb.jpg"
|
||||
},
|
||||
"user_info": {
|
||||
"user_id": "475210",
|
||||
"user_name": "Xinrea",
|
||||
"user_avatar": "https://i1.hdslb.com/bfs/face/91beb3bf444b295fe12bae1f3dc6d9fc4fe4c224.jpg"
|
||||
},
|
||||
"total_length": 0,
|
||||
"current_live_id": "",
|
||||
"live_status": true,
|
||||
"is_recording": false,
|
||||
"auto_start": true,
|
||||
"platform": "bilibili"
|
||||
},
|
||||
"timestamp": 1757217365
|
||||
}
|
||||
```
|
||||
|
||||
### 录播相关
|
||||
|
||||
#### 开始录制
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "5ec1ea10-2b31-48fd-8deb-f2d7d2ea5985",
|
||||
"event": "record.started",
|
||||
"payload": {
|
||||
"room_id": 26966466,
|
||||
"room_info": {
|
||||
"room_id": 26966466,
|
||||
"room_title": "早安獭獭栞!下播前抽fufu",
|
||||
"room_cover": "https://i0.hdslb.com/bfs/live/user_cover/b810c36855168034557e905e5916b1dba1761fa4.jpg"
|
||||
},
|
||||
"user_info": {
|
||||
"user_id": "1609526545",
|
||||
"user_name": "栞栞Shiori",
|
||||
"user_avatar": "https://i1.hdslb.com/bfs/face/47e8dbabb895de44ec6cace085d4dc1d40307277.jpg"
|
||||
},
|
||||
"total_length": 0,
|
||||
"current_live_id": "1757216045412",
|
||||
"live_status": true,
|
||||
"is_recording": false,
|
||||
"auto_start": true,
|
||||
"platform": "bilibili"
|
||||
},
|
||||
"timestamp": 1757216045
|
||||
}
|
||||
```
|
||||
|
||||
#### 结束录制
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "56fd03e5-3965-4c2e-a6a9-bb6932347eb3",
|
||||
"event": "record.ended",
|
||||
"payload": {
|
||||
"room_id": 26966466,
|
||||
"room_info": {
|
||||
"room_id": 26966466,
|
||||
"room_title": "早安獭獭栞!下播前抽fufu",
|
||||
"room_cover": "https://i0.hdslb.com/bfs/live/user_cover/b810c36855168034557e905e5916b1dba1761fa4.jpg"
|
||||
},
|
||||
"user_info": {
|
||||
"user_id": "1609526545",
|
||||
"user_name": "栞栞Shiori",
|
||||
"user_avatar": "https://i1.hdslb.com/bfs/face/47e8dbabb895de44ec6cace085d4dc1d40307277.jpg"
|
||||
},
|
||||
"total_length": 52.96700000000001,
|
||||
"current_live_id": "1757215994597",
|
||||
"live_status": true,
|
||||
"is_recording": true,
|
||||
"auto_start": true,
|
||||
"platform": "bilibili"
|
||||
},
|
||||
"timestamp": 1757216040
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除录播
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "c32bc811-ab4b-49fd-84c7-897727905d16",
|
||||
"event": "archive.deleted",
|
||||
"payload": {
|
||||
"platform": "bilibili",
|
||||
"live_id": "1756607084705",
|
||||
"room_id": 1967212929,
|
||||
"title": "灶台O.o",
|
||||
"length": 9,
|
||||
"size": 1927112,
|
||||
"created_at": "2025-08-31T02:24:44.728616+00:00",
|
||||
"cover": "bilibili/1967212929/1756607084705/cover.jpg"
|
||||
},
|
||||
"timestamp": 1757176219
|
||||
}
|
||||
```
|
||||
|
||||
### 切片相关
|
||||
|
||||
#### 切片生成
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "f542e0e1-688b-4f1a-8ce1-e5e51530cf5d",
|
||||
"event": "clip.generated",
|
||||
"payload": {
|
||||
"id": 316,
|
||||
"room_id": 27183290,
|
||||
"cover": "[27183290][1757172501727][一起看凡人修仙传][2025-09-07_00-16-11].jpg",
|
||||
"file": "[27183290][1757172501727][一起看凡人修仙传][2025-09-07_00-16-11].mp4",
|
||||
"note": "",
|
||||
"length": 121,
|
||||
"size": 53049119,
|
||||
"status": 0,
|
||||
"bvid": "",
|
||||
"title": "",
|
||||
"desc": "",
|
||||
"tags": "",
|
||||
"area": 0,
|
||||
"created_at": "2025-09-07T00:16:11.747461+08:00",
|
||||
"platform": "bilibili"
|
||||
},
|
||||
"timestamp": 1757175371
|
||||
}
|
||||
```
|
||||
|
||||
#### 切片删除
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "5c7ca728-753d-4a7d-a0b4-02c997ad2f92",
|
||||
"event": "clip.deleted",
|
||||
"payload": {
|
||||
"id": 313,
|
||||
"room_id": 27183290,
|
||||
"cover": "[27183290][1756903953470][不出非洲之心不下播][2025-09-03_21-10-54].jpg",
|
||||
"file": "[27183290][1756903953470][不出非洲之心不下播][2025-09-03_21-10-54].mp4",
|
||||
"note": "",
|
||||
"length": 32,
|
||||
"size": 18530098,
|
||||
"status": 0,
|
||||
"bvid": "",
|
||||
"title": "",
|
||||
"desc": "",
|
||||
"tags": "",
|
||||
"area": 0,
|
||||
"created_at": "2025-09-03T21:10:54.943682+08:00",
|
||||
"platform": "bilibili"
|
||||
},
|
||||
"timestamp": 1757147617
|
||||
}
|
||||
```
|
||||
30
docs/usage/features/workflow.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 工作流程
|
||||
|
||||
- 直播间:各个平台的直播间
|
||||
- 录播:直播流的存档,每次录制会自动生成一场录播记录
|
||||
- 切片:从直播流中剪切生成的视频片段
|
||||
- 投稿:将切片上传到各个平台(目前仅支持 Bilibili)
|
||||
|
||||
下图展示了它们之间的关系:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[直播间] -->|录制| B[录播 01]
|
||||
A -->|录制| C[录播 02]
|
||||
A -->|录制| E[录播 N]
|
||||
|
||||
B --> F[直播流预览窗口]
|
||||
|
||||
F -->|区间生成| G[切片 01]
|
||||
F -->|区间生成| H[切片 02]
|
||||
F -->|区间生成| I[切片 N]
|
||||
|
||||
G --> J[切片预览窗口]
|
||||
|
||||
J -->|字幕压制| K[新切片]
|
||||
|
||||
K --> J
|
||||
|
||||
J -->|投稿| L[Bilibili]
|
||||
|
||||
```
|
||||
13
index_clip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cn">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>切片窗口</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main_clip.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,15 +8,20 @@
|
||||
<link rel="stylesheet" href="shaka-player/youtube-theme.css" />
|
||||
<script src="shaka-player/shaka-player.ui.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="src/live_main.ts"></script>
|
||||
<script type="module" src="src/main_live.ts"></script>
|
||||
<style>
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
width: 12px; /* 设置滑块按钮宽度 */
|
||||
height: 12px; /* 设置滑块按钮高度 */
|
||||
border-radius: 50%; /* 设置为圆形 */
|
||||
width: 12px;
|
||||
/* 设置滑块按钮宽度 */
|
||||
height: 12px;
|
||||
/* 设置滑块按钮高度 */
|
||||
border-radius: 50%;
|
||||
/* 设置为圆形 */
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-face-color: #646464;
|
||||
scrollbar-base-color: #646464;
|
||||
@@ -31,20 +36,25 @@
|
||||
width: 8px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #646464;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track-piece {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
height: 50px;
|
||||
background-color: #666;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: #646464;
|
||||
}
|
||||
24
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bili-shadowreplay",
|
||||
"private": true,
|
||||
"version": "2.5.3",
|
||||
"version": "2.13.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,10 +11,16 @@
|
||||
"tauri": "tauri",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"bump": "node scripts/bump.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.4.1",
|
||||
"@langchain/core": "^0.3.64",
|
||||
"@langchain/deepseek": "^0.1.0",
|
||||
"@langchain/langgraph": "^0.3.10",
|
||||
"@langchain/ollama": "^0.2.3",
|
||||
"@tauri-apps/api": "^2.6.2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-fs": "~2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
@@ -23,7 +29,9 @@
|
||||
"@tauri-apps/plugin-shell": "~2",
|
||||
"@tauri-apps/plugin-sql": "~2",
|
||||
"lucide-svelte": "^0.479.0",
|
||||
"qrcode": "^1.5.4"
|
||||
"marked": "^16.1.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.0.0",
|
||||
@@ -35,6 +43,7 @@
|
||||
"flowbite": "^2.5.1",
|
||||
"flowbite-svelte": "^0.46.16",
|
||||
"flowbite-svelte-icons": "^1.6.1",
|
||||
"mermaid": "^11.9.0",
|
||||
"postcss": "^8.4.21",
|
||||
"svelte": "^3.54.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
@@ -42,8 +51,9 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.6.4",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.0.0",
|
||||
"vitepress": "^1.6.3"
|
||||
"vitepress": "^1.6.3",
|
||||
"vitepress-plugin-mermaid": "^2.0.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/imgs/bilibili.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/imgs/bilibili_avatar.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 246 KiB |
BIN
public/imgs/douyin_avatar.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
58
scripts/bump.cjs
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function updatePackageJson(version) {
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2) + "\n"
|
||||
);
|
||||
console.log(`✅ Updated package.json version to ${version}`);
|
||||
}
|
||||
|
||||
function updateCargoToml(version) {
|
||||
const cargoTomlPath = path.join(process.cwd(), "src-tauri", "Cargo.toml");
|
||||
let cargoToml = fs.readFileSync(cargoTomlPath, "utf8");
|
||||
|
||||
// Update the version in the [package] section
|
||||
cargoToml = cargoToml.replace(/^version = ".*"$/m, `version = "${version}"`);
|
||||
|
||||
fs.writeFileSync(cargoTomlPath, cargoToml);
|
||||
console.log(`✅ Updated Cargo.toml version to ${version}`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("❌ Please provide a version number");
|
||||
console.error("Usage: yarn bump <version>");
|
||||
console.error("Example: yarn bump 3.1.0");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = args[0];
|
||||
|
||||
// Validate version format (simple check)
|
||||
if (!/^\d+\.\d+\.\d+/.test(version)) {
|
||||
console.error(
|
||||
"❌ Invalid version format. Please use semantic versioning (e.g., 3.1.0)"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
updatePackageJson(version);
|
||||
updateCargoToml(version);
|
||||
console.log(`🎉 Successfully bumped version to ${version}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Error updating version:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
2722
src-tauri/Cargo.lock
generated
@@ -1,17 +1,29 @@
|
||||
[workspace]
|
||||
members = ["crates/danmu_stream"]
|
||||
resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "bili-shadowreplay"
|
||||
version = "1.0.0"
|
||||
version = "2.13.8"
|
||||
description = "BiliBili ShadowReplay"
|
||||
authors = ["Xinrea"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
|
||||
[lints.clippy]
|
||||
correctness="deny"
|
||||
suspicious="deny"
|
||||
complexity="deny"
|
||||
style="deny"
|
||||
perf="deny"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
danmu_stream = { path = "crates/danmu_stream" }
|
||||
serde_json = "1.0"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json", "multipart"] }
|
||||
serde_derive = "1.0.158"
|
||||
serde = "1.0.158"
|
||||
sysinfo = "0.32.0"
|
||||
@@ -20,8 +32,6 @@ async-std = "1.12.0"
|
||||
async-ffmpeg-sidecar = "0.0.1"
|
||||
chrono = { version = "0.4.24", features = ["serde"] }
|
||||
toml = "0.7.3"
|
||||
custom_error = "1.9.2"
|
||||
felgens = { git = "https://github.com/Xinrea/felgens.git", tag = "v0.4.3" }
|
||||
regex = "1.7.3"
|
||||
tokio = { version = "1.27.0", features = ["process"] }
|
||||
platform-dirs = "0.3.0"
|
||||
@@ -40,13 +50,19 @@ async-trait = "0.1.87"
|
||||
whisper-rs = "0.14.2"
|
||||
hound = "3.5.1"
|
||||
uuid = { version = "1.4", features = ["v4"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "fs", "limit"] }
|
||||
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
futures-core = "0.3"
|
||||
futures = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tokio-stream = "0.1"
|
||||
clap = { version = "4.5.37", features = ["derive"] }
|
||||
url = "2.5.4"
|
||||
srtparse = "0.2.0"
|
||||
thiserror = "2"
|
||||
deno_core = "0.355"
|
||||
sanitize-filename = "0.6.0"
|
||||
socketioxide = "0.17.2"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
@@ -66,6 +82,7 @@ gui = [
|
||||
"tauri-utils",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-deep-link",
|
||||
"fix-path-env",
|
||||
"tauri-build",
|
||||
]
|
||||
@@ -78,6 +95,7 @@ optional = true
|
||||
[dependencies.tauri-plugin-single-instance]
|
||||
version = "2"
|
||||
optional = true
|
||||
features = ["deep-link"]
|
||||
|
||||
[dependencies.tauri-plugin-dialog]
|
||||
version = "2"
|
||||
@@ -112,6 +130,10 @@ optional = true
|
||||
version = "2"
|
||||
optional = true
|
||||
|
||||
[dependencies.tauri-plugin-deep-link]
|
||||
version = "2"
|
||||
optional = true
|
||||
|
||||
[dependencies.fix-path-env]
|
||||
git = "https://github.com/tauri-apps/fix-path-env-rs"
|
||||
optional = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fn main() {
|
||||
#[cfg(feature = "gui")]
|
||||
tauri_build::build()
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
"identifier": "migrated",
|
||||
"description": "permissions that were migrated from v1",
|
||||
"local": true,
|
||||
"windows": [
|
||||
"main",
|
||||
"Live*"
|
||||
],
|
||||
"windows": ["main", "Live*", "Clip*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"fs:allow-read-file",
|
||||
@@ -19,9 +16,7 @@
|
||||
"fs:allow-exists",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
"**"
|
||||
]
|
||||
"allow": ["**"]
|
||||
},
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
@@ -54,6 +49,12 @@
|
||||
},
|
||||
{
|
||||
"url": "https://*.douyinpic.com/"
|
||||
},
|
||||
{
|
||||
"url": "http://tauri.localhost/*"
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8054/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -70,6 +71,7 @@
|
||||
"shell:default",
|
||||
"sql:default",
|
||||
"os:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ live_end_notify = true
|
||||
clip_notify = true
|
||||
post_notify = true
|
||||
auto_subtitle = false
|
||||
subtitle_generator_type = "whisper_online"
|
||||
whisper_model = "./whisper_model.bin"
|
||||
whisper_prompt = "这是一段中文 你们好"
|
||||
openai_api_key = ""
|
||||
clip_name_format = "[{room_id}][{live_id}][{title}][{created_at}].mp4"
|
||||
|
||||
[auto_generate]
|
||||
|
||||
48
src-tauri/crates/danmu_stream/Cargo.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "danmu_stream"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "danmu_stream"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[example]]
|
||||
name = "bilibili"
|
||||
path = "examples/bilibili.rs"
|
||||
|
||||
[[example]]
|
||||
name = "douyin"
|
||||
path = "examples/douyin.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.27", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
prost = "0.14"
|
||||
chrono = "0.4"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
url = "2.4"
|
||||
md5 = "0.8"
|
||||
regex = "1.9"
|
||||
deno_core = "0.355"
|
||||
pct-str = "2.0"
|
||||
thiserror = "2.0"
|
||||
flate2 = "1.0"
|
||||
scroll = "0.13"
|
||||
scroll_derive = "0.13"
|
||||
brotli = "8.0"
|
||||
http = "1.0"
|
||||
rand = "0.9"
|
||||
urlencoding = "2.1"
|
||||
gzip = "0.1.2"
|
||||
hex = "0.4.3"
|
||||
async-trait = "0.1"
|
||||
uuid = "1"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.14"
|
||||
41
src-tauri/crates/danmu_stream/examples/bilibili.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use danmu_stream::{danmu_stream::DanmuStream, provider::ProviderType, DanmuMessageType};
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
env_logger::init();
|
||||
// Replace these with actual values
|
||||
let room_id = 768756;
|
||||
let cookie = "";
|
||||
let stream = Arc::new(DanmuStream::new(ProviderType::BiliBili, cookie, room_id).await?);
|
||||
|
||||
log::info!("Start to receive danmu messages: {}", cookie);
|
||||
|
||||
let stream_clone = stream.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
log::info!("Waitting for message");
|
||||
if let Ok(Some(msg)) = stream_clone.recv().await {
|
||||
match msg {
|
||||
DanmuMessageType::DanmuMessage(danmu) => {
|
||||
log::info!("Received danmu message: {:?}", danmu.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("Channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = stream.start().await;
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
stream.stop().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
40
src-tauri/crates/danmu_stream/examples/douyin.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use danmu_stream::{danmu_stream::DanmuStream, provider::ProviderType, DanmuMessageType};
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
env_logger::init();
|
||||
// Replace these with actual values
|
||||
let room_id = 7514298567821937427; // Replace with actual Douyin room_id. When live starts, the room_id will be generated, so it's more like a live_id.
|
||||
let cookie = "your_cookie";
|
||||
let stream = Arc::new(DanmuStream::new(ProviderType::Douyin, cookie, room_id).await?);
|
||||
|
||||
log::info!("Start to receive danmu messages");
|
||||
|
||||
let _ = stream.start().await;
|
||||
|
||||
let stream_clone = stream.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Ok(Some(msg)) = stream_clone.recv().await {
|
||||
match msg {
|
||||
DanmuMessageType::DanmuMessage(danmu) => {
|
||||
log::info!("Received danmu message: {:?}", danmu.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!("Channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
stream.stop().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
52
src-tauri/crates/danmu_stream/src/danmu_stream.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
use crate::{
|
||||
provider::{new, DanmuProvider, ProviderType},
|
||||
DanmuMessageType, DanmuStreamError,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DanmuStream {
|
||||
pub provider_type: ProviderType,
|
||||
pub identifier: String,
|
||||
pub room_id: i64,
|
||||
pub provider: Arc<RwLock<Box<dyn DanmuProvider>>>,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
rx: Arc<RwLock<mpsc::UnboundedReceiver<DanmuMessageType>>>,
|
||||
}
|
||||
|
||||
impl DanmuStream {
|
||||
pub async fn new(
|
||||
provider_type: ProviderType,
|
||||
identifier: &str,
|
||||
room_id: i64,
|
||||
) -> Result<Self, DanmuStreamError> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let provider = new(provider_type, identifier, room_id).await?;
|
||||
Ok(Self {
|
||||
provider_type,
|
||||
identifier: identifier.to_string(),
|
||||
room_id,
|
||||
provider: Arc::new(RwLock::new(provider)),
|
||||
tx,
|
||||
rx: Arc::new(RwLock::new(rx)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<(), DanmuStreamError> {
|
||||
self.provider.write().await.start(self.tx.clone()).await
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<(), DanmuStreamError> {
|
||||
self.provider.write().await.stop().await?;
|
||||
// close channel
|
||||
self.rx.write().await.close();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn recv(&self) -> Result<Option<DanmuMessageType>, DanmuStreamError> {
|
||||
Ok(self.rx.write().await.recv().await)
|
||||
}
|
||||
}
|
||||
40
src-tauri/crates/danmu_stream/src/http_client.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
use crate::DanmuStreamError;
|
||||
|
||||
pub struct ApiClient {
|
||||
client: reqwest::Client,
|
||||
header: HeaderMap,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(cookies: &str) -> Self {
|
||||
let mut header = HeaderMap::new();
|
||||
header.insert("cookie", cookies.parse().unwrap());
|
||||
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
header,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
&self,
|
||||
url: &str,
|
||||
query: Option<&[(&str, &str)]>,
|
||||
) -> Result<reqwest::Response, DanmuStreamError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.query(query.unwrap_or_default())
|
||||
.headers(self.header.clone())
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
39
src-tauri/crates/danmu_stream/src/lib.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
pub mod danmu_stream;
|
||||
mod http_client;
|
||||
pub mod provider;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DanmuStreamError {
|
||||
#[error("HttpError {0:?}")]
|
||||
HttpError(#[from] reqwest::Error),
|
||||
#[error("ParseError {0:?}")]
|
||||
ParseError(#[from] url::ParseError),
|
||||
#[error("WebsocketError {err}")]
|
||||
WebsocketError { err: String },
|
||||
#[error("PackError {err}")]
|
||||
PackError { err: String },
|
||||
#[error("UnsupportProto {proto}")]
|
||||
UnsupportProto { proto: u16 },
|
||||
#[error("MessageParseError {err}")]
|
||||
MessageParseError { err: String },
|
||||
#[error("InvalidIdentifier {err}")]
|
||||
InvalidIdentifier { err: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DanmuMessageType {
|
||||
DanmuMessage(DanmuMessage),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DanmuMessage {
|
||||
pub room_id: i64,
|
||||
pub user_id: u64,
|
||||
pub user_name: String,
|
||||
pub message: String,
|
||||
pub color: u32,
|
||||
/// timestamp in milliseconds
|
||||
pub timestamp: i64,
|
||||
}
|
||||
443
src-tauri/crates/danmu_stream/src/provider/bilibili.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
mod dannmu_msg;
|
||||
mod interact_word;
|
||||
mod pack;
|
||||
mod send_gift;
|
||||
mod stream;
|
||||
mod super_chat;
|
||||
|
||||
use std::{sync::Arc, time::SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||
use log::{error, info};
|
||||
use pct_str::{PctString, URIReserved};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::{
|
||||
sync::{mpsc, RwLock},
|
||||
time::{sleep, Duration},
|
||||
};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
|
||||
use crate::{
|
||||
http_client::ApiClient,
|
||||
provider::{DanmuMessageType, DanmuProvider},
|
||||
DanmuStreamError,
|
||||
};
|
||||
|
||||
type WsReadType = futures_util::stream::SplitStream<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
>;
|
||||
|
||||
type WsWriteType = futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
Message,
|
||||
>;
|
||||
|
||||
pub struct BiliDanmu {
|
||||
client: ApiClient,
|
||||
room_id: i64,
|
||||
user_id: i64,
|
||||
stop: Arc<RwLock<bool>>,
|
||||
write: Arc<RwLock<Option<WsWriteType>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DanmuProvider for BiliDanmu {
|
||||
async fn new(cookie: &str, room_id: i64) -> Result<Self, DanmuStreamError> {
|
||||
// find DedeUserID=<user_id> in cookie str
|
||||
let user_id = BiliDanmu::parse_user_id(cookie)?;
|
||||
// add buvid3 to cookie
|
||||
let cookie = format!("{};buvid3={}", cookie, uuid::Uuid::new_v4());
|
||||
let client = ApiClient::new(&cookie);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
user_id,
|
||||
room_id,
|
||||
stop: Arc::new(RwLock::new(false)),
|
||||
write: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
let mut retry_count = 0;
|
||||
const RETRY_DELAY: Duration = Duration::from_secs(5);
|
||||
info!(
|
||||
"Bilibili WebSocket connection started, room_id: {}",
|
||||
self.room_id
|
||||
);
|
||||
|
||||
loop {
|
||||
if *self.stop.read().await {
|
||||
info!(
|
||||
"Bilibili WebSocket connection stopped, room_id: {}",
|
||||
self.room_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
match self.connect_and_handle(tx.clone()).await {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Bilibili WebSocket connection closed normally, room_id: {}",
|
||||
self.room_id
|
||||
);
|
||||
retry_count = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Bilibili WebSocket connection error, room_id: {}, error: {}",
|
||||
self.room_id, e
|
||||
);
|
||||
retry_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Retrying connection in {} seconds... (Attempt {}), room_id: {}",
|
||||
RETRY_DELAY.as_secs(),
|
||||
retry_count,
|
||||
self.room_id
|
||||
);
|
||||
tokio::time::sleep(RETRY_DELAY).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), DanmuStreamError> {
|
||||
*self.stop.write().await = true;
|
||||
if let Some(mut write) = self.write.write().await.take() {
|
||||
if let Err(e) = write.close().await {
|
||||
error!("Failed to close WebSocket connection: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BiliDanmu {
|
||||
async fn connect_and_handle(
|
||||
&self,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
let wbi_key = self.get_wbi_key().await?;
|
||||
let real_room = self.get_real_room(&wbi_key, self.room_id).await?;
|
||||
let danmu_info = self.get_danmu_info(&wbi_key, real_room).await?;
|
||||
let ws_hosts = danmu_info.data.host_list.clone();
|
||||
let mut conn = None;
|
||||
log::debug!("ws_hosts: {:?}", ws_hosts);
|
||||
// try to connect to ws_hsots, once success, send the token to the tx
|
||||
for i in ws_hosts {
|
||||
let host = format!("wss://{}/sub", i.host);
|
||||
match connect_async(&host).await {
|
||||
Ok((c, _)) => {
|
||||
conn = Some(c);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Connect ws host: {} has error, trying next host ...\n{:?}\n{:?}",
|
||||
host, i, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let conn = conn.ok_or(DanmuStreamError::WebsocketError {
|
||||
err: "Failed to connect to ws host".into(),
|
||||
})?;
|
||||
|
||||
let (write, read) = conn.split();
|
||||
*self.write.write().await = Some(write);
|
||||
|
||||
let json = serde_json::to_string(&WsSend {
|
||||
roomid: real_room,
|
||||
key: danmu_info.data.token,
|
||||
uid: self.user_id,
|
||||
protover: 3,
|
||||
platform: "web".to_string(),
|
||||
t: 2,
|
||||
})
|
||||
.map_err(|e| DanmuStreamError::WebsocketError { err: e.to_string() })?;
|
||||
|
||||
let json = pack::encode(&json, 7);
|
||||
if let Some(write) = self.write.write().await.as_mut() {
|
||||
write
|
||||
.send(Message::binary(json))
|
||||
.await
|
||||
.map_err(|e| DanmuStreamError::WebsocketError { err: e.to_string() })?;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
v = BiliDanmu::send_heartbeat_packets(Arc::clone(&self.write)) => v,
|
||||
v = BiliDanmu::recv(read, tx, Arc::clone(&self.stop)) => v
|
||||
}?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_heartbeat_packets(
|
||||
write: Arc<RwLock<Option<WsWriteType>>>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
loop {
|
||||
if let Some(write) = write.write().await.as_mut() {
|
||||
write
|
||||
.send(Message::binary(pack::encode("", 2)))
|
||||
.await
|
||||
.map_err(|e| DanmuStreamError::WebsocketError { err: e.to_string() })?;
|
||||
}
|
||||
sleep(Duration::from_secs(30)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn recv(
|
||||
mut read: WsReadType,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
stop: Arc<RwLock<bool>>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
while let Ok(Some(msg)) = read.try_next().await {
|
||||
if *stop.read().await {
|
||||
log::info!("Stopping bilibili danmu stream");
|
||||
break;
|
||||
}
|
||||
let data = msg.into_data();
|
||||
|
||||
if !data.is_empty() {
|
||||
let s = pack::build_pack(&data);
|
||||
|
||||
if let Ok(msgs) = s {
|
||||
for i in msgs {
|
||||
let ws = stream::WsStreamCtx::new(&i);
|
||||
if let Ok(ws) = ws {
|
||||
match ws.match_msg() {
|
||||
Ok(v) => {
|
||||
log::debug!("Received message: {:?}", v);
|
||||
tx.send(v).map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
log::trace!(
|
||||
"This message parsing is not yet supported:\nMessage: {i}\nErr: {e:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("{}", ws.unwrap_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_danmu_info(
|
||||
&self,
|
||||
wbi_key: &str,
|
||||
room_id: i64,
|
||||
) -> Result<DanmuInfo, DanmuStreamError> {
|
||||
let params = self
|
||||
.get_sign(
|
||||
wbi_key,
|
||||
serde_json::json!({
|
||||
"id": room_id,
|
||||
"type": 0,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let resp = self
|
||||
.client
|
||||
.get(
|
||||
&format!(
|
||||
"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?{}",
|
||||
params
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.json::<DanmuInfo>()
|
||||
.await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn get_real_room(&self, wbi_key: &str, room_id: i64) -> Result<i64, DanmuStreamError> {
|
||||
let params = self
|
||||
.get_sign(
|
||||
wbi_key,
|
||||
serde_json::json!({
|
||||
"id": room_id,
|
||||
"from": "room",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let resp = self
|
||||
.client
|
||||
.get(
|
||||
&format!(
|
||||
"https://api.live.bilibili.com/room/v1/Room/room_init?{}",
|
||||
params
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.json::<RoomInit>()
|
||||
.await?
|
||||
.data
|
||||
.room_id;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn parse_user_id(cookie: &str) -> Result<i64, DanmuStreamError> {
|
||||
let mut user_id = None;
|
||||
|
||||
// find DedeUserID=<user_id> in cookie str
|
||||
let re = Regex::new(r"DedeUserID=(\d+)").unwrap();
|
||||
if let Some(captures) = re.captures(cookie) {
|
||||
if let Some(user) = captures.get(1) {
|
||||
user_id = Some(user.as_str().parse::<i64>().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(user_id) = user_id {
|
||||
Ok(user_id)
|
||||
} else {
|
||||
Err(DanmuStreamError::InvalidIdentifier {
|
||||
err: format!("Failed to find user_id in cookie: {cookie}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_wbi_key(&self) -> Result<String, DanmuStreamError> {
|
||||
let nav_info: serde_json::Value = self
|
||||
.client
|
||||
.get("https://api.bilibili.com/x/web-interface/nav", None)
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let re = Regex::new(r"wbi/(.*).png").unwrap();
|
||||
let img = re
|
||||
.captures(nav_info["data"]["wbi_img"]["img_url"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let sub = re
|
||||
.captures(nav_info["data"]["wbi_img"]["sub_url"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let raw_string = format!("{}{}", img, sub);
|
||||
Ok(raw_string)
|
||||
}
|
||||
|
||||
pub async fn get_sign(
|
||||
&self,
|
||||
wbi_key: &str,
|
||||
mut parameters: serde_json::Value,
|
||||
) -> Result<String, DanmuStreamError> {
|
||||
let table = vec![
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42,
|
||||
19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60,
|
||||
51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
|
||||
];
|
||||
let raw_string = wbi_key;
|
||||
let mut encoded = Vec::new();
|
||||
table.into_iter().for_each(|x| {
|
||||
if x < raw_string.len() {
|
||||
encoded.push(raw_string.as_bytes()[x]);
|
||||
}
|
||||
});
|
||||
// only keep 32 bytes of encoded
|
||||
encoded = encoded[0..32].to_vec();
|
||||
let encoded = String::from_utf8(encoded).unwrap();
|
||||
// Timestamp in seconds
|
||||
let wts = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
parameters
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("wts".to_owned(), serde_json::Value::String(wts.to_string()));
|
||||
// Get all keys from parameters into vec
|
||||
let mut keys = parameters
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(|x| x.to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
// sort keys
|
||||
keys.sort();
|
||||
let mut params = String::new();
|
||||
keys.iter().for_each(|x| {
|
||||
params.push_str(x);
|
||||
params.push('=');
|
||||
// Convert value to string based on its type
|
||||
let value = match parameters.get(x).unwrap() {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
_ => "".to_string(),
|
||||
};
|
||||
// Value filters !'()* characters
|
||||
let value = value.replace(['!', '\'', '(', ')', '*'], "");
|
||||
let value = PctString::encode(value.chars(), URIReserved);
|
||||
params.push_str(value.as_str());
|
||||
// add & if not last
|
||||
if x != keys.last().unwrap() {
|
||||
params.push('&');
|
||||
}
|
||||
});
|
||||
// md5 params+encoded
|
||||
let w_rid = md5::compute(params.to_string() + encoded.as_str());
|
||||
let params = params + format!("&w_rid={:x}", w_rid).as_str();
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WsSend {
|
||||
uid: i64,
|
||||
roomid: i64,
|
||||
key: String,
|
||||
protover: u32,
|
||||
platform: String,
|
||||
#[serde(rename = "type")]
|
||||
t: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DanmuInfo {
|
||||
pub data: DanmuInfoData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DanmuInfoData {
|
||||
pub token: String,
|
||||
pub host_list: Vec<WsHost>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct WsHost {
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct RoomInit {
|
||||
data: RoomInitData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct RoomInitData {
|
||||
room_id: i64,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::stream::WsStreamCtx;
|
||||
|
||||
use crate::DanmuStreamError;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct BiliDanmuMessage {
|
||||
pub uid: u64,
|
||||
pub username: String,
|
||||
pub msg: String,
|
||||
pub fan: Option<String>,
|
||||
pub fan_level: Option<u64>,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl BiliDanmuMessage {
|
||||
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
|
||||
let info = ctx
|
||||
.info
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "info is None".to_string(),
|
||||
})?;
|
||||
|
||||
let array_2 = info
|
||||
.get(2)
|
||||
.and_then(|x| x.as_array())
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "array_2 is None".to_string(),
|
||||
})?
|
||||
.to_owned();
|
||||
|
||||
let uid = array_2.first().and_then(|x| x.as_u64()).ok_or_else(|| {
|
||||
DanmuStreamError::MessageParseError {
|
||||
err: "uid is None".to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let username = array_2
|
||||
.get(1)
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "username is None".to_string(),
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let msg = info
|
||||
.get(1)
|
||||
.and_then(|x| x.as_str())
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "msg is None".to_string(),
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let array_3 = info
|
||||
.get(3)
|
||||
.and_then(|x| x.as_array())
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "array_3 is None".to_string(),
|
||||
})?
|
||||
.to_owned();
|
||||
|
||||
let fan = array_3
|
||||
.get(1)
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|x| x.to_owned());
|
||||
|
||||
let fan_level = array_3.first().and_then(|x| x.as_u64());
|
||||
|
||||
let timestamp = info
|
||||
.first()
|
||||
.and_then(|x| x.as_array())
|
||||
.and_then(|x| x.get(4))
|
||||
.and_then(|x| x.as_i64())
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "timestamp is None".to_string(),
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
uid,
|
||||
username,
|
||||
msg,
|
||||
fan,
|
||||
fan_level,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::stream::WsStreamCtx;
|
||||
|
||||
use crate::DanmuStreamError;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct InteractWord {
|
||||
pub uid: u64,
|
||||
pub uname: String,
|
||||
pub fan: Option<String>,
|
||||
pub fan_level: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl InteractWord {
|
||||
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
|
||||
let data = ctx
|
||||
.data
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "data is None".to_string(),
|
||||
})?;
|
||||
|
||||
let uname = data
|
||||
.uname
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "uname is None".to_string(),
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let uid = data
|
||||
.uid
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "uid is None".to_string(),
|
||||
})?
|
||||
.as_u64()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "uid is None".to_string(),
|
||||
})?;
|
||||
|
||||
let fan = data
|
||||
.fans_medal
|
||||
.as_ref()
|
||||
.and_then(|x| x.medal_name.to_owned());
|
||||
|
||||
let fan = if fan == Some("".to_string()) {
|
||||
None
|
||||
} else {
|
||||
fan
|
||||
};
|
||||
|
||||
let fan_level = data.fans_medal.as_ref().and_then(|x| x.medal_level);
|
||||
|
||||
let fan_level = if fan_level == Some(0) {
|
||||
None
|
||||
} else {
|
||||
fan_level
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
uid,
|
||||
uname,
|
||||
fan,
|
||||
fan_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
161
src-tauri/crates/danmu_stream/src/provider/bilibili/pack.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
// This file is copied from https://github.com/eatradish/felgens/blob/master/src/pack.rs
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use flate2::read::ZlibDecoder;
|
||||
use scroll::Pread;
|
||||
use scroll_derive::Pread;
|
||||
|
||||
use crate::DanmuStreamError;
|
||||
|
||||
#[derive(Debug, Pread, Clone)]
|
||||
struct BilibiliPackHeader {
|
||||
pack_len: u32,
|
||||
_header_len: u16,
|
||||
ver: u16,
|
||||
_op: u32,
|
||||
_seq: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Pread)]
|
||||
struct PackHotCount {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
type BilibiliPackCtx<'a> = (BilibiliPackHeader, &'a [u8]);
|
||||
|
||||
fn pack(buffer: &[u8]) -> Result<BilibiliPackCtx<'_>, DanmuStreamError> {
|
||||
let data = buffer
|
||||
.pread_with(0, scroll::BE)
|
||||
.map_err(|e: scroll::Error| DanmuStreamError::PackError { err: e.to_string() })?;
|
||||
|
||||
let buf = &buffer[16..];
|
||||
|
||||
Ok((data, buf))
|
||||
}
|
||||
|
||||
fn write_int(buffer: &[u8], start: usize, val: u32) -> Vec<u8> {
|
||||
let val_bytes = val.to_be_bytes();
|
||||
|
||||
let mut buf = buffer.to_vec();
|
||||
|
||||
for (i, c) in val_bytes.iter().enumerate() {
|
||||
buf[start + i] = *c;
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn encode(s: &str, op: u8) -> Vec<u8> {
|
||||
let data = s.as_bytes();
|
||||
let packet_len = 16 + data.len();
|
||||
let header = vec![0, 0, 0, 0, 0, 16, 0, 1, 0, 0, 0, op, 0, 0, 0, 1];
|
||||
|
||||
let header = write_int(&header, 0, packet_len as u32);
|
||||
|
||||
[&header, data].concat()
|
||||
}
|
||||
|
||||
pub fn build_pack(buf: &[u8]) -> Result<Vec<String>, DanmuStreamError> {
|
||||
let ctx = pack(buf)?;
|
||||
let msgs = decode(ctx)?;
|
||||
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
fn get_hot_count(body: &[u8]) -> Result<u32, DanmuStreamError> {
|
||||
let count = body
|
||||
.pread_with::<PackHotCount>(0, scroll::BE)
|
||||
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?
|
||||
.count;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn zlib_decode(body: &[u8]) -> Result<(BilibiliPackHeader, Vec<u8>), DanmuStreamError> {
|
||||
let mut buf = vec![];
|
||||
let mut z = ZlibDecoder::new(body);
|
||||
z.read_to_end(&mut buf)
|
||||
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
|
||||
|
||||
let ctx = pack(&buf)?;
|
||||
let header = ctx.0;
|
||||
let buf = ctx.1.to_vec();
|
||||
|
||||
Ok((header, buf))
|
||||
}
|
||||
|
||||
fn decode(ctx: BilibiliPackCtx) -> Result<Vec<String>, DanmuStreamError> {
|
||||
let (mut header, body) = ctx;
|
||||
|
||||
let mut buf = body.to_vec();
|
||||
|
||||
loop {
|
||||
(header, buf) = match header.ver {
|
||||
2 => zlib_decode(&buf)?,
|
||||
3 => brotli_decode(&buf)?,
|
||||
0 | 1 => break,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
let msgs = match header.ver {
|
||||
0 => split_msgs(buf, header)?,
|
||||
1 => vec![format!("{{\"count\": {}}}", get_hot_count(&buf)?)],
|
||||
x => return Err(DanmuStreamError::UnsupportProto { proto: x }),
|
||||
};
|
||||
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
fn split_msgs(buf: Vec<u8>, header: BilibiliPackHeader) -> Result<Vec<String>, DanmuStreamError> {
|
||||
let mut buf = buf;
|
||||
let mut header = header;
|
||||
let mut msgs = vec![];
|
||||
let mut offset = 0;
|
||||
let buf_len = buf.len();
|
||||
|
||||
msgs.push(
|
||||
std::str::from_utf8(&buf[..(header.pack_len - 16) as usize])
|
||||
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?
|
||||
.to_string(),
|
||||
);
|
||||
buf = buf[(header.pack_len - 16) as usize..].to_vec();
|
||||
offset += header.pack_len - 16;
|
||||
|
||||
while offset != buf_len as u32 {
|
||||
let ctx = pack(&buf).map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
|
||||
|
||||
header = ctx.0;
|
||||
buf = ctx.1.to_vec();
|
||||
|
||||
msgs.push(
|
||||
std::str::from_utf8(&buf[..(header.pack_len - 16) as usize])
|
||||
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
buf = buf[(header.pack_len - 16) as usize..].to_vec();
|
||||
|
||||
offset += header.pack_len;
|
||||
}
|
||||
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
fn brotli_decode(body: &[u8]) -> Result<(BilibiliPackHeader, Vec<u8>), DanmuStreamError> {
|
||||
let mut reader = brotli::Decompressor::new(body, 4096);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
|
||||
reader
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
|
||||
|
||||
let ctx = pack(&buf).map_err(|e| DanmuStreamError::PackError { err: e.to_string() })?;
|
||||
|
||||
let header = ctx.0;
|
||||
let buf = ctx.1.to_vec();
|
||||
|
||||
Ok((header, buf))
|
||||
}
|
||||
117
src-tauri/crates/danmu_stream/src/provider/bilibili/send_gift.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::stream::WsStreamCtx;
|
||||
|
||||
use crate::DanmuStreamError;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SendGift {
|
||||
pub action: String,
|
||||
pub gift_name: String,
|
||||
pub num: u64,
|
||||
pub uname: String,
|
||||
pub uid: u64,
|
||||
pub medal_name: Option<String>,
|
||||
pub medal_level: Option<u32>,
|
||||
pub price: u32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl SendGift {
|
||||
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
|
||||
let data = ctx
|
||||
.data
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "data is None".to_string(),
|
||||
})?;
|
||||
|
||||
let action = data
|
||||
.action
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "action is None".to_string(),
|
||||
})?
|
||||
.to_owned();
|
||||
|
||||
let combo_send = data.combo_send.clone();
|
||||
|
||||
let gift_name = if let Some(gift) = data.gift_name.as_ref() {
|
||||
gift.to_owned()
|
||||
} else if let Some(gift) = combo_send.clone().and_then(|x| x.gift_name) {
|
||||
gift
|
||||
} else {
|
||||
return Err(DanmuStreamError::MessageParseError {
|
||||
err: "gift_name is None".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
let num = if let Some(num) = combo_send.clone().and_then(|x| x.combo_num) {
|
||||
num
|
||||
} else if let Some(num) = data.num {
|
||||
num
|
||||
} else if let Some(num) = combo_send.and_then(|x| x.gift_num) {
|
||||
num
|
||||
} else {
|
||||
return Err(DanmuStreamError::MessageParseError {
|
||||
err: "num is None".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
let uname = data
|
||||
.uname
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "uname is None".to_string(),
|
||||
})?
|
||||
.to_owned();
|
||||
|
||||
let uid = data
|
||||
.uid
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "uid is None".to_string(),
|
||||
})?
|
||||
.as_u64()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "uid is None".to_string(),
|
||||
})?;
|
||||
|
||||
let medal_name = data
|
||||
.medal_info
|
||||
.as_ref()
|
||||
.and_then(|x| x.medal_name.to_owned());
|
||||
|
||||
let medal_level = data.medal_info.as_ref().and_then(|x| x.medal_level);
|
||||
|
||||
let medal_name = if medal_name == Some("".to_string()) {
|
||||
None
|
||||
} else {
|
||||
medal_name
|
||||
};
|
||||
|
||||
let medal_level = if medal_level == Some(0) {
|
||||
None
|
||||
} else {
|
||||
medal_level
|
||||
};
|
||||
|
||||
let price = data
|
||||
.price
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "price is None".to_string(),
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
action,
|
||||
gift_name,
|
||||
num,
|
||||
uname,
|
||||
uid,
|
||||
medal_name,
|
||||
medal_level,
|
||||
price,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::dannmu_msg::BiliDanmuMessage;
|
||||
|
||||
use crate::{provider::DanmuMessageType, DanmuMessage, DanmuStreamError};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct WsStreamCtx {
|
||||
pub cmd: Option<String>,
|
||||
pub info: Option<Vec<Value>>,
|
||||
pub data: Option<WsStreamCtxData>,
|
||||
#[serde(flatten)]
|
||||
_v: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct WsStreamCtxData {
|
||||
pub message: Option<String>,
|
||||
pub price: Option<u32>,
|
||||
pub start_time: Option<u64>,
|
||||
pub time: Option<u32>,
|
||||
pub uid: Option<Value>,
|
||||
pub user_info: Option<WsStreamCtxDataUser>,
|
||||
pub medal_info: Option<WsStreamCtxDataMedalInfo>,
|
||||
pub uname: Option<String>,
|
||||
pub fans_medal: Option<WsStreamCtxDataMedalInfo>,
|
||||
pub action: Option<String>,
|
||||
#[serde(rename = "giftName")]
|
||||
pub gift_name: Option<String>,
|
||||
pub num: Option<u64>,
|
||||
pub combo_num: Option<u64>,
|
||||
pub gift_num: Option<u64>,
|
||||
pub combo_send: Box<Option<WsStreamCtxData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct WsStreamCtxDataMedalInfo {
|
||||
pub medal_name: Option<String>,
|
||||
pub medal_level: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct WsStreamCtxDataUser {
|
||||
pub face: String,
|
||||
pub uname: String,
|
||||
}
|
||||
|
||||
impl WsStreamCtx {
|
||||
pub fn new(s: &str) -> Result<Self, DanmuStreamError> {
|
||||
serde_json::from_str(s).map_err(|_| DanmuStreamError::MessageParseError {
|
||||
err: "Failed to parse message".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn match_msg(&self) -> Result<DanmuMessageType, DanmuStreamError> {
|
||||
let cmd = self.handle_cmd();
|
||||
|
||||
let danmu_msg = match cmd {
|
||||
Some(c) if c.contains("DANMU_MSG") => Some(BiliDanmuMessage::new_from_ctx(self)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(danmu_msg) = danmu_msg {
|
||||
Ok(DanmuMessageType::DanmuMessage(DanmuMessage {
|
||||
room_id: 0,
|
||||
user_id: danmu_msg.uid,
|
||||
user_name: danmu_msg.username,
|
||||
message: danmu_msg.msg,
|
||||
color: 0,
|
||||
timestamp: danmu_msg.timestamp,
|
||||
}))
|
||||
} else {
|
||||
Err(DanmuStreamError::MessageParseError {
|
||||
err: "Unknown message".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cmd(&self) -> Option<&str> {
|
||||
// handle DANMU_MSG:4:0:2:2:2:0
|
||||
let cmd = if let Some(c) = self.cmd.as_deref() {
|
||||
if c.starts_with("DM_INTERACTION") {
|
||||
Some("DANMU_MSG")
|
||||
} else {
|
||||
Some(c)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
cmd
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::stream::WsStreamCtx;
|
||||
|
||||
use crate::DanmuStreamError;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SuperChatMessage {
|
||||
pub uname: String,
|
||||
pub uid: u64,
|
||||
pub face: String,
|
||||
pub price: u32,
|
||||
pub start_time: u64,
|
||||
pub time: u32,
|
||||
pub msg: String,
|
||||
pub medal_name: Option<String>,
|
||||
pub medal_level: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl SuperChatMessage {
|
||||
pub fn new_from_ctx(ctx: &WsStreamCtx) -> Result<Self, DanmuStreamError> {
|
||||
let data = ctx
|
||||
.data
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "data is None".to_string(),
|
||||
})?;
|
||||
|
||||
let user_info =
|
||||
data.user_info
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "user_info is None".to_string(),
|
||||
})?;
|
||||
|
||||
let uname = user_info.uname.to_owned();
|
||||
|
||||
let uid = data.uid.as_ref().and_then(|x| x.as_u64()).ok_or_else(|| {
|
||||
DanmuStreamError::MessageParseError {
|
||||
err: "uid is None".to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let face = user_info.face.to_owned();
|
||||
|
||||
let price = data
|
||||
.price
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "price is None".to_string(),
|
||||
})?;
|
||||
|
||||
let start_time = data
|
||||
.start_time
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "start_time is None".to_string(),
|
||||
})?;
|
||||
|
||||
let time = data
|
||||
.time
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "time is None".to_string(),
|
||||
})?;
|
||||
|
||||
let msg = data
|
||||
.message
|
||||
.as_ref()
|
||||
.ok_or_else(|| DanmuStreamError::MessageParseError {
|
||||
err: "message is None".to_string(),
|
||||
})?
|
||||
.to_owned();
|
||||
|
||||
let medal = data
|
||||
.medal_info
|
||||
.as_ref()
|
||||
.map(|x| (x.medal_name.to_owned(), x.medal_level.to_owned()));
|
||||
|
||||
let medal_name = medal.as_ref().and_then(|(name, _)| name.to_owned());
|
||||
|
||||
let medal_level = medal.and_then(|(_, level)| level);
|
||||
|
||||
Ok(Self {
|
||||
uname,
|
||||
uid,
|
||||
face,
|
||||
price,
|
||||
start_time,
|
||||
time,
|
||||
msg,
|
||||
medal_name,
|
||||
medal_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
457
src-tauri/crates/danmu_stream/src/provider/douyin.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
mod messages;
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use deno_core::v8;
|
||||
use deno_core::JsRuntime;
|
||||
use deno_core::RuntimeOptions;
|
||||
use flate2::read::GzDecoder;
|
||||
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||
use log::debug;
|
||||
use log::{error, info};
|
||||
use messages::*;
|
||||
use prost::bytes::Bytes;
|
||||
use prost::Message;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_tungstenite::{
|
||||
connect_async, tungstenite::Message as WsMessage, MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
|
||||
use crate::{provider::DanmuProvider, DanmuMessage, DanmuMessageType, DanmuStreamError};
|
||||
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
type WsReadType = futures_util::stream::SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>;
|
||||
type WsWriteType =
|
||||
futures_util::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, WsMessage>;
|
||||
|
||||
pub struct DouyinDanmu {
|
||||
room_id: i64,
|
||||
cookie: String,
|
||||
stop: Arc<RwLock<bool>>,
|
||||
write: Arc<RwLock<Option<WsWriteType>>>,
|
||||
}
|
||||
|
||||
impl DouyinDanmu {
|
||||
async fn connect_and_handle(
|
||||
&self,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
let url = self.get_wss_url().await?;
|
||||
|
||||
let request = tokio_tungstenite::tungstenite::http::Request::builder()
|
||||
.uri(url)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::COOKIE,
|
||||
self.cookie.as_str(),
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::REFERER,
|
||||
"https://live.douyin.com/",
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::USER_AGENT,
|
||||
USER_AGENT,
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::HOST,
|
||||
"webcast5-ws-web-hl.douyin.com",
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::UPGRADE,
|
||||
"websocket",
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::CONNECTION,
|
||||
"Upgrade",
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_VERSION,
|
||||
"13",
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_EXTENSIONS,
|
||||
"permessage-deflate; client_max_window_bits",
|
||||
)
|
||||
.header(
|
||||
tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_KEY,
|
||||
"V1Yza5x1zcfkembl6u/0Pg==",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
|
||||
let (ws_stream, response) =
|
||||
connect_async(request)
|
||||
.await
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to connect to douyin websocket: {}", e),
|
||||
})?;
|
||||
|
||||
// Log the response status for debugging
|
||||
info!("WebSocket connection response: {:?}", response.status());
|
||||
|
||||
let (write, read) = ws_stream.split();
|
||||
*self.write.write().await = Some(write);
|
||||
self.handle_connection(read, tx).await
|
||||
}
|
||||
|
||||
async fn get_wss_url(&self) -> Result<String, DanmuStreamError> {
|
||||
// Create a new V8 runtime
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions::default());
|
||||
|
||||
// Add global CryptoJS object
|
||||
let crypto_js = include_str!("douyin/crypto-js.min.js");
|
||||
runtime
|
||||
.execute_script(
|
||||
"<crypto-js.min.js>",
|
||||
deno_core::FastString::from_static(crypto_js),
|
||||
)
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to execute crypto-js: {}", e),
|
||||
})?;
|
||||
|
||||
// Load and execute the sign.js file
|
||||
let js_code = include_str!("douyin/webmssdk.js");
|
||||
runtime
|
||||
.execute_script("<sign.js>", deno_core::FastString::from_static(js_code))
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to execute JavaScript: {}", e),
|
||||
})?;
|
||||
|
||||
// Call the get_wss_url function
|
||||
let sign_call = format!("get_wss_url(\"{}\")", self.room_id);
|
||||
let result = runtime
|
||||
.execute_script("<sign_call>", deno_core::FastString::from(sign_call))
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to execute JavaScript: {}", e),
|
||||
})?;
|
||||
|
||||
// Get the result from the V8 runtime
|
||||
let scope = &mut runtime.handle_scope();
|
||||
let local = v8::Local::new(scope, result);
|
||||
let url = local.to_string(scope).unwrap().to_rust_string_lossy(scope);
|
||||
|
||||
debug!("Douyin wss url: {}", url);
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
&self,
|
||||
mut read: WsReadType,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
// Start heartbeat task with error handling
|
||||
let (tx_write, mut _rx_write) = mpsc::channel(32);
|
||||
let tx_write_clone = tx_write.clone();
|
||||
let stop = Arc::clone(&self.stop);
|
||||
let heartbeat_handle = tokio::spawn(async move {
|
||||
let mut last_heartbeat = SystemTime::now();
|
||||
let mut consecutive_failures = 0;
|
||||
const MAX_FAILURES: u32 = 3;
|
||||
|
||||
loop {
|
||||
if *stop.read().await {
|
||||
log::info!("Stopping douyin danmu stream");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(HEARTBEAT_INTERVAL).await;
|
||||
|
||||
match Self::send_heartbeat(&tx_write_clone).await {
|
||||
Ok(_) => {
|
||||
last_heartbeat = SystemTime::now();
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to send heartbeat: {}", e);
|
||||
consecutive_failures += 1;
|
||||
|
||||
if consecutive_failures >= MAX_FAILURES {
|
||||
error!("Too many consecutive heartbeat failures, closing connection");
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've exceeded the maximum time without a successful heartbeat
|
||||
if let Ok(duration) = last_heartbeat.elapsed() {
|
||||
if duration > HEARTBEAT_INTERVAL * 2 {
|
||||
error!("No successful heartbeat for too long, closing connection");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Main message handling loop
|
||||
let room_id = self.room_id;
|
||||
let stop = Arc::clone(&self.stop);
|
||||
let write = Arc::clone(&self.write);
|
||||
let message_handle = tokio::spawn(async move {
|
||||
while let Some(msg) =
|
||||
read.try_next()
|
||||
.await
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to read message: {}", e),
|
||||
})?
|
||||
{
|
||||
if *stop.read().await {
|
||||
log::info!("Stopping douyin danmu stream");
|
||||
break;
|
||||
}
|
||||
|
||||
match msg {
|
||||
WsMessage::Binary(data) => {
|
||||
if let Ok(Some(ack)) = handle_binary_message(&data, &tx, room_id).await {
|
||||
if let Some(write) = write.write().await.as_mut() {
|
||||
if let Err(e) =
|
||||
write.send(WsMessage::binary(ack.encode_to_vec())).await
|
||||
{
|
||||
error!("Failed to send ack: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WsMessage::Close(_) => {
|
||||
info!("WebSocket connection closed");
|
||||
break;
|
||||
}
|
||||
WsMessage::Ping(data) => {
|
||||
// Respond to ping with pong
|
||||
if let Err(e) = tx_write.send(WsMessage::Pong(data)).await {
|
||||
error!("Failed to send pong: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok::<(), DanmuStreamError>(())
|
||||
});
|
||||
|
||||
// Wait for either the heartbeat or message handling to complete
|
||||
tokio::select! {
|
||||
result = heartbeat_handle => {
|
||||
if let Err(e) = result {
|
||||
error!("Heartbeat task failed: {}", e);
|
||||
}
|
||||
}
|
||||
result = message_handle => {
|
||||
if let Err(e) = result {
|
||||
error!("Message handling task failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_heartbeat(tx: &mpsc::Sender<WsMessage>) -> Result<(), DanmuStreamError> {
|
||||
// heartbeat message: 3A 02 68 62
|
||||
tx.send(WsMessage::binary(vec![0x3A, 0x02, 0x68, 0x62]))
|
||||
.await
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to send heartbeat message: {}", e),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_binary_message(
|
||||
data: &[u8],
|
||||
tx: &mpsc::UnboundedSender<DanmuMessageType>,
|
||||
room_id: i64,
|
||||
) -> Result<Option<PushFrame>, DanmuStreamError> {
|
||||
// First decode the PushFrame
|
||||
let push_frame = PushFrame::decode(Bytes::from(data.to_vec())).map_err(|e| {
|
||||
DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decode PushFrame: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Decompress the payload
|
||||
let mut decoder = GzDecoder::new(push_frame.payload.as_slice());
|
||||
let mut decompressed = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decompress payload: {}", e),
|
||||
})?;
|
||||
|
||||
// Decode the Response from decompressed payload
|
||||
let response = Response::decode(Bytes::from(decompressed)).map_err(|e| {
|
||||
DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decode Response: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
// if payload_package.needAck:
|
||||
// obj = PushFrame()
|
||||
// obj.payloadType = 'ack'
|
||||
// obj.logId = log_id
|
||||
// obj.payloadType = payload_package.internalExt
|
||||
// ack = obj.SerializeToString()
|
||||
let mut ack = None;
|
||||
if response.need_ack {
|
||||
let ack_msg = PushFrame {
|
||||
payload_type: "ack".to_string(),
|
||||
log_id: push_frame.log_id,
|
||||
payload_encoding: "".to_string(),
|
||||
payload: vec![],
|
||||
seq_id: 0,
|
||||
service: 0,
|
||||
method: 0,
|
||||
headers_list: vec![],
|
||||
};
|
||||
|
||||
debug!("Need to respond ack: {:?}", ack_msg);
|
||||
|
||||
ack = Some(ack_msg);
|
||||
}
|
||||
|
||||
for message in response.messages_list {
|
||||
match message.method.as_str() {
|
||||
"WebcastChatMessage" => {
|
||||
let chat_msg =
|
||||
DouyinChatMessage::decode(message.payload.as_slice()).map_err(|e| {
|
||||
DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decode chat message: {}", e),
|
||||
}
|
||||
})?;
|
||||
if let Some(user) = chat_msg.user {
|
||||
let danmu_msg = DanmuMessage {
|
||||
room_id,
|
||||
user_id: user.id,
|
||||
user_name: user.nick_name,
|
||||
message: chat_msg.content,
|
||||
color: 0xffffff,
|
||||
timestamp: chat_msg.event_time as i64 * 1000,
|
||||
};
|
||||
debug!("Received danmu message: {:?}", danmu_msg);
|
||||
tx.send(DanmuMessageType::DanmuMessage(danmu_msg))
|
||||
.map_err(|e| DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to send message to channel: {}", e),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
"WebcastGiftMessage" => {
|
||||
let gift_msg = GiftMessage::decode(message.payload.as_slice()).map_err(|e| {
|
||||
DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decode gift message: {}", e),
|
||||
}
|
||||
})?;
|
||||
if let Some(user) = gift_msg.user {
|
||||
if let Some(gift) = gift_msg.gift {
|
||||
log::debug!("Received gift: {} from user: {}", gift.name, user.nick_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
"WebcastLikeMessage" => {
|
||||
let like_msg = LikeMessage::decode(message.payload.as_slice()).map_err(|e| {
|
||||
DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decode like message: {}", e),
|
||||
}
|
||||
})?;
|
||||
if let Some(user) = like_msg.user {
|
||||
log::debug!(
|
||||
"Received {} likes from user: {}",
|
||||
like_msg.count,
|
||||
user.nick_name
|
||||
);
|
||||
}
|
||||
}
|
||||
"WebcastMemberMessage" => {
|
||||
let member_msg =
|
||||
MemberMessage::decode(message.payload.as_slice()).map_err(|e| {
|
||||
DanmuStreamError::WebsocketError {
|
||||
err: format!("Failed to decode member message: {}", e),
|
||||
}
|
||||
})?;
|
||||
if let Some(user) = member_msg.user {
|
||||
log::debug!(
|
||||
"Member joined: {} (Action: {})",
|
||||
user.nick_name,
|
||||
member_msg.action_description
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
debug!("Unknown message: {:?}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ack)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DanmuProvider for DouyinDanmu {
|
||||
async fn new(identifier: &str, room_id: i64) -> Result<Self, DanmuStreamError> {
|
||||
Ok(Self {
|
||||
room_id,
|
||||
cookie: identifier.to_string(),
|
||||
stop: Arc::new(RwLock::new(false)),
|
||||
write: Arc::new(RwLock::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError> {
|
||||
let mut retry_count = 0;
|
||||
const RETRY_DELAY: Duration = Duration::from_secs(5);
|
||||
info!(
|
||||
"Douyin WebSocket connection started, room_id: {}",
|
||||
self.room_id
|
||||
);
|
||||
|
||||
loop {
|
||||
if *self.stop.read().await {
|
||||
break;
|
||||
}
|
||||
|
||||
match self.connect_and_handle(tx.clone()).await {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Douyin WebSocket connection closed normally, room_id: {}",
|
||||
self.room_id
|
||||
);
|
||||
retry_count = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Douyin WebSocket connection error: {}", e);
|
||||
retry_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Retrying connection in {} seconds... (Attempt {}), room_id: {}",
|
||||
RETRY_DELAY.as_secs(),
|
||||
retry_count,
|
||||
self.room_id
|
||||
);
|
||||
tokio::time::sleep(RETRY_DELAY).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), DanmuStreamError> {
|
||||
*self.stop.write().await = true;
|
||||
if let Some(mut write) = self.write.write().await.take() {
|
||||
if let Err(e) = write.close().await {
|
||||
error!("Failed to close WebSocket connection: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1
src-tauri/crates/danmu_stream/src/provider/douyin/crypto-js.min.js
vendored
Normal file
862
src-tauri/crates/danmu_stream/src/provider/douyin/messages.rs
Normal file
@@ -0,0 +1,862 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use prost::Message;
|
||||
|
||||
// message Response {
|
||||
// repeated Message messagesList = 1;
|
||||
// string cursor = 2;
|
||||
// uint64 fetchInterval = 3;
|
||||
// uint64 now = 4;
|
||||
// string internalExt = 5;
|
||||
// uint32 fetchType = 6;
|
||||
// map<string, string> routeParams = 7;
|
||||
// uint64 heartbeatDuration = 8;
|
||||
// bool needAck = 9;
|
||||
// string pushServer = 10;
|
||||
// string liveCursor = 11;
|
||||
// bool historyNoMore = 12;
|
||||
// }
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct Response {
|
||||
#[prost(message, repeated, tag = "1")]
|
||||
pub messages_list: Vec<CommonMessage>,
|
||||
#[prost(string, tag = "2")]
|
||||
pub cursor: String,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub fetch_interval: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub now: u64,
|
||||
#[prost(string, tag = "5")]
|
||||
pub internal_ext: String,
|
||||
#[prost(uint32, tag = "6")]
|
||||
pub fetch_type: u32,
|
||||
#[prost(map = "string, string", tag = "7")]
|
||||
pub route_params: HashMap<String, String>,
|
||||
#[prost(uint64, tag = "8")]
|
||||
pub heartbeat_duration: u64,
|
||||
#[prost(bool, tag = "9")]
|
||||
pub need_ack: bool,
|
||||
#[prost(string, tag = "10")]
|
||||
pub push_server: String,
|
||||
#[prost(string, tag = "11")]
|
||||
pub live_cursor: String,
|
||||
#[prost(bool, tag = "12")]
|
||||
pub history_no_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct CommonMessage {
|
||||
#[prost(string, tag = "1")]
|
||||
pub method: String,
|
||||
#[prost(bytes, tag = "2")]
|
||||
pub payload: Vec<u8>,
|
||||
#[prost(int64, tag = "3")]
|
||||
pub msg_id: i64,
|
||||
#[prost(int32, tag = "4")]
|
||||
pub msg_type: i32,
|
||||
#[prost(int64, tag = "5")]
|
||||
pub offset: i64,
|
||||
#[prost(bool, tag = "6")]
|
||||
pub need_wrds_store: bool,
|
||||
#[prost(int64, tag = "7")]
|
||||
pub wrds_version: i64,
|
||||
#[prost(string, tag = "8")]
|
||||
pub wrds_sub_key: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct Common {
|
||||
#[prost(string, tag = "1")]
|
||||
pub method: String,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub msg_id: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub room_id: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub create_time: u64,
|
||||
#[prost(uint32, tag = "5")]
|
||||
pub monitor: u32,
|
||||
#[prost(bool, tag = "6")]
|
||||
pub is_show_msg: bool,
|
||||
#[prost(string, tag = "7")]
|
||||
pub describe: String,
|
||||
#[prost(uint64, tag = "9")]
|
||||
pub fold_type: u64,
|
||||
#[prost(uint64, tag = "10")]
|
||||
pub anchor_fold_type: u64,
|
||||
#[prost(uint64, tag = "11")]
|
||||
pub priority_score: u64,
|
||||
#[prost(string, tag = "12")]
|
||||
pub log_id: String,
|
||||
#[prost(string, tag = "13")]
|
||||
pub msg_process_filter_k: String,
|
||||
#[prost(string, tag = "14")]
|
||||
pub msg_process_filter_v: String,
|
||||
#[prost(message, optional, tag = "15")]
|
||||
pub user: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct User {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub id: u64,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub short_id: u64,
|
||||
#[prost(string, tag = "3")]
|
||||
pub nick_name: String,
|
||||
#[prost(uint32, tag = "4")]
|
||||
pub gender: u32,
|
||||
#[prost(string, tag = "5")]
|
||||
pub signature: String,
|
||||
#[prost(uint32, tag = "6")]
|
||||
pub level: u32,
|
||||
#[prost(uint64, tag = "7")]
|
||||
pub birthday: u64,
|
||||
#[prost(string, tag = "8")]
|
||||
pub telephone: String,
|
||||
#[prost(message, optional, tag = "9")]
|
||||
pub avatar_thumb: Option<Image>,
|
||||
#[prost(message, optional, tag = "10")]
|
||||
pub avatar_medium: Option<Image>,
|
||||
#[prost(message, optional, tag = "11")]
|
||||
pub avatar_large: Option<Image>,
|
||||
#[prost(bool, tag = "12")]
|
||||
pub verified: bool,
|
||||
#[prost(uint32, tag = "13")]
|
||||
pub experience: u32,
|
||||
#[prost(string, tag = "14")]
|
||||
pub city: String,
|
||||
#[prost(int32, tag = "15")]
|
||||
pub status: i32,
|
||||
#[prost(uint64, tag = "16")]
|
||||
pub create_time: u64,
|
||||
#[prost(uint64, tag = "17")]
|
||||
pub modify_time: u64,
|
||||
#[prost(uint32, tag = "18")]
|
||||
pub secret: u32,
|
||||
#[prost(string, tag = "19")]
|
||||
pub share_qrcode_uri: String,
|
||||
#[prost(uint32, tag = "20")]
|
||||
pub income_share_percent: u32,
|
||||
#[prost(message, repeated, tag = "21")]
|
||||
pub badge_image_list: Vec<Image>,
|
||||
#[prost(message, optional, tag = "22")]
|
||||
pub follow_info: Option<FollowInfo>,
|
||||
#[prost(message, optional, tag = "23")]
|
||||
pub pay_grade: Option<PayGrade>,
|
||||
#[prost(message, optional, tag = "24")]
|
||||
pub fans_club: Option<FansClub>,
|
||||
#[prost(string, tag = "26")]
|
||||
pub special_id: String,
|
||||
#[prost(message, optional, tag = "27")]
|
||||
pub avatar_border: Option<Image>,
|
||||
#[prost(message, optional, tag = "28")]
|
||||
pub medal: Option<Image>,
|
||||
#[prost(message, repeated, tag = "29")]
|
||||
pub real_time_icons_list: Vec<Image>,
|
||||
#[prost(string, tag = "38")]
|
||||
pub display_id: String,
|
||||
#[prost(string, tag = "46")]
|
||||
pub sec_uid: String,
|
||||
#[prost(uint64, tag = "1022")]
|
||||
pub fan_ticket_count: u64,
|
||||
#[prost(string, tag = "1028")]
|
||||
pub id_str: String,
|
||||
#[prost(uint32, tag = "1045")]
|
||||
pub age_range: u32,
|
||||
}
|
||||
|
||||
#[derive(Message, PartialEq)]
|
||||
pub struct Image {
|
||||
#[prost(string, repeated, tag = "1")]
|
||||
pub url_list_list: Vec<String>,
|
||||
#[prost(string, tag = "2")]
|
||||
pub uri: String,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub height: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub width: u64,
|
||||
#[prost(string, tag = "5")]
|
||||
pub avg_color: String,
|
||||
#[prost(uint32, tag = "6")]
|
||||
pub image_type: u32,
|
||||
#[prost(string, tag = "7")]
|
||||
pub open_web_url: String,
|
||||
#[prost(message, optional, tag = "8")]
|
||||
pub content: Option<ImageContent>,
|
||||
#[prost(bool, tag = "9")]
|
||||
pub is_animated: bool,
|
||||
#[prost(message, optional, tag = "10")]
|
||||
pub flex_setting_list: Option<NinePatchSetting>,
|
||||
#[prost(message, optional, tag = "11")]
|
||||
pub text_setting_list: Option<NinePatchSetting>,
|
||||
}
|
||||
|
||||
#[derive(Message, PartialEq)]
|
||||
pub struct ImageContent {
|
||||
#[prost(string, tag = "1")]
|
||||
pub name: String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub font_color: String,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub level: u64,
|
||||
#[prost(string, tag = "4")]
|
||||
pub alternative_text: String,
|
||||
}
|
||||
|
||||
#[derive(Message, PartialEq)]
|
||||
pub struct NinePatchSetting {
|
||||
#[prost(string, repeated, tag = "1")]
|
||||
pub setting_list_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct FollowInfo {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub following_count: u64,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub follower_count: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub follow_status: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub push_status: u64,
|
||||
#[prost(string, tag = "5")]
|
||||
pub remark_name: String,
|
||||
#[prost(string, tag = "6")]
|
||||
pub follower_count_str: String,
|
||||
#[prost(string, tag = "7")]
|
||||
pub following_count_str: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct PayGrade {
|
||||
#[prost(int64, tag = "1")]
|
||||
pub total_diamond_count: i64,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub diamond_icon: Option<Image>,
|
||||
#[prost(string, tag = "3")]
|
||||
pub name: String,
|
||||
#[prost(message, optional, tag = "4")]
|
||||
pub icon: Option<Image>,
|
||||
#[prost(string, tag = "5")]
|
||||
pub next_name: String,
|
||||
#[prost(int64, tag = "6")]
|
||||
pub level: i64,
|
||||
#[prost(message, optional, tag = "7")]
|
||||
pub next_icon: Option<Image>,
|
||||
#[prost(int64, tag = "8")]
|
||||
pub next_diamond: i64,
|
||||
#[prost(int64, tag = "9")]
|
||||
pub now_diamond: i64,
|
||||
#[prost(int64, tag = "10")]
|
||||
pub this_grade_min_diamond: i64,
|
||||
#[prost(int64, tag = "11")]
|
||||
pub this_grade_max_diamond: i64,
|
||||
#[prost(int64, tag = "12")]
|
||||
pub pay_diamond_bak: i64,
|
||||
#[prost(string, tag = "13")]
|
||||
pub grade_describe: String,
|
||||
#[prost(message, repeated, tag = "14")]
|
||||
pub grade_icon_list: Vec<GradeIcon>,
|
||||
#[prost(int64, tag = "15")]
|
||||
pub screen_chat_type: i64,
|
||||
#[prost(message, optional, tag = "16")]
|
||||
pub im_icon: Option<Image>,
|
||||
#[prost(message, optional, tag = "17")]
|
||||
pub im_icon_with_level: Option<Image>,
|
||||
#[prost(message, optional, tag = "18")]
|
||||
pub live_icon: Option<Image>,
|
||||
#[prost(message, optional, tag = "19")]
|
||||
pub new_im_icon_with_level: Option<Image>,
|
||||
#[prost(message, optional, tag = "20")]
|
||||
pub new_live_icon: Option<Image>,
|
||||
#[prost(int64, tag = "21")]
|
||||
pub upgrade_need_consume: i64,
|
||||
#[prost(string, tag = "22")]
|
||||
pub next_privileges: String,
|
||||
#[prost(message, optional, tag = "23")]
|
||||
pub background: Option<Image>,
|
||||
#[prost(message, optional, tag = "24")]
|
||||
pub background_back: Option<Image>,
|
||||
#[prost(int64, tag = "25")]
|
||||
pub score: i64,
|
||||
#[prost(message, optional, tag = "26")]
|
||||
pub buff_info: Option<GradeBuffInfo>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct GradeIcon {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub icon: Option<Image>,
|
||||
#[prost(int64, tag = "2")]
|
||||
pub icon_diamond: i64,
|
||||
#[prost(int64, tag = "3")]
|
||||
pub level: i64,
|
||||
#[prost(string, tag = "4")]
|
||||
pub level_str: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct GradeBuffInfo {}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct FansClub {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub data: Option<FansClubData>,
|
||||
#[prost(map = "int32, message", tag = "2")]
|
||||
pub prefer_data: HashMap<i32, FansClubData>,
|
||||
}
|
||||
|
||||
#[derive(Message, PartialEq)]
|
||||
pub struct FansClubData {
|
||||
#[prost(string, tag = "1")]
|
||||
pub club_name: String,
|
||||
#[prost(int32, tag = "2")]
|
||||
pub level: i32,
|
||||
#[prost(int32, tag = "3")]
|
||||
pub user_fans_club_status: i32,
|
||||
#[prost(message, optional, tag = "4")]
|
||||
pub badge: Option<UserBadge>,
|
||||
#[prost(int64, repeated, tag = "5")]
|
||||
pub available_gift_ids: Vec<i64>,
|
||||
#[prost(int64, tag = "6")]
|
||||
pub anchor_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Message, PartialEq)]
|
||||
pub struct UserBadge {
|
||||
#[prost(map = "int32, message", tag = "1")]
|
||||
pub icons: HashMap<i32, Image>,
|
||||
#[prost(string, tag = "2")]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct PublicAreaCommon {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub user_label: Option<Image>,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub user_consume_in_room: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub user_send_gift_cnt_in_room: u64,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct LandscapeAreaCommon {
|
||||
#[prost(bool, tag = "1")]
|
||||
pub show_head: bool,
|
||||
#[prost(bool, tag = "2")]
|
||||
pub show_nickname: bool,
|
||||
#[prost(bool, tag = "3")]
|
||||
pub show_font_color: bool,
|
||||
#[prost(string, repeated, tag = "4")]
|
||||
pub color_value_list: Vec<String>,
|
||||
#[prost(enumeration = "CommentTypeTag", repeated, tag = "5")]
|
||||
pub comment_type_tags_list: Vec<i32>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct Text {
|
||||
#[prost(string, tag = "1")]
|
||||
pub key: String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub default_patter: String,
|
||||
#[prost(message, optional, tag = "3")]
|
||||
pub default_format: Option<TextFormat>,
|
||||
#[prost(message, repeated, tag = "4")]
|
||||
pub pieces_list: Vec<TextPiece>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextFormat {
|
||||
#[prost(string, tag = "1")]
|
||||
pub color: String,
|
||||
#[prost(bool, tag = "2")]
|
||||
pub bold: bool,
|
||||
#[prost(bool, tag = "3")]
|
||||
pub italic: bool,
|
||||
#[prost(uint32, tag = "4")]
|
||||
pub weight: u32,
|
||||
#[prost(uint32, tag = "5")]
|
||||
pub italic_angle: u32,
|
||||
#[prost(uint32, tag = "6")]
|
||||
pub font_size: u32,
|
||||
#[prost(bool, tag = "7")]
|
||||
pub use_heigh_light_color: bool,
|
||||
#[prost(bool, tag = "8")]
|
||||
pub use_remote_clor: bool,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextPiece {
|
||||
#[prost(bool, tag = "1")]
|
||||
pub r#type: bool,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub format: Option<TextFormat>,
|
||||
#[prost(string, tag = "3")]
|
||||
pub string_value: String,
|
||||
#[prost(message, optional, tag = "4")]
|
||||
pub user_value: Option<TextPieceUser>,
|
||||
#[prost(message, optional, tag = "5")]
|
||||
pub gift_value: Option<TextPieceGift>,
|
||||
#[prost(message, optional, tag = "6")]
|
||||
pub heart_value: Option<TextPieceHeart>,
|
||||
#[prost(message, optional, tag = "7")]
|
||||
pub pattern_ref_value: Option<TextPiecePatternRef>,
|
||||
#[prost(message, optional, tag = "8")]
|
||||
pub image_value: Option<TextPieceImage>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextPieceUser {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub user: Option<User>,
|
||||
#[prost(bool, tag = "2")]
|
||||
pub with_colon: bool,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextPieceGift {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub gift_id: u64,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub name_ref: Option<PatternRef>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct PatternRef {
|
||||
#[prost(string, tag = "1")]
|
||||
pub key: String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub default_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextPieceHeart {
|
||||
#[prost(string, tag = "1")]
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextPiecePatternRef {
|
||||
#[prost(string, tag = "1")]
|
||||
pub key: String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub default_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextPieceImage {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub image: Option<Image>,
|
||||
#[prost(float, tag = "2")]
|
||||
pub scaling_rate: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||
#[repr(i32)]
|
||||
pub enum CommentTypeTag {
|
||||
CommentTypeTagUnknown = 0,
|
||||
CommentTypeTagStar = 1,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct DouyinChatMessage {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub common: Option<Common>,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub user: Option<User>,
|
||||
#[prost(string, tag = "3")]
|
||||
pub content: String,
|
||||
#[prost(bool, tag = "4")]
|
||||
pub visible_to_sender: bool,
|
||||
#[prost(message, optional, tag = "5")]
|
||||
pub background_image: Option<Image>,
|
||||
#[prost(string, tag = "6")]
|
||||
pub full_screen_text_color: String,
|
||||
#[prost(message, optional, tag = "7")]
|
||||
pub background_image_v2: Option<Image>,
|
||||
#[prost(message, optional, tag = "9")]
|
||||
pub public_area_common: Option<PublicAreaCommon>,
|
||||
#[prost(message, optional, tag = "10")]
|
||||
pub gift_image: Option<Image>,
|
||||
#[prost(uint64, tag = "11")]
|
||||
pub agree_msg_id: u64,
|
||||
#[prost(uint32, tag = "12")]
|
||||
pub priority_level: u32,
|
||||
#[prost(message, optional, tag = "13")]
|
||||
pub landscape_area_common: Option<LandscapeAreaCommon>,
|
||||
#[prost(uint64, tag = "15")]
|
||||
pub event_time: u64,
|
||||
#[prost(bool, tag = "16")]
|
||||
pub send_review: bool,
|
||||
#[prost(bool, tag = "17")]
|
||||
pub from_intercom: bool,
|
||||
#[prost(bool, tag = "18")]
|
||||
pub intercom_hide_user_card: bool,
|
||||
#[prost(string, tag = "20")]
|
||||
pub chat_by: String,
|
||||
#[prost(uint32, tag = "21")]
|
||||
pub individual_chat_priority: u32,
|
||||
#[prost(message, optional, tag = "22")]
|
||||
pub rtf_content: Option<Text>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct GiftMessage {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub common: Option<Common>,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub gift_id: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub fan_ticket_count: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub group_count: u64,
|
||||
#[prost(uint64, tag = "5")]
|
||||
pub repeat_count: u64,
|
||||
#[prost(uint64, tag = "6")]
|
||||
pub combo_count: u64,
|
||||
#[prost(message, optional, tag = "7")]
|
||||
pub user: Option<User>,
|
||||
#[prost(message, optional, tag = "8")]
|
||||
pub to_user: Option<User>,
|
||||
#[prost(uint32, tag = "9")]
|
||||
pub repeat_end: u32,
|
||||
#[prost(message, optional, tag = "10")]
|
||||
pub text_effect: Option<TextEffect>,
|
||||
#[prost(uint64, tag = "11")]
|
||||
pub group_id: u64,
|
||||
#[prost(uint64, tag = "12")]
|
||||
pub income_taskgifts: u64,
|
||||
#[prost(uint64, tag = "13")]
|
||||
pub room_fan_ticket_count: u64,
|
||||
#[prost(message, optional, tag = "14")]
|
||||
pub priority: Option<GiftIMPriority>,
|
||||
#[prost(message, optional, tag = "15")]
|
||||
pub gift: Option<GiftStruct>,
|
||||
#[prost(string, tag = "16")]
|
||||
pub log_id: String,
|
||||
#[prost(uint64, tag = "17")]
|
||||
pub send_type: u64,
|
||||
#[prost(message, optional, tag = "18")]
|
||||
pub public_area_common: Option<PublicAreaCommon>,
|
||||
#[prost(message, optional, tag = "19")]
|
||||
pub tray_display_text: Option<Text>,
|
||||
#[prost(uint64, tag = "20")]
|
||||
pub banned_display_effects: u64,
|
||||
#[prost(bool, tag = "25")]
|
||||
pub display_for_self: bool,
|
||||
#[prost(string, tag = "26")]
|
||||
pub interact_gift_info: String,
|
||||
#[prost(string, tag = "27")]
|
||||
pub diy_item_info: String,
|
||||
#[prost(uint64, repeated, tag = "28")]
|
||||
pub min_asset_set_list: Vec<u64>,
|
||||
#[prost(uint64, tag = "29")]
|
||||
pub total_count: u64,
|
||||
#[prost(uint32, tag = "30")]
|
||||
pub client_gift_source: u32,
|
||||
#[prost(uint64, repeated, tag = "32")]
|
||||
pub to_user_ids_list: Vec<u64>,
|
||||
#[prost(uint64, tag = "33")]
|
||||
pub send_time: u64,
|
||||
#[prost(uint64, tag = "34")]
|
||||
pub force_display_effects: u64,
|
||||
#[prost(string, tag = "35")]
|
||||
pub trace_id: String,
|
||||
#[prost(uint64, tag = "36")]
|
||||
pub effect_display_ts: u64,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct GiftStruct {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub image: Option<Image>,
|
||||
#[prost(string, tag = "2")]
|
||||
pub describe: String,
|
||||
#[prost(bool, tag = "3")]
|
||||
pub notify: bool,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub duration: u64,
|
||||
#[prost(uint64, tag = "5")]
|
||||
pub id: u64,
|
||||
#[prost(bool, tag = "7")]
|
||||
pub for_linkmic: bool,
|
||||
#[prost(bool, tag = "8")]
|
||||
pub doodle: bool,
|
||||
#[prost(bool, tag = "9")]
|
||||
pub for_fansclub: bool,
|
||||
#[prost(bool, tag = "10")]
|
||||
pub combo: bool,
|
||||
#[prost(uint32, tag = "11")]
|
||||
pub r#type: u32,
|
||||
#[prost(uint32, tag = "12")]
|
||||
pub diamond_count: u32,
|
||||
#[prost(bool, tag = "13")]
|
||||
pub is_displayed_on_panel: bool,
|
||||
#[prost(uint64, tag = "14")]
|
||||
pub primary_effect_id: u64,
|
||||
#[prost(message, optional, tag = "15")]
|
||||
pub gift_label_icon: Option<Image>,
|
||||
#[prost(string, tag = "16")]
|
||||
pub name: String,
|
||||
#[prost(string, tag = "17")]
|
||||
pub region: String,
|
||||
#[prost(string, tag = "18")]
|
||||
pub manual: String,
|
||||
#[prost(bool, tag = "19")]
|
||||
pub for_custom: bool,
|
||||
#[prost(message, optional, tag = "21")]
|
||||
pub icon: Option<Image>,
|
||||
#[prost(uint32, tag = "22")]
|
||||
pub action_type: u32,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct GiftIMPriority {
|
||||
#[prost(uint64, repeated, tag = "1")]
|
||||
pub queue_sizes_list: Vec<u64>,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub self_queue_priority: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub priority: u64,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextEffect {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub portrait: Option<TextEffectDetail>,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub landscape: Option<TextEffectDetail>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct TextEffectDetail {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub text: Option<Text>,
|
||||
#[prost(uint32, tag = "2")]
|
||||
pub text_font_size: u32,
|
||||
#[prost(message, optional, tag = "3")]
|
||||
pub background: Option<Image>,
|
||||
#[prost(uint32, tag = "4")]
|
||||
pub start: u32,
|
||||
#[prost(uint32, tag = "5")]
|
||||
pub duration: u32,
|
||||
#[prost(uint32, tag = "6")]
|
||||
pub x: u32,
|
||||
#[prost(uint32, tag = "7")]
|
||||
pub y: u32,
|
||||
#[prost(uint32, tag = "8")]
|
||||
pub width: u32,
|
||||
#[prost(uint32, tag = "9")]
|
||||
pub height: u32,
|
||||
#[prost(uint32, tag = "10")]
|
||||
pub shadow_dx: u32,
|
||||
#[prost(uint32, tag = "11")]
|
||||
pub shadow_dy: u32,
|
||||
#[prost(uint32, tag = "12")]
|
||||
pub shadow_radius: u32,
|
||||
#[prost(string, tag = "13")]
|
||||
pub shadow_color: String,
|
||||
#[prost(string, tag = "14")]
|
||||
pub stroke_color: String,
|
||||
#[prost(uint32, tag = "15")]
|
||||
pub stroke_width: u32,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct LikeMessage {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub common: Option<Common>,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub count: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub total: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub color: u64,
|
||||
#[prost(message, optional, tag = "5")]
|
||||
pub user: Option<User>,
|
||||
#[prost(string, tag = "6")]
|
||||
pub icon: String,
|
||||
#[prost(message, optional, tag = "7")]
|
||||
pub double_like_detail: Option<DoubleLikeDetail>,
|
||||
#[prost(message, optional, tag = "8")]
|
||||
pub display_control_info: Option<DisplayControlInfo>,
|
||||
#[prost(uint64, tag = "9")]
|
||||
pub linkmic_guest_uid: u64,
|
||||
#[prost(string, tag = "10")]
|
||||
pub scene: String,
|
||||
#[prost(message, optional, tag = "11")]
|
||||
pub pico_display_info: Option<PicoDisplayInfo>,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct DoubleLikeDetail {
|
||||
#[prost(bool, tag = "1")]
|
||||
pub double_flag: bool,
|
||||
#[prost(uint32, tag = "2")]
|
||||
pub seq_id: u32,
|
||||
#[prost(uint32, tag = "3")]
|
||||
pub renewals_num: u32,
|
||||
#[prost(uint32, tag = "4")]
|
||||
pub triggers_num: u32,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct DisplayControlInfo {
|
||||
#[prost(bool, tag = "1")]
|
||||
pub show_text: bool,
|
||||
#[prost(bool, tag = "2")]
|
||||
pub show_icons: bool,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct PicoDisplayInfo {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub combo_sum_count: u64,
|
||||
#[prost(string, tag = "2")]
|
||||
pub emoji: String,
|
||||
#[prost(message, optional, tag = "3")]
|
||||
pub emoji_icon: Option<Image>,
|
||||
#[prost(string, tag = "4")]
|
||||
pub emoji_text: String,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct MemberMessage {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub common: Option<Common>,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub user: Option<User>,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub member_count: u64,
|
||||
#[prost(message, optional, tag = "4")]
|
||||
pub operator: Option<User>,
|
||||
#[prost(bool, tag = "5")]
|
||||
pub is_set_to_admin: bool,
|
||||
#[prost(bool, tag = "6")]
|
||||
pub is_top_user: bool,
|
||||
#[prost(uint64, tag = "7")]
|
||||
pub rank_score: u64,
|
||||
#[prost(uint64, tag = "8")]
|
||||
pub top_user_no: u64,
|
||||
#[prost(uint64, tag = "9")]
|
||||
pub enter_type: u64,
|
||||
#[prost(uint64, tag = "10")]
|
||||
pub action: u64,
|
||||
#[prost(string, tag = "11")]
|
||||
pub action_description: String,
|
||||
#[prost(uint64, tag = "12")]
|
||||
pub user_id: u64,
|
||||
#[prost(message, optional, tag = "13")]
|
||||
pub effect_config: Option<EffectConfig>,
|
||||
#[prost(string, tag = "14")]
|
||||
pub pop_str: String,
|
||||
#[prost(message, optional, tag = "15")]
|
||||
pub enter_effect_config: Option<EffectConfig>,
|
||||
#[prost(message, optional, tag = "16")]
|
||||
pub background_image: Option<Image>,
|
||||
#[prost(message, optional, tag = "17")]
|
||||
pub background_image_v2: Option<Image>,
|
||||
#[prost(message, optional, tag = "18")]
|
||||
pub anchor_display_text: Option<Text>,
|
||||
#[prost(message, optional, tag = "19")]
|
||||
pub public_area_common: Option<PublicAreaCommon>,
|
||||
#[prost(uint64, tag = "20")]
|
||||
pub user_enter_tip_type: u64,
|
||||
#[prost(uint64, tag = "21")]
|
||||
pub anchor_enter_tip_type: u64,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct EffectConfig {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub r#type: u64,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub icon: Option<Image>,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub avatar_pos: u64,
|
||||
#[prost(message, optional, tag = "4")]
|
||||
pub text: Option<Text>,
|
||||
#[prost(message, optional, tag = "5")]
|
||||
pub text_icon: Option<Image>,
|
||||
#[prost(uint32, tag = "6")]
|
||||
pub stay_time: u32,
|
||||
#[prost(uint64, tag = "7")]
|
||||
pub anim_asset_id: u64,
|
||||
#[prost(message, optional, tag = "8")]
|
||||
pub badge: Option<Image>,
|
||||
#[prost(uint64, repeated, tag = "9")]
|
||||
pub flex_setting_array_list: Vec<u64>,
|
||||
#[prost(message, optional, tag = "10")]
|
||||
pub text_icon_overlay: Option<Image>,
|
||||
#[prost(message, optional, tag = "11")]
|
||||
pub animated_badge: Option<Image>,
|
||||
#[prost(bool, tag = "12")]
|
||||
pub has_sweep_light: bool,
|
||||
#[prost(uint64, repeated, tag = "13")]
|
||||
pub text_flex_setting_array_list: Vec<u64>,
|
||||
#[prost(uint64, tag = "14")]
|
||||
pub center_anim_asset_id: u64,
|
||||
#[prost(message, optional, tag = "15")]
|
||||
pub dynamic_image: Option<Image>,
|
||||
#[prost(map = "string, string", tag = "16")]
|
||||
pub extra_map: HashMap<String, String>,
|
||||
#[prost(uint64, tag = "17")]
|
||||
pub mp4_anim_asset_id: u64,
|
||||
#[prost(uint64, tag = "18")]
|
||||
pub priority: u64,
|
||||
#[prost(uint64, tag = "19")]
|
||||
pub max_wait_time: u64,
|
||||
#[prost(string, tag = "20")]
|
||||
pub dress_id: String,
|
||||
#[prost(uint64, tag = "21")]
|
||||
pub alignment: u64,
|
||||
#[prost(uint64, tag = "22")]
|
||||
pub alignment_offset: u64,
|
||||
}
|
||||
|
||||
// message PushFrame {
|
||||
// uint64 seqId = 1;
|
||||
// uint64 logId = 2;
|
||||
// uint64 service = 3;
|
||||
// uint64 method = 4;
|
||||
// repeated HeadersList headersList = 5;
|
||||
// string payloadEncoding = 6;
|
||||
// string payloadType = 7;
|
||||
// bytes payload = 8;
|
||||
// }
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct PushFrame {
|
||||
#[prost(uint64, tag = "1")]
|
||||
pub seq_id: u64,
|
||||
#[prost(uint64, tag = "2")]
|
||||
pub log_id: u64,
|
||||
#[prost(uint64, tag = "3")]
|
||||
pub service: u64,
|
||||
#[prost(uint64, tag = "4")]
|
||||
pub method: u64,
|
||||
#[prost(message, repeated, tag = "5")]
|
||||
pub headers_list: Vec<HeadersList>,
|
||||
#[prost(string, tag = "6")]
|
||||
pub payload_encoding: String,
|
||||
#[prost(string, tag = "7")]
|
||||
pub payload_type: String,
|
||||
#[prost(bytes, tag = "8")]
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
// message HeadersList {
|
||||
// string key = 1;
|
||||
// string value = 2;
|
||||
// }
|
||||
|
||||
#[derive(Message)]
|
||||
pub struct HeadersList {
|
||||
#[prost(string, tag = "1")]
|
||||
pub key: String,
|
||||
#[prost(string, tag = "2")]
|
||||
pub value: String,
|
||||
}
|
||||
13167
src-tauri/crates/danmu_stream/src/provider/douyin/webmssdk.js
Normal file
72
src-tauri/crates/danmu_stream/src/provider/mod.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
mod bilibili;
|
||||
mod douyin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use self::bilibili::BiliDanmu;
|
||||
use self::douyin::DouyinDanmu;
|
||||
|
||||
use crate::{DanmuMessageType, DanmuStreamError};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
BiliBili,
|
||||
Douyin,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DanmuProvider: Send + Sync {
|
||||
async fn new(identifier: &str, room_id: i64) -> Result<Self, DanmuStreamError>
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
tx: mpsc::UnboundedSender<DanmuMessageType>,
|
||||
) -> Result<(), DanmuStreamError>;
|
||||
|
||||
async fn stop(&self) -> Result<(), DanmuStreamError>;
|
||||
}
|
||||
|
||||
/// Creates a new danmu stream provider for the specified platform.
|
||||
///
|
||||
/// This function initializes and starts a danmu stream provider based on the specified platform type.
|
||||
/// The provider will fetch danmu messages and send them through the provided channel.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `tx` - An unbounded sender channel that will receive danmu messages
|
||||
/// * `provider_type` - The type of platform to fetch danmu from (BiliBili or Douyin)
|
||||
/// * `identifier` - User validation information (e.g., cookies) required by the platform
|
||||
/// * `room_id` - The unique identifier of the room/channel to fetch danmu from. Notice that douyin room_id is more like a live_id, it changes every time the live starts.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Result<(), DanmmuStreamError>` where:
|
||||
/// * `Ok(())` indicates successful initialization and start of the provider, only return after disconnect
|
||||
/// * `Err(DanmmuStreamError)` indicates an error occurred during initialization or startup
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use tokio::sync::mpsc;
|
||||
/// let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
/// new(tx, ProviderType::BiliBili, "your_cookie", 123456).await?;
|
||||
/// ```
|
||||
pub async fn new(
|
||||
provider_type: ProviderType,
|
||||
identifier: &str,
|
||||
room_id: i64,
|
||||
) -> Result<Box<dyn DanmuProvider>, DanmuStreamError> {
|
||||
match provider_type {
|
||||
ProviderType::BiliBili => {
|
||||
let bili = BiliDanmu::new(identifier, room_id).await?;
|
||||
Ok(Box::new(bili))
|
||||
}
|
||||
ProviderType::Douyin => {
|
||||
let douyin = DouyinDanmu::new(identifier, room_id).await?;
|
||||
Ok(Box::new(douyin))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default"]}}
|
||||
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main","Live*","Clip*"],"permissions":["core:default","fs:allow-read-file","fs:allow-write-file","fs:allow-read-dir","fs:allow-copy-file","fs:allow-mkdir","fs:allow-remove","fs:allow-remove","fs:allow-rename","fs:allow-exists",{"identifier":"fs:scope","allow":["**"]},"core:window:default","core:window:allow-start-dragging","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-set-title","sql:allow-execute","shell:allow-open","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"http:default","allow":[{"url":"https://*.hdslb.com/"},{"url":"https://afdian.com/"},{"url":"https://*.afdiancdn.com/"},{"url":"https://*.douyin.com/"},{"url":"https://*.douyinpic.com/"},{"url":"http://tauri.localhost/*"},{"url":"http://localhost:8054/*"}]},"dialog:default","shell:default","fs:default","http:default","sql:default","os:default","notification:default","dialog:default","fs:default","http:default","shell:default","sql:default","os:default","dialog:default","deep-link:default"]}}
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
@@ -49,7 +49,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3152,6 +3152,12 @@
|
||||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -3254,6 +3260,12 @@
|
||||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -4208,6 +4220,60 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
|
||||
"type": "string",
|
||||
"const": "deep-link:default",
|
||||
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-get-current",
|
||||
"markdownDescription": "Enables the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-get-current",
|
||||
"markdownDescription": "Denies the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
@@ -49,7 +49,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3152,6 +3152,12 @@
|
||||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -3254,6 +3260,12 @@
|
||||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -4208,6 +4220,60 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`",
|
||||
"type": "string",
|
||||
"const": "deep-link:default",
|
||||
"markdownDescription": "Allows reading the opened deep link via the get_current command\n#### This default permission set includes:\n\n- `allow-get-current`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-get-current",
|
||||
"markdownDescription": "Enables the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-is-registered",
|
||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-register",
|
||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:allow-unregister",
|
||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_current command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-get-current",
|
||||
"markdownDescription": "Denies the get_current command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-is-registered",
|
||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-register",
|
||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the unregister command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deep-link:deny-unregister",
|
||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::recorder::PlatformType;
|
||||
|
||||
pub async fn try_rebuild_archives(
|
||||
db: &Arc<Database>,
|
||||
cache_path: PathBuf,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let rooms = db.get_recorders().await?;
|
||||
for room in rooms {
|
||||
let room_id = room.room_id;
|
||||
let room_cache_path = cache_path.join(format!("{}/{}", room.platform, room_id));
|
||||
let mut files = tokio::fs::read_dir(room_cache_path).await?;
|
||||
while let Some(file) = files.next_entry().await? {
|
||||
if file.file_type().await?.is_dir() {
|
||||
// use folder name as live_id
|
||||
let live_id = file.file_name();
|
||||
let live_id = live_id.to_str().unwrap();
|
||||
// check if live_id is in db
|
||||
let record = db.get_record(room_id, live_id).await;
|
||||
if record.is_ok() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// get created_at from folder metadata
|
||||
let metadata = file.metadata().await?;
|
||||
let created_at = metadata.created();
|
||||
if created_at.is_err() {
|
||||
continue;
|
||||
}
|
||||
let created_at = created_at.unwrap();
|
||||
let created_at = chrono::DateTime::<Utc>::from(created_at)
|
||||
.format("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
.to_string();
|
||||
// create a record for this live_id
|
||||
let record = db
|
||||
.add_record(
|
||||
PlatformType::from_str(room.platform.as_str()).unwrap(),
|
||||
live_id,
|
||||
room_id,
|
||||
&format!("UnknownLive {}", live_id),
|
||||
None,
|
||||
Some(&created_at),
|
||||
)
|
||||
.await?;
|
||||
|
||||
log::info!("rebuild archive {:?}", record);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{recorder::PlatformType, recorder_manager::ClipRangeParams};
|
||||
@@ -15,16 +15,28 @@ pub struct Config {
|
||||
pub post_notify: bool,
|
||||
#[serde(default = "default_auto_subtitle")]
|
||||
pub auto_subtitle: bool,
|
||||
#[serde(default = "default_subtitle_generator_type")]
|
||||
pub subtitle_generator_type: String,
|
||||
#[serde(default = "default_whisper_model")]
|
||||
pub whisper_model: String,
|
||||
#[serde(default = "default_whisper_prompt")]
|
||||
pub whisper_prompt: String,
|
||||
#[serde(default = "default_openai_api_endpoint")]
|
||||
pub openai_api_endpoint: String,
|
||||
#[serde(default = "default_openai_api_key")]
|
||||
pub openai_api_key: String,
|
||||
#[serde(default = "default_clip_name_format")]
|
||||
pub clip_name_format: String,
|
||||
#[serde(default = "default_auto_generate_config")]
|
||||
pub auto_generate: AutoGenerateConfig,
|
||||
#[serde(default = "default_status_check_interval")]
|
||||
pub status_check_interval: u64,
|
||||
#[serde(skip)]
|
||||
pub config_path: String,
|
||||
#[serde(default = "default_whisper_language")]
|
||||
pub whisper_language: String,
|
||||
#[serde(default = "default_webhook_url")]
|
||||
pub webhook_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
@@ -37,6 +49,10 @@ fn default_auto_subtitle() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_subtitle_generator_type() -> String {
|
||||
"whisper".to_string()
|
||||
}
|
||||
|
||||
fn default_whisper_model() -> String {
|
||||
"whisper_model.bin".to_string()
|
||||
}
|
||||
@@ -45,6 +61,14 @@ fn default_whisper_prompt() -> String {
|
||||
"这是一段中文 你们好".to_string()
|
||||
}
|
||||
|
||||
fn default_openai_api_endpoint() -> String {
|
||||
"https://api.openai.com/v1".to_string()
|
||||
}
|
||||
|
||||
fn default_openai_api_key() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn default_clip_name_format() -> String {
|
||||
"[{room_id}][{live_id}][{title}][{created_at}].mp4".to_string()
|
||||
}
|
||||
@@ -56,11 +80,23 @@ fn default_auto_generate_config() -> AutoGenerateConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status_check_interval() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_whisper_language() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
fn default_webhook_url() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(
|
||||
config_path: &PathBuf,
|
||||
default_cache: &PathBuf,
|
||||
default_output: &PathBuf,
|
||||
default_cache: &Path,
|
||||
default_output: &Path,
|
||||
) -> Result<Self, String> {
|
||||
if let Ok(content) = std::fs::read_to_string(config_path) {
|
||||
if let Ok(mut config) = toml::from_str::<Config>(&content) {
|
||||
@@ -83,13 +119,21 @@ impl Config {
|
||||
clip_notify: true,
|
||||
post_notify: true,
|
||||
auto_subtitle: false,
|
||||
whisper_model: "whisper_model.bin".to_string(),
|
||||
whisper_prompt: "这是一段中文 你们好".to_string(),
|
||||
clip_name_format: "[{room_id}][{live_id}][{title}][{created_at}].mp4".to_string(),
|
||||
subtitle_generator_type: default_subtitle_generator_type(),
|
||||
whisper_model: default_whisper_model(),
|
||||
whisper_prompt: default_whisper_prompt(),
|
||||
openai_api_endpoint: default_openai_api_endpoint(),
|
||||
openai_api_key: default_openai_api_key(),
|
||||
clip_name_format: default_clip_name_format(),
|
||||
auto_generate: default_auto_generate_config(),
|
||||
status_check_interval: default_status_check_interval(),
|
||||
config_path: config_path.to_str().unwrap().into(),
|
||||
whisper_language: default_whisper_language(),
|
||||
webhook_url: default_webhook_url(),
|
||||
};
|
||||
|
||||
config.save();
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -100,16 +144,24 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_cache_path(&mut self, path: &str) {
|
||||
self.cache = path.to_string();
|
||||
self.save();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_output_path(&mut self, path: &str) {
|
||||
self.output = path.into();
|
||||
self.save();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn set_whisper_language(&mut self, language: &str) {
|
||||
self.whisper_language = language.to_string();
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn generate_clip_name(&self, params: &ClipRangeParams) -> PathBuf {
|
||||
let platform = PlatformType::from_str(¶ms.platform).unwrap();
|
||||
|
||||
@@ -125,13 +177,31 @@ impl Config {
|
||||
let format_config = format_config.replace("{platform}", platform.as_str());
|
||||
let format_config = format_config.replace("{room_id}", ¶ms.room_id.to_string());
|
||||
let format_config = format_config.replace("{live_id}", ¶ms.live_id);
|
||||
let format_config = format_config.replace("{x}", ¶ms.x.to_string());
|
||||
let format_config = format_config.replace("{y}", ¶ms.y.to_string());
|
||||
let format_config = format_config.replace(
|
||||
"{x}",
|
||||
¶ms
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("0".to_string(), |r| r.start.to_string()),
|
||||
);
|
||||
let format_config = format_config.replace(
|
||||
"{y}",
|
||||
¶ms
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("0".to_string(), |r| r.end.to_string()),
|
||||
);
|
||||
let format_config = format_config.replace(
|
||||
"{created_at}",
|
||||
&Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string(),
|
||||
&Local::now().format("%Y-%m-%d_%H-%M-%S").to_string(),
|
||||
);
|
||||
let format_config = format_config.replace(
|
||||
"{length}",
|
||||
¶ms
|
||||
.range
|
||||
.as_ref()
|
||||
.map_or("0".to_string(), |r| r.duration().to_string()),
|
||||
);
|
||||
let format_config = format_config.replace("{length}", &(params.y - params.x).to_string());
|
||||
|
||||
let output = self.output.clone();
|
||||
|
||||
|
||||
4
src-tauri/src/constants.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub const PREFIX_SUBTITLE: &str = "[subtitle]";
|
||||
pub const PREFIX_IMPORTED: &str = "[imported]";
|
||||
pub const PREFIX_DANMAKU: &str = "[danmaku]";
|
||||
pub const PREFIX_CLIP: &str = "[clip]";
|
||||