├── .dockerignore ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── main.yml │ ├── package.yml │ └── pages.yml ├── .gitignore ├── .helix └── languages.toml ├── .ignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.ps1 ├── cliff.toml ├── docs ├── .vitepress │ └── config.ts ├── develop │ └── architecture.md ├── getting-started │ ├── configuration.md │ ├── ffmpeg.md │ └── installation.md ├── index.md ├── public │ └── images │ │ ├── accounts.png │ │ ├── archives.png │ │ ├── coveredit.png │ │ ├── donate.png │ │ ├── douyin_cookie.png │ │ ├── header.png │ │ ├── icon.png │ │ ├── livewindow.png │ │ ├── rooms.png │ │ ├── settings.png │ │ └── summary.png └── usage │ ├── faq.md │ └── features.md ├── ffmpeg_setup.ps1 ├── index.html ├── live_index.html ├── package.json ├── postcss.config.cjs ├── public ├── imgs │ ├── donate.png │ └── douyin.png ├── shaka-player │ ├── controls.min.css │ ├── shaka-player.ui.js │ ├── shaka-player.ui.map │ └── youtube-theme.css ├── svelte.svg ├── tauri.svg └── vite.svg ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── migrated.json ├── config.example.toml ├── gen │ └── schemas │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ ├── macOS-schema.json │ │ └── windows-schema.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── archive_migration.rs │ ├── config.rs │ ├── danmu2ass.rs │ ├── database.rs │ ├── database │ │ ├── account.rs │ │ ├── message.rs │ │ ├── record.rs │ │ ├── recorder.rs │ │ └── video.rs │ ├── ffmpeg.rs │ ├── handlers │ │ ├── account.rs │ │ ├── config.rs │ │ ├── macros.rs │ │ ├── message.rs │ │ ├── mod.rs │ │ ├── recorder.rs │ │ ├── utils.rs │ │ └── video.rs │ ├── http_server.rs │ ├── main.rs │ ├── migration.rs │ ├── progress_manager.rs │ ├── progress_reporter.rs │ ├── recorder.rs │ ├── recorder │ │ ├── bilibili.rs │ │ ├── bilibili │ │ │ ├── client.rs │ │ │ ├── errors.rs │ │ │ ├── profile.rs │ │ │ └── response.rs │ │ ├── danmu.rs │ │ ├── douyin.rs │ │ ├── douyin │ │ │ ├── client.rs │ │ │ ├── response.rs │ │ │ └── stream_info.rs │ │ ├── entry.rs │ │ ├── errors.rs │ │ ├── recorder_manager.rs │ │ └── ts.rs │ ├── recorder_manager.rs │ ├── state.rs │ ├── subtitle_generator.rs │ ├── subtitle_generator │ │ └── whisper.rs │ └── tray.rs ├── tauri.conf.json ├── tauri.linux.conf.json ├── tauri.macos.conf.json ├── tauri.windows.conf.json ├── tauri.windows.cuda.conf.json └── tests │ ├── audio │ └── test.wav │ └── model │ ├── .gitkeep │ └── ggml-tiny-q5_1.bin ├── src ├── App.svelte ├── AppLive.svelte ├── env.d.ts ├── lib │ ├── AutoRecordIcon.svelte │ ├── BSidebar.svelte │ ├── BilibiliIcon.svelte │ ├── CoverEditor.svelte │ ├── DouyinIcon.svelte │ ├── Image.svelte │ ├── MarkerPanel.svelte │ ├── Player.svelte │ ├── SidebarItem.svelte │ ├── SubtitleStyleEditor.svelte │ ├── TypeSelect.svelte │ ├── VideoPreview.svelte │ ├── db.ts │ ├── interface.ts │ ├── invoker.ts │ └── stores │ │ └── version.ts ├── live_main.ts ├── main.ts ├── page │ ├── About.svelte │ ├── Account.svelte │ ├── Room.svelte │ ├── Setting.svelte │ └── Summary.svelte ├── styles.css └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | .npm 5 | .yarn/cache 6 | .yarn/unplugged 7 | .yarn/build-state.yml 8 | .yarn/install-state.gz 9 | 10 | # Build outputs 11 | dist 12 | build 13 | target 14 | *.log 15 | 16 | # Version control 17 | .git 18 | .gitignore 19 | 20 | # IDE and editor files 21 | .idea 22 | .vscode 23 | *.swp 24 | *.swo 25 | .DS_Store 26 | 27 | # Environment files 28 | .env 29 | .env.local 30 | .env.*.local 31 | 32 | # Debug files 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | 37 | # Tauri specific 38 | src-tauri/target 39 | src-tauri/dist -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # BiliBili-ShadowReplay contribute guide 2 | 3 | ## Project Setup 4 | 5 | ### MacOS 6 | 7 | 项目无需额外配置,直接 `yarn tauri dev` 即可编译运行。 8 | 9 | ### Linux 10 | 11 | 也无需额外配置。 12 | 13 | ### Windows 14 | 15 | Windows 下分为两个版本,分别是 `cpu` 和 `cuda` 版本。区别在于 Whisper 是否使用 GPU 加速。`cpu` 版本使用 CPU 进行推理,`cuda` 版本使用 GPU 进行推理。 16 | 17 | 默认运行为 `cpu` 版本,使用 `yarn tauri dev --features cuda` 命令运行 `cuda` 版本。 18 | 19 | 在运行前,须要安装以下依赖: 20 | 21 | 1. 安装 LLVM 且配置相关环境变量,详情见 [LLVM Windows Setup](https://llvm.org/docs/GettingStarted.html#building-llvm-on-windows); 22 | 23 | 2. 安装 CUDA Toolkit,详情见 [CUDA Windows Setup](https://docs.nvidia.com/cuda/cuda-installation-guide-microsoft-windows/index.html);要注意,安装时请勾选 **VisualStudio integration**。 24 | 25 | ### 常见问题 26 | 27 | #### 1. error C3688 28 | 29 | 构建前配置参数 `/utf-8`: 30 | 31 | ```powershell 32 | $env:CMAKE_CXX_FLAGS="/utf-8" 33 | ``` 34 | 35 | #### 2. error: 'exists' is unavailable: introduced in macOS 10.15 36 | 37 | 配置环境变量 `CMAKE_OSX_DEPLOYMENT_TARGET`,不低于 `13.3`。 38 | 39 | ### 3. CUDA arch 错误 40 | 41 | 配置环境变量 `CMAKE_CUDA_ARCHITECTURES`,可以参考 Workflows 中的配置。 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: 提交一个 BUG 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: Xinrea 7 | --- 8 | 9 | **描述:** 10 | 简要描述一下这个 BUG 的现象 11 | 12 | **日志和截图:** 13 | 如果可以的话,请尽量附上相关截图和日志文件(日志是位于安装目录下,名为 bsr.log 的文件)。 14 | 15 | **相关信息:** 16 | 17 | - 程序版本: 18 | - 系统类型: 19 | 20 | **其他** 21 | 任何其他想说的 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: 提交一个新功能的建议 4 | title: "[feature]" 5 | labels: enhancement 6 | assignees: Xinrea 7 | 8 | --- 9 | 10 | **遇到的问题:** 11 | 在使用过程中遇到了什么问题让你想要提出建议 12 | 13 | **想要的功能:** 14 | 想要怎样的新功能来解决这个问题 15 | 16 | **通过什么方式实现(有思路的话):** 17 | 如果有相关的实现思路或者是参考,可以在此提供 18 | 19 | **其他:** 20 | 其他任何想说的话 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - "v*" 7 | jobs: 8 | publish-tauri: 9 | permissions: 10 | contents: write 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - platform: "macos-latest" # for Intel based macs. 16 | args: "--target x86_64-apple-darwin" 17 | - platform: "ubuntu-22.04" 18 | args: "" 19 | - platform: "windows-latest" 20 | args: "--features cuda" 21 | features: "cuda" 22 | - platform: "windows-latest" 23 | args: "" 24 | features: "cpu" 25 | 26 | runs-on: ${{ matrix.platform }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set build type 31 | id: build_type 32 | run: | 33 | if [[ "${{ github.ref }}" == *"rc"* ]]; then 34 | echo "debug=true" >> $GITHUB_OUTPUT 35 | else 36 | echo "debug=false" >> $GITHUB_OUTPUT 37 | fi 38 | shell: bash 39 | 40 | - name: install dependencies (ubuntu only) 41 | if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above. 42 | run: | 43 | sudo apt-get update 44 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 45 | 46 | - name: setup node 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: lts/* 50 | cache: "yarn" # Set this to npm, yarn or pnpm. 51 | 52 | - name: install Rust stable 53 | uses: dtolnay/rust-toolchain@stable # Set this to dtolnay/rust-toolchain@nightly 54 | with: 55 | # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. 56 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 57 | 58 | - name: Install CUDA toolkit (Windows CUDA only) 59 | if: matrix.platform == 'windows-latest' && matrix.features == 'cuda' 60 | uses: Jimver/cuda-toolkit@master 61 | 62 | - name: Rust cache 63 | uses: swatinem/rust-cache@v2 64 | with: 65 | workspaces: "./src-tauri -> target" 66 | 67 | - name: Setup ffmpeg 68 | if: matrix.platform == 'windows-latest' 69 | working-directory: ./ 70 | shell: pwsh 71 | # running script ffmpeg_setup.ps1 to install ffmpeg on windows. 72 | # This script is located in the root of the repository. 73 | run: ./ffmpeg_setup.ps1 74 | 75 | - name: install frontend dependencies 76 | # If you don't have `beforeBuildCommand` configured you may want to build your frontend here too. 77 | run: yarn install # change this to npm or pnpm depending on which one you use. 78 | 79 | - name: Copy CUDA DLLs (Windows CUDA only) 80 | if: matrix.platform == 'windows-latest' && matrix.features == 'cuda' 81 | shell: pwsh 82 | run: | 83 | $cudaPath = "$env:CUDA_PATH\bin" 84 | $targetPath = "src-tauri" 85 | New-Item -ItemType Directory -Force -Path $targetPath 86 | Copy-Item "$cudaPath\cudart64*.dll" -Destination $targetPath 87 | Copy-Item "$cudaPath\cublas64*.dll" -Destination $targetPath 88 | Copy-Item "$cudaPath\cublasLt64*.dll" -Destination $targetPath 89 | 90 | - uses: tauri-apps/tauri-action@v0 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | CMAKE_OSX_DEPLOYMENT_TARGET: "13.3" 94 | CMAKE_CUDA_ARCHITECTURES: "75" 95 | WHISPER_BACKEND: ${{ matrix.features }} 96 | with: 97 | tagName: v__VERSION__ 98 | releaseName: "BiliBili ShadowReplay v__VERSION__" 99 | releaseBody: "See the assets to download this version and install." 100 | releaseDraft: true 101 | prerelease: false 102 | args: ${{ matrix.args }} ${{ matrix.platform == 'windows-latest' && matrix.features == 'cuda' && '--config src-tauri/tauri.windows.cuda.conf.json' || '' }} 103 | includeDebug: true 104 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Log in to the Container registry 25 | uses: docker/login-action@v3 26 | with: 27 | registry: ${{ env.REGISTRY }} 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Extract metadata (tags, labels) for Docker 32 | id: meta 33 | uses: docker/metadata-action@v5 34 | with: 35 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 36 | tags: | 37 | type=sha,format=long 38 | type=ref,event=branch 39 | type=ref,event=pr 40 | type=semver,pattern={{version}} 41 | type=semver,pattern={{major}}.{{minor}} 42 | type=semver,pattern={{major}} 43 | type=raw,value=latest,enable={{is_default_branch}} 44 | 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@v5 47 | with: 48 | context: . 49 | push: true 50 | tags: ${{ steps.meta.outputs.tags }} 51 | labels: ${{ steps.meta.outputs.labels }} 52 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | # Runs on pushes targeting the `main` branch. Change this to `master` if you're 5 | # using the `master` branch as the default branch. 6 | push: 7 | branches: [main] 8 | paths: 9 | - docs/** 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 21 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 22 | concurrency: 23 | group: pages 24 | cancel-in-progress: false 25 | 26 | jobs: 27 | # Build job 28 | build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 35 | # - uses: pnpm/action-setup@v3 # Uncomment this block if you're using pnpm 36 | # with: 37 | # version: 9 # Not needed if you've set "packageManager" in package.json 38 | # - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun 39 | - name: Setup Node 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: 22 43 | cache: npm # or pnpm / yarn 44 | - name: Setup Pages 45 | uses: actions/configure-pages@v4 46 | - name: Install dependencies 47 | run: yarn install # or pnpm install / yarn install / bun install 48 | - name: Build with VitePress 49 | run: yarn run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build 50 | - name: Upload artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | path: docs/.vitepress/dist 54 | 55 | # Deployment job 56 | deploy: 57 | environment: 58 | name: github-pages 59 | url: ${{ steps.deployment.outputs.page_url }} 60 | needs: build 61 | runs-on: ubuntu-latest 62 | name: Deploy 63 | steps: 64 | - name: Deploy to GitHub Pages 65 | id: deployment 66 | uses: actions/deploy-pages@v4 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | *.zip 26 | 27 | src-tauri/*.exe 28 | 29 | # test files 30 | src-tauri/tests/audio/*.srt 31 | 32 | .env 33 | 34 | docs/.vitepress/cache 35 | docs/.vitepress/dist 36 | 37 | *.debug.js 38 | *.debug.map 39 | -------------------------------------------------------------------------------- /.helix/languages.toml: -------------------------------------------------------------------------------- 1 | [[language]] 2 | name = "rust" 3 | auto-format = true 4 | 5 | [[language]] 6 | name = "svelte" 7 | auto-format = true 8 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.svg 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build frontend 2 | FROM node:20-bullseye AS frontend-builder 3 | 4 | WORKDIR /app 5 | 6 | # Install system dependencies 7 | RUN apt-get update && apt-get install -y \ 8 | python3 \ 9 | make \ 10 | g++ \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | # Copy package files 14 | COPY package.json yarn.lock ./ 15 | 16 | # Install dependencies with specific flags 17 | RUN yarn install --frozen-lockfile 18 | 19 | # Copy source files 20 | COPY . . 21 | 22 | # Build frontend 23 | RUN yarn build 24 | 25 | # Build Rust backend 26 | FROM rust:1.86-slim AS rust-builder 27 | 28 | WORKDIR /app 29 | 30 | # Install required system dependencies 31 | RUN apt-get update && apt-get install -y \ 32 | cmake \ 33 | pkg-config \ 34 | libssl-dev \ 35 | glib-2.0-dev \ 36 | libclang-dev \ 37 | g++ \ 38 | wget \ 39 | xz-utils \ 40 | && rm -rf /var/lib/apt/lists/* 41 | 42 | # Copy Rust project files 43 | COPY src-tauri/Cargo.toml src-tauri/Cargo.lock ./src-tauri/ 44 | COPY src-tauri/src ./src-tauri/src 45 | 46 | # Build Rust backend 47 | WORKDIR /app/src-tauri 48 | RUN rustup component add rustfmt 49 | RUN cargo build --no-default-features --features headless --release 50 | # Download and install FFmpeg static build 51 | RUN wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ 52 | && tar xf ffmpeg-release-amd64-static.tar.xz \ 53 | && mv ffmpeg-*-static/ffmpeg ./ \ 54 | && mv ffmpeg-*-static/ffprobe ./ \ 55 | && rm -rf ffmpeg-*-static ffmpeg-release-amd64-static.tar.xz 56 | 57 | # Final stage 58 | FROM debian:bookworm-slim AS final 59 | 60 | WORKDIR /app 61 | 62 | # Install runtime dependencies, SSL certificates and Chinese fonts 63 | RUN apt-get update && apt-get install -y \ 64 | libssl3 \ 65 | ca-certificates \ 66 | fonts-wqy-microhei \ 67 | && update-ca-certificates \ 68 | && rm -rf /var/lib/apt/lists/* 69 | 70 | # Add /app to PATH 71 | ENV PATH="/app:${PATH}" 72 | 73 | # Copy built frontend 74 | COPY --from=frontend-builder /app/dist ./dist 75 | 76 | # Copy built Rust binary 77 | COPY --from=rust-builder /app/src-tauri/target/release/bili-shadowreplay . 78 | COPY --from=rust-builder /app/src-tauri/ffmpeg ./ffmpeg 79 | COPY --from=rust-builder /app/src-tauri/ffprobe ./ffprobe 80 | 81 | # Expose port 82 | EXPOSE 3000 83 | 84 | # Run the application 85 | CMD ["./bili-shadowreplay"] 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Xinrea 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BiliBili ShadowReplay 2 | 3 | ![icon](docs/public/images/header.png) 4 | 5 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/xinrea/bili-shadowreplay/main.yml?label=Application%20Build) 6 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Xinrea/bili-shadowreplay/package.yml?label=Docker%20Build) 7 | 8 | ![GitHub Release](https://img.shields.io/github/v/release/xinrea/bili-shadowreplay) 9 | ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/xinrea/bili-shadowreplay/total) 10 | 11 | BiliBili ShadowReplay 是一个缓存直播并进行实时编辑投稿的工具。通过划定时间区间,并编辑简单的必需信息,即可完成直播切片以及投稿,将整个流程压缩到分钟级。同时,也支持对缓存的历史直播进行回放,以及相同的切片编辑投稿处理流程。 12 | 13 | 目前仅支持 B 站和抖音平台的直播。 14 | 15 | ![rooms](docs/public/images/summary.png) 16 | 17 | ## 安装和使用 18 | 19 | 前往网站查看说明:[BiliBili ShadowReplay](https://bsr.xinrea.cn/) 20 | 21 | ## 参与开发 22 | 23 | [Contributing](.github/CONTRIBUTING.md) 24 | 25 | ## 赞助 26 | 27 | ![donate](docs/public/images/donate.png) 28 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | # run yarn tauri build 2 | 3 | yarn tauri build 4 | yarn tauri build --debug 5 | 6 | # rename the builds, "bili-shadowreplay" to "bili-shadowreplay-cpu" 7 | Get-ChildItem -Path ./src-tauri/target/release/bundle/msi/ | ForEach-Object { 8 | $newName = $_.Name -replace 'bili-shadowreplay', 'bili-shadowreplay-cpu' 9 | Rename-Item -Path $_.FullName -NewName $newName 10 | } 11 | Get-ChildItem -Path ./src-tauri/target/release/bundle/nsis/ | ForEach-Object { 12 | $newName = $_.Name -replace 'bili-shadowreplay', 'bili-shadowreplay-cpu' 13 | Rename-Item -Path $_.FullName -NewName $newName 14 | } 15 | 16 | # rename the debug builds, "bili-shadowreplay" to "bili-shadowreplay-cpu" 17 | Get-ChildItem -Path ./src-tauri/target/debug/bundle/msi/ | ForEach-Object { 18 | $newName = $_.Name -replace 'bili-shadowreplay', 'bili-shadowreplay-cpu' 19 | Rename-Item -Path $_.FullName -NewName $newName 20 | } 21 | Get-ChildItem -Path ./src-tauri/target/debug/bundle/nsis/ | ForEach-Object { 22 | $newName = $_.Name -replace 'bili-shadowreplay', 'bili-shadowreplay-cpu' 23 | Rename-Item -Path $_.FullName -NewName $newName 24 | } 25 | 26 | # move the build to the correct location 27 | Move-Item ./src-tauri/target/release/bundle/msi/* ./src-tauri/target/ 28 | Move-Item ./src-tauri/target/release/bundle/nsis/* ./src-tauri/target/ 29 | 30 | # rename debug builds to add "-debug" suffix 31 | Get-ChildItem -Path ./src-tauri/target/debug/bundle/msi/ | ForEach-Object { 32 | $newName = $_.Name -replace '\.msi$', '-debug.msi' 33 | Rename-Item -Path $_.FullName -NewName $newName 34 | } 35 | Get-ChildItem -Path ./src-tauri/target/debug/bundle/nsis/ | ForEach-Object { 36 | $newName = $_.Name -replace '\.exe$', '-debug.exe' 37 | Rename-Item -Path $_.FullName -NewName $newName 38 | } 39 | 40 | # move the debug builds to the correct location 41 | Move-Item ./src-tauri/target/debug/bundle/msi/* ./src-tauri/target/ 42 | Move-Item ./src-tauri/target/debug/bundle/nsis/* ./src-tauri/target/ 43 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # template for the changelog header 10 | # header = """""" 11 | # template for the changelog body 12 | # https://keats.github.io/tera/docs/#introduction 13 | body = """ 14 | {% if version %}\ 15 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 16 | {% else %}\ 17 | ## [unreleased] 18 | {% endif %}\ 19 | {% for group, commits in commits | group_by(attribute="group") %} 20 | ### {{ group | striptags | trim | upper_first }} 21 | {% for commit in commits %} 22 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 23 | {% if commit.breaking %}[**breaking**] {% endif %}\ 24 | {{ commit.message | upper_first }} by @{{ commit.author.name }} - {{ commit.id }}\ 25 | {% endfor %} 26 | {% endfor %}\n 27 | """ 28 | # template for the changelog footer 29 | # footer = """""" 30 | # remove the leading and trailing s 31 | trim = true 32 | # postprocessors 33 | postprocessors = [ 34 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 35 | ] 36 | # render body even when there are no releases to process 37 | # render_always = true 38 | # output file path 39 | # output = "test.md" 40 | 41 | [git] 42 | # parse the commits based on https://www.conventionalcommits.org 43 | conventional_commits = true 44 | # filter out the commits that are not conventional 45 | filter_unconventional = true 46 | # process each line of a commit as an individual commit 47 | split_commits = false 48 | # regex for preprocessing the commit messages 49 | commit_preprocessors = [ 50 | # Replace issue numbers 51 | #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, 52 | # Check spelling of the commit with https://github.com/crate-ci/typos 53 | # If the spelling is incorrect, it will be automatically fixed. 54 | #{ pattern = '.*', replace_command = 'typos --write-changes -' }, 55 | ] 56 | # regex for parsing and grouping commits 57 | commit_parsers = [ 58 | { message = "^feat", group = "🚀 Features" }, 59 | { message = "^fix", group = "🐛 Bug Fixes" }, 60 | { message = "^doc", group = "📚 Documentation" }, 61 | { message = "^perf", group = "⚡ Performance" }, 62 | { message = "^refactor", group = "🚜 Refactor" }, 63 | { message = "^style", group = "🎨 Styling" }, 64 | { message = "^test", group = "🧪 Testing" }, 65 | { message = "^chore\\(release\\): prepare for", skip = true }, 66 | { message = "^chore\\(deps.*\\)", skip = true }, 67 | { message = "^chore\\(pr\\)", skip = true }, 68 | { message = "^chore\\(pull\\)", skip = true }, 69 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 70 | { body = ".*security", group = "🛡️ Security" }, 71 | { message = "^revert", group = "◀️ Revert" }, 72 | ] 73 | # filter out the commits that are not matched by commit parsers 74 | filter_commits = false 75 | # sort the tags topologically 76 | topo_order = false 77 | # sort the commits inside sections by oldest/newest order 78 | sort_commits = "oldest" 79 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "BiliBili ShadowReplay", 6 | description: "直播录制/实时回放/剪辑/投稿工具", 7 | themeConfig: { 8 | // https://vitepress.dev/reference/default-theme-config 9 | nav: [ 10 | { text: "Home", link: "/" }, 11 | { 12 | text: "Releases", 13 | link: "https://github.com/Xinrea/bili-shadowreplay/releases", 14 | }, 15 | ], 16 | 17 | sidebar: [ 18 | { 19 | text: "开始使用", 20 | items: [ 21 | { text: "安装准备", link: "/getting-started/installation" }, 22 | { text: "配置使用", link: "/getting-started/configuration" }, 23 | { text: "FFmpeg 配置", link: "/getting-started/ffmpeg" }, 24 | ], 25 | }, 26 | { 27 | text: "说明文档", 28 | items: [ 29 | { text: "功能说明", link: "/usage/features" }, 30 | { text: "常见问题", link: "/usage/faq" }, 31 | ], 32 | }, 33 | { 34 | text: "开发文档", 35 | items: [{ text: "架构设计", link: "/develop/architecture" }], 36 | }, 37 | ], 38 | 39 | socialLinks: [ 40 | { icon: "github", link: "https://github.com/Xinrea/bili-shadowreplay" }, 41 | ], 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /docs/develop/architecture.md: -------------------------------------------------------------------------------- 1 | # 架构设计 2 | -------------------------------------------------------------------------------- /docs/getting-started/configuration.md: -------------------------------------------------------------------------------- 1 | # 配置使用 2 | 3 | ## 账号配置 4 | 5 | 要添加直播间,至少需要配置一个同平台的账号。在账号页面,你可以通过添加账号按钮添加一个账号。 6 | 7 | - B 站账号:目前支持扫码登录和 Cookie 手动配置两种方式,推荐使用扫码登录 8 | - 抖音账号:目前仅支持 Cookie 手动配置登陆 9 | 10 | ### 抖音账号配置 11 | 12 | 首先确保已经登录抖音,然后打开[个人主页](https://www.douyin.com/user/self),右键单击网页,在菜单中选择 `检查(Inspect)`,打开开发者工具,切换到 `网络(Network)` 选项卡,然后刷新网页,此时能在列表中找到 `self` 请求(一般是列表中第一个),单击该请求,查看`请求标头`,在 `请求标头` 中找到 `Cookie`,复制该字段的值,粘贴到配置页面的 `Cookie` 输入框中,要注意复制完全。 13 | 14 | ![DouyinCookie](/images/douyin_cookie.png) 15 | 16 | ## FFmpeg 配置 17 | 18 | 如果想要使用切片生成和压制功能,请确保 FFmpeg 已正确配置;除了 Windows 平台打包自带 FFfmpeg 以外,其他平台需要手动安装 FFfmpeg,请参考 [FFfmpeg 配置](/getting-started/ffmpeg)。 19 | 20 | ## Whisper 模型配置 21 | 22 | 要使用 AI 字幕识别功能,需要在设置页面配置 Whisper 模型路径,模型文件可以从网络上下载,例如: 23 | 24 | - [Whisper.cpp(国内镜像,内容较旧)](https://www.modelscope.cn/models/cjc1887415157/whisper.cpp/files) 25 | - [Whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main) 26 | 27 | 可以跟据自己的需求选择不同的模型,要注意带有 `en` 的模型是英文模型,其他模型为多语言模型。 28 | -------------------------------------------------------------------------------- /docs/getting-started/ffmpeg.md: -------------------------------------------------------------------------------- 1 | # FFmpeg 配置 2 | 3 | FFmpeg 是一个开源的音视频处理工具,支持多种格式的音视频编解码、转码、剪辑、合并等操作。 4 | 在本项目中,FFmpeg 用于切片生成以及字幕和弹幕的硬编码处理,因此需要确保安装了 FFmpeg。 5 | 6 | ## MacOS 7 | 8 | 在 MacOS 上安装 FFmpeg 非常简单,可以使用 Homebrew 来安装: 9 | 10 | ```bash 11 | brew install ffmpeg 12 | ``` 13 | 14 | 如果没有安装 Homebrew,可以参考 [Homebrew 官网](https://brew.sh/) 进行安装。 15 | 16 | ## Linux 17 | 18 | 在 Linux 上安装 FFmpeg 可以使用系统自带的包管理器进行安装,例如: 19 | 20 | - Ubuntu/Debian 系统: 21 | 22 | ```bash 23 | sudo apt install ffmpeg 24 | ``` 25 | 26 | - Fedora 系统: 27 | 28 | ```bash 29 | sudo dnf install ffmpeg 30 | ``` 31 | 32 | - Arch Linux 系统: 33 | 34 | ```bash 35 | sudo pacman -S ffmpeg 36 | ``` 37 | 38 | - CentOS 系统: 39 | 40 | ```bash 41 | sudo yum install epel-release 42 | sudo yum install ffmpeg 43 | ``` 44 | 45 | ## Windows 46 | 47 | Windows 版本安装后,FFmpeg 已经放置在了程序目录下,因此不需要额外安装。 48 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # 安装准备 2 | 3 | ## 桌面端安装 4 | 5 | 桌面端目前提供了 Windows、Linux 和 MacOS 三个平台的安装包。 6 | 7 | 安装包分为两个版本,普通版和 debug 版,普通版适合大部分用户使用,debug 版包含了更多的调试信息,适合开发者使用;由于程序会对账号等敏感信息进行管理,请从信任的来源进行下载;所有版本均可在 [GitHub Releases](https://github.com/Xinrea/bili-shadowreplay/releases) 页面下载安装。 8 | 9 | ### Windows 10 | 11 | 由于程序内置 Whisper 字幕识别模型支持,Windows 版本分为两种: 12 | 13 | - **普通版本**:内置了 Whisper GPU 加速,字幕识别较快,体积较大,只支持 Nvidia 显卡 14 | - **CPU 版本**: 使用 CPU 进行字幕识别推理,速度较慢 15 | 16 | 请根据自己的显卡情况选择合适的版本进行下载。 17 | 18 | ### Linux 19 | 20 | Linux 版本目前仅支持使用 CPU 推理,且测试较少,可能存在一些问题,遇到问题请及时反馈。 21 | 22 | ### MacOS 23 | 24 | MacOS 版本内置 Metal GPU 加速;安装后首次运行,会提示无法打开从网络下载的软件,请在设置-隐私与安全性下,选择仍然打开以允许程序运行。 25 | 26 | ## Docker 部署 27 | 28 | BiliBili ShadowReplay 提供了服务端部署的能力,提供 Web 控制界面,可以用于在服务器等无图形界面环境下部署使用。 29 | 30 | ### 镜像获取 31 | 32 | ```bash 33 | # 拉取最新版本 34 | docker pull ghcr.io/xinrea/bili-shadowreplay:latest 35 | # 拉取指定版本 36 | docker pull ghcr.io/xinrea/bili-shadowreplay:2.5.0 37 | # 速度太慢?从镜像源拉取 38 | docker pull ghcr.nju.edu.cn/xinrea/bili-shadowreplay:latest 39 | ``` 40 | 41 | ### 镜像使用 42 | 43 | 使用方法: 44 | 45 | ```bash 46 | sudo docker run -it -d\ 47 | -p 3000:3000 \ 48 | -v $DATA_DIR:/app/data \ 49 | -v $CACHE_DIR:/app/cache \ 50 | -v $OUTPUT_DIR:/app/output \ 51 | -v $WHISPER_MODEL:/app/whisper_model.bin \ 52 | --name bili-shadowreplay \ 53 | ghcr.io/xinrea/bili-shadowreplay:latest 54 | ``` 55 | 56 | 其中: 57 | 58 | - `$DATA_DIR`:为数据目录,对应于桌面版的数据目录, 59 | 60 | Windows 下位于 `C:\Users\{用户名}\AppData\Roaming\cn.vjoi.bilishadowreplay`; 61 | 62 | MacOS 下位于 `/Users/{user}/Library/Application Support/cn.vjoi.bilishadowreplay` 63 | 64 | - `$CACHE_DIR`:为缓存目录,对应于桌面版的缓存目录; 65 | - `$OUTPUT_DIR`:为输出目录,对应于桌面版的输出目录; 66 | - `$WHISPER_MODEL`:为 Whisper 模型文件路径,对应于桌面版的 Whisper 模型文件路径。 67 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "BiliBili ShadowReplay" 7 | tagline: "直播录制/实时回放/剪辑/投稿工具" 8 | image: 9 | src: /images/icon.png 10 | alt: BiliBili ShadowReplay 11 | actions: 12 | - theme: brand 13 | text: 开始使用 14 | link: /getting-started/installation 15 | - theme: alt 16 | text: 说明文档 17 | link: /usage/features 18 | 19 | features: 20 | - icon: 📹 21 | title: 直播录制 22 | details: 缓存直播流,直播结束自动生成整场录播 23 | - icon: 📺 24 | title: 实时回放 25 | details: 实时回放当前直播,不错过任何内容 26 | - icon: ✂️ 27 | title: 剪辑投稿 28 | details: 剪辑切片,封面编辑,一键投稿 29 | - icon: 📝 30 | title: 字幕生成 31 | details: 支持 Wisper 模型生成字幕,编辑与压制 32 | - icon: 📄 33 | title: 弹幕支持 34 | details: 直播间弹幕压制到切片,并支持直播弹幕发送和导出 35 | - icon: 🌐 36 | title: 多直播平台支持 37 | details: 目前支持 B 站和抖音直播 38 | - icon: 🔍 39 | title: 云端部署 40 | details: 支持 Docker 部署,提供 Web 控制界面 41 | - icon: 📦 42 | title: 多平台支持 43 | details: 桌面端支持 Windows/Linux/macOS 44 | --- 45 | 46 | ## 总览 47 | 48 | ![rooms](/images/summary.png) 49 | 50 | ## 直播间管理 51 | 52 | ![clip](/images/rooms.png) 53 | 54 | ![archives](/images/archives.png) 55 | 56 | ## 账号管理 57 | 58 | ![accounts](/images/accounts.png) 59 | 60 | ## 预览窗口 61 | 62 | ![livewindow](/images/livewindow.png) 63 | 64 | ## 封面编辑 65 | 66 | ![cover](/images/coveredit.png) 67 | 68 | ## 设置 69 | 70 | ![settings](/images/settings.png) 71 | -------------------------------------------------------------------------------- /docs/public/images/accounts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/accounts.png -------------------------------------------------------------------------------- /docs/public/images/archives.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/archives.png -------------------------------------------------------------------------------- /docs/public/images/coveredit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/coveredit.png -------------------------------------------------------------------------------- /docs/public/images/donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/donate.png -------------------------------------------------------------------------------- /docs/public/images/douyin_cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/douyin_cookie.png -------------------------------------------------------------------------------- /docs/public/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/header.png -------------------------------------------------------------------------------- /docs/public/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/icon.png -------------------------------------------------------------------------------- /docs/public/images/livewindow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/livewindow.png -------------------------------------------------------------------------------- /docs/public/images/rooms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/rooms.png -------------------------------------------------------------------------------- /docs/public/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/settings.png -------------------------------------------------------------------------------- /docs/public/images/summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/public/images/summary.png -------------------------------------------------------------------------------- /docs/usage/faq.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/usage/faq.md -------------------------------------------------------------------------------- /docs/usage/features.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/docs/usage/features.md -------------------------------------------------------------------------------- /ffmpeg_setup.ps1: -------------------------------------------------------------------------------- 1 | # download ffmpeg from https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip 2 | $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" 3 | $ffmpegPath = "ffmpeg-release-essentials.zip" 4 | # download the file if it doesn't exist 5 | if (-not (Test-Path $ffmpegPath)) { 6 | Invoke-WebRequest -Uri $ffmpegUrl -OutFile $ffmpegPath 7 | } 8 | # extract the 7z file 9 | Add-Type -AssemblyName System.IO.Compression.FileSystem 10 | $extractPath = "ffmpeg" 11 | # check if the directory exists, if not create it 12 | if (-not (Test-Path $extractPath)) { 13 | New-Item -ItemType Directory -Path $extractPath 14 | } 15 | 16 | [System.IO.Compression.ZipFile]::ExtractToDirectory($ffmpegPath, $extractPath) 17 | 18 | # move the bin directory to the src-tauri directory 19 | # ffmpeg/ffmpeg-*-essentials_build/bin to src-tauri 20 | $ffmpegDir = Get-ChildItem -Path $extractPath -Directory | Where-Object { $_.Name -match "ffmpeg-.*-essentials_build" } 21 | if ($ffmpegDir) { 22 | $binPath = Join-Path $ffmpegDir.FullName "bin" 23 | } else { 24 | Write-Host "No ffmpeg directory found in the extracted files." 25 | exit 1 26 | } 27 | 28 | $destPath = Join-Path $PSScriptRoot "src-tauri" 29 | Copy-Item -Path "$binPath/*" -Destination $destPath -Recurse 30 | 31 | # remove the extracted directory 32 | Remove-Item $extractPath -Recurse -Force -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | BiliBili ShadowReplay 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /live_index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bili-shadowreplay", 3 | "private": true, 4 | "version": "2.5.9", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.json", 11 | "tauri": "tauri", 12 | "docs:dev": "vitepress dev docs", 13 | "docs:build": "vitepress build docs", 14 | "docs:preview": "vitepress preview docs" 15 | }, 16 | "dependencies": { 17 | "@tauri-apps/api": "^2.4.1", 18 | "@tauri-apps/plugin-dialog": "~2", 19 | "@tauri-apps/plugin-fs": "~2", 20 | "@tauri-apps/plugin-http": "~2", 21 | "@tauri-apps/plugin-notification": "~2", 22 | "@tauri-apps/plugin-os": "~2", 23 | "@tauri-apps/plugin-shell": "~2", 24 | "@tauri-apps/plugin-sql": "~2", 25 | "lucide-svelte": "^0.479.0", 26 | "qrcode": "^1.5.4" 27 | }, 28 | "devDependencies": { 29 | "@sveltejs/vite-plugin-svelte": "^2.0.0", 30 | "@tauri-apps/cli": "^2.4.1", 31 | "@tsconfig/svelte": "^3.0.0", 32 | "@types/node": "^18.7.10", 33 | "@types/qrcode": "^1.5.5", 34 | "autoprefixer": "^10.4.14", 35 | "flowbite": "^2.5.1", 36 | "flowbite-svelte": "^0.46.16", 37 | "flowbite-svelte-icons": "^1.6.1", 38 | "postcss": "^8.4.21", 39 | "svelte": "^3.54.0", 40 | "svelte-check": "^3.0.0", 41 | "svelte-preprocess": "^5.0.0", 42 | "tailwindcss": "^3.3.0", 43 | "ts-node": "^10.9.1", 44 | "tslib": "^2.4.1", 45 | "typescript": "^4.6.4", 46 | "vite": "^4.0.0", 47 | "vitepress": "^1.6.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')], 3 | }; 4 | -------------------------------------------------------------------------------- /public/imgs/donate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/public/imgs/donate.png -------------------------------------------------------------------------------- /public/imgs/douyin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/public/imgs/douyin.png -------------------------------------------------------------------------------- /public/shaka-player/youtube-theme.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: swap; 6 | src: url(https://fonts.gstatic.com/s/roboto/v27/KFOmCnqEu92Fr1Me5Q.ttf) format('truetype'); 7 | } 8 | @font-face { 9 | font-family: 'Roboto'; 10 | font-style: normal; 11 | font-weight: 500; 12 | font-display: swap; 13 | src: url(https://fonts.gstatic.com/s/roboto/v27/KFOlCnqEu92Fr1MmEU9vAw.ttf) format('truetype'); 14 | } 15 | .youtube-theme { 16 | font-family: 'Roboto', sans-serif; 17 | } 18 | .youtube-theme .shaka-bottom-controls { 19 | width: 100%; 20 | padding: 0; 21 | padding-bottom: 0; 22 | z-index: 1; 23 | } 24 | .youtube-theme .shaka-bottom-controls { 25 | display: -webkit-box; 26 | display: -ms-flexbox; 27 | display: flex; 28 | -webkit-box-orient: vertical; 29 | -webkit-box-direction: normal; 30 | -ms-flex-direction: column; 31 | flex-direction: column; 32 | } 33 | .youtube-theme .shaka-ad-controls { 34 | -webkit-box-ordinal-group: 2; 35 | -ms-flex-order: 1; 36 | order: 1; 37 | } 38 | .youtube-theme .shaka-controls-button-panel { 39 | -webkit-box-ordinal-group: 3; 40 | -ms-flex-order: 2; 41 | order: 2; 42 | height: 40px; 43 | padding: 0 10px; 44 | } 45 | .youtube-theme .shaka-range-container { 46 | margin: 4px 10px 4px 10px; 47 | top: 0; 48 | } 49 | .youtube-theme .shaka-small-play-button { 50 | -webkit-box-ordinal-group: -2; 51 | -ms-flex-order: -3; 52 | order: -3; 53 | } 54 | .youtube-theme .shaka-mute-button { 55 | -webkit-box-ordinal-group: -1; 56 | -ms-flex-order: -2; 57 | order: -2; 58 | } 59 | .youtube-theme .shaka-controls-button-panel > * { 60 | margin: 0; 61 | padding: 3px 8px; 62 | color: #EEE; 63 | height: 40px; 64 | } 65 | .youtube-theme .shaka-controls-button-panel > *:focus { 66 | outline: none; 67 | -webkit-box-shadow: inset 0 0 0 2px rgba(27, 127, 204, 0.8); 68 | box-shadow: inset 0 0 0 2px rgba(27, 127, 204, 0.8); 69 | color: #FFF; 70 | } 71 | .youtube-theme .shaka-controls-button-panel > *:hover { 72 | color: #FFF; 73 | } 74 | .youtube-theme .shaka-controls-button-panel .shaka-volume-bar-container { 75 | position: relative; 76 | z-index: 10; 77 | left: -1px; 78 | -webkit-box-ordinal-group: 0; 79 | -ms-flex-order: -1; 80 | order: -1; 81 | opacity: 0; 82 | width: 0px; 83 | -webkit-transition: width 0.2s cubic-bezier(0.4, 0, 1, 1); 84 | height: 3px; 85 | transition: width 0.2s cubic-bezier(0.4, 0, 1, 1); 86 | padding: 0; 87 | } 88 | .youtube-theme .shaka-controls-button-panel .shaka-volume-bar-container:hover, 89 | .youtube-theme .shaka-controls-button-panel .shaka-volume-bar-container:focus { 90 | display: block; 91 | width: 50px; 92 | opacity: 1; 93 | padding: 0 6px; 94 | } 95 | .youtube-theme .shaka-mute-button:hover + div { 96 | opacity: 1; 97 | width: 50px; 98 | padding: 0 6px; 99 | } 100 | .youtube-theme .shaka-current-time { 101 | padding: 0 10px; 102 | font-size: 12px; 103 | } 104 | .youtube-theme .shaka-seek-bar-container { 105 | height: 3px; 106 | position: relative; 107 | top: -1px; 108 | border-radius: 0; 109 | margin-bottom: 0; 110 | } 111 | .youtube-theme .shaka-seek-bar-container .shaka-range-element { 112 | opacity: 0; 113 | } 114 | .youtube-theme .shaka-seek-bar-container:hover { 115 | height: 5px; 116 | top: 0; 117 | cursor: pointer; 118 | } 119 | .youtube-theme .shaka-seek-bar-container:hover .shaka-range-element { 120 | opacity: 1; 121 | cursor: pointer; 122 | } 123 | .youtube-theme .shaka-seek-bar-container input[type=range]::-webkit-slider-thumb { 124 | background: #FF0000; 125 | cursor: pointer; 126 | } 127 | .youtube-theme .shaka-seek-bar-container input[type=range]::-moz-range-thumb { 128 | background: #FF0000; 129 | cursor: pointer; 130 | } 131 | .youtube-theme .shaka-seek-bar-container input[type=range]::-ms-thumb { 132 | background: #FF0000; 133 | cursor: pointer; 134 | } 135 | .youtube-theme .shaka-video-container * { 136 | font-family: 'Roboto', sans-serif; 137 | } 138 | .youtube-theme .shaka-video-container .material-icons-round { 139 | font-family: 'Material Icons Sharp'; 140 | } 141 | .youtube-theme .shaka-overflow-menu, 142 | .youtube-theme .shaka-settings-menu { 143 | border-radius: 2px; 144 | background: rgba(28, 28, 28, 0.9); 145 | text-shadow: 0 0 2px rgb(0 0 0%); 146 | -webkit-transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1); 147 | transition: opacity 0.1s cubic-bezier(0, 0, 0.2, 1); 148 | -moz-user-select: none; 149 | -ms-user-select: none; 150 | -webkit-user-select: none; 151 | right: 10px; 152 | bottom: 50px; 153 | padding: 8px 0; 154 | min-width: 200px; 155 | } 156 | .youtube-theme .shaka-settings-menu { 157 | padding: 0 0 8px 0; 158 | } 159 | .youtube-theme .shaka-settings-menu button { 160 | font-size: 12px; 161 | } 162 | .youtube-theme .shaka-settings-menu button span { 163 | margin-left: 33px; 164 | font-size: 13px; 165 | } 166 | .youtube-theme .shaka-settings-menu button[aria-selected="true"] { 167 | display: -webkit-box; 168 | display: -ms-flexbox; 169 | display: flex; 170 | } 171 | .youtube-theme .shaka-settings-menu button[aria-selected="true"] span { 172 | -webkit-box-ordinal-group: 3; 173 | -ms-flex-order: 2; 174 | order: 2; 175 | margin-left: 0; 176 | } 177 | .youtube-theme .shaka-settings-menu button[aria-selected="true"] i { 178 | -webkit-box-ordinal-group: 2; 179 | -ms-flex-order: 1; 180 | order: 1; 181 | font-size: 18px; 182 | padding-left: 5px; 183 | } 184 | .youtube-theme .shaka-overflow-menu button { 185 | padding: 0; 186 | } 187 | .youtube-theme .shaka-overflow-menu button i { 188 | display: none; 189 | } 190 | .youtube-theme .shaka-overflow-menu button .shaka-overflow-button-label { 191 | display: -webkit-box; 192 | display: -ms-flexbox; 193 | display: flex; 194 | -webkit-box-pack: justify; 195 | -ms-flex-pack: justify; 196 | justify-content: space-between; 197 | -webkit-box-orient: horizontal; 198 | -webkit-box-direction: normal; 199 | -ms-flex-direction: row; 200 | flex-direction: row; 201 | -webkit-box-align: center; 202 | -ms-flex-align: center; 203 | align-items: center; 204 | cursor: default; 205 | outline: none; 206 | height: 40px; 207 | -webkit-box-flex: 0; 208 | -ms-flex: 0 0 100%; 209 | flex: 0 0 100%; 210 | } 211 | .youtube-theme .shaka-overflow-menu button .shaka-overflow-button-label span { 212 | -ms-flex-negative: initial; 213 | flex-shrink: initial; 214 | padding-left: 15px; 215 | font-size: 13px; 216 | font-weight: 500; 217 | display: -webkit-box; 218 | display: -ms-flexbox; 219 | display: flex; 220 | -webkit-box-align: center; 221 | -ms-flex-align: center; 222 | align-items: center; 223 | } 224 | .youtube-theme .shaka-overflow-menu span + span { 225 | color: #FFF; 226 | font-weight: 400 !important; 227 | font-size: 12px !important; 228 | padding-right: 8px; 229 | padding-left: 0 !important; 230 | } 231 | .youtube-theme .shaka-overflow-menu span + span:after { 232 | content: "navigate_next"; 233 | font-family: 'Material Icons Sharp'; 234 | font-size: 20px; 235 | } 236 | .youtube-theme .shaka-overflow-menu .shaka-pip-button span + span { 237 | padding-right: 15px !important; 238 | } 239 | .youtube-theme .shaka-overflow-menu .shaka-pip-button span + span:after { 240 | content: ""; 241 | } 242 | .youtube-theme .shaka-back-to-overflow-button { 243 | padding: 8px 0; 244 | border-bottom: 1px solid rgba(255, 255, 255, 0.2); 245 | font-size: 12px; 246 | color: #eee; 247 | height: 40px; 248 | } 249 | .youtube-theme .shaka-back-to-overflow-button .material-icons-round { 250 | font-size: 15px; 251 | padding-right: 10px; 252 | } 253 | .youtube-theme .shaka-back-to-overflow-button span { 254 | margin-left: 3px !important; 255 | } 256 | .youtube-theme .shaka-overflow-menu button:hover, 257 | .youtube-theme .shaka-settings-menu button:hover { 258 | background-color: rgba(255, 255, 255, 0.1); 259 | cursor: pointer; 260 | } 261 | .youtube-theme .shaka-overflow-menu button:hover label, 262 | .youtube-theme .shaka-settings-menu button:hover label { 263 | cursor: pointer; 264 | } 265 | .youtube-theme .shaka-overflow-menu button:focus, 266 | .youtube-theme .shaka-settings-menu button:focus { 267 | background-color: rgba(255, 255, 255, 0.1); 268 | border: none; 269 | outline: none; 270 | } 271 | .youtube-theme .shaka-overflow-menu button, 272 | .youtube-theme .shaka-settings-menu button { 273 | color: #EEE; 274 | } 275 | .youtube-theme .shaka-captions-off { 276 | color: #BFBFBF; 277 | } 278 | .youtube-theme .shaka-overflow-menu-button { 279 | font-size: 18px; 280 | margin-right: 5px; 281 | } 282 | .youtube-theme .shaka-fullscreen-button:hover { 283 | font-size: 25px; 284 | -webkit-transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1); 285 | transition: font-size 0.1s cubic-bezier(0, 0, 0.2, 1); 286 | } 287 | -------------------------------------------------------------------------------- /public/svelte.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | cache 5 | output 6 | tmps 7 | clips 8 | data 9 | config.toml -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bili-shadowreplay" 3 | version = "1.0.0" 4 | description = "BiliBili ShadowReplay" 5 | authors = ["Xinrea"] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | serde_json = "1.0" 14 | reqwest = { version = "0.11", features = ["blocking", "json"] } 15 | serde_derive = "1.0.158" 16 | serde = "1.0.158" 17 | sysinfo = "0.32.0" 18 | m3u8-rs = "5.0.3" 19 | async-std = "1.12.0" 20 | async-ffmpeg-sidecar = "0.0.1" 21 | chrono = { version = "0.4.24", features = ["serde"] } 22 | toml = "0.7.3" 23 | custom_error = "1.9.2" 24 | felgens = { git = "https://github.com/Xinrea/felgens.git", tag = "v0.4.6" } 25 | regex = "1.7.3" 26 | tokio = { version = "1.27.0", features = ["process"] } 27 | platform-dirs = "0.3.0" 28 | pct-str = "1.2.0" 29 | md5 = "0.7.0" 30 | hyper = { version = "0.14", features = ["full"] } 31 | dashmap = "6.1.0" 32 | urlencoding = "2.1.3" 33 | log = "0.4.22" 34 | simplelog = "0.12.2" 35 | sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } 36 | rand = "0.8.5" 37 | base64 = "0.21" 38 | mime_guess = "2.0" 39 | async-trait = "0.1.87" 40 | whisper-rs = "0.14.2" 41 | hound = "3.5.1" 42 | uuid = { version = "1.4", features = ["v4"] } 43 | axum = { version = "0.7", features = ["macros"] } 44 | tower-http = { version = "0.5", features = ["cors", "fs", "limit"] } 45 | futures-core = "0.3" 46 | futures = "0.3" 47 | tokio-util = { version = "0.7", features = ["io"] } 48 | clap = { version = "4.5.37", features = ["derive"] } 49 | url = "2.5.4" 50 | 51 | [features] 52 | # this feature is used for production builds or when `devPath` points to the filesystem 53 | # DO NOT REMOVE!! 54 | custom-protocol = ["tauri/custom-protocol"] 55 | cuda = ["whisper-rs/cuda"] 56 | headless = [] 57 | default = ["gui"] 58 | gui = [ 59 | "tauri", 60 | "tauri-plugin-single-instance", 61 | "tauri-plugin-dialog", 62 | "tauri-plugin-shell", 63 | "tauri-plugin-fs", 64 | "tauri-plugin-http", 65 | "tauri-plugin-sql", 66 | "tauri-utils", 67 | "tauri-plugin-os", 68 | "tauri-plugin-notification", 69 | "fix-path-env", 70 | "tauri-build", 71 | ] 72 | 73 | [dependencies.tauri] 74 | version = "2" 75 | features = ["protocol-asset", "tray-icon"] 76 | optional = true 77 | 78 | [dependencies.tauri-plugin-single-instance] 79 | version = "2" 80 | optional = true 81 | 82 | [dependencies.tauri-plugin-dialog] 83 | version = "2" 84 | optional = true 85 | 86 | [dependencies.tauri-plugin-shell] 87 | version = "2" 88 | optional = true 89 | 90 | [dependencies.tauri-plugin-fs] 91 | version = "2" 92 | optional = true 93 | 94 | [dependencies.tauri-plugin-http] 95 | version = "2" 96 | optional = true 97 | 98 | [dependencies.tauri-plugin-sql] 99 | version = "2" 100 | optional = true 101 | features = ["sqlite"] 102 | 103 | [dependencies.tauri-utils] 104 | version = "2" 105 | optional = true 106 | 107 | [dependencies.tauri-plugin-os] 108 | version = "2" 109 | optional = true 110 | 111 | [dependencies.tauri-plugin-notification] 112 | version = "2" 113 | optional = true 114 | 115 | [dependencies.fix-path-env] 116 | git = "https://github.com/tauri-apps/fix-path-env-rs" 117 | optional = true 118 | 119 | [build-dependencies.tauri-build] 120 | version = "2" 121 | features = [] 122 | optional = true 123 | 124 | [target.'cfg(windows)'.dependencies] 125 | whisper-rs = { version = "0.14.2", default-features = false } 126 | 127 | [target.'cfg(darwin)'.dependencies.whisper-rs] 128 | version = "0.14.2" 129 | features = ["metal"] 130 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(feature = "gui")] 3 | tauri_build::build() 4 | } 5 | -------------------------------------------------------------------------------- /src-tauri/capabilities/migrated.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "migrated", 3 | "description": "permissions that were migrated from v1", 4 | "local": true, 5 | "windows": [ 6 | "main", 7 | "Live*" 8 | ], 9 | "permissions": [ 10 | "core:default", 11 | "fs:allow-read-file", 12 | "fs:allow-write-file", 13 | "fs:allow-read-dir", 14 | "fs:allow-copy-file", 15 | "fs:allow-mkdir", 16 | "fs:allow-remove", 17 | "fs:allow-remove", 18 | "fs:allow-rename", 19 | "fs:allow-exists", 20 | { 21 | "identifier": "fs:scope", 22 | "allow": [ 23 | "**" 24 | ] 25 | }, 26 | "core:window:default", 27 | "core:window:allow-start-dragging", 28 | "core:window:allow-close", 29 | "core:window:allow-minimize", 30 | "core:window:allow-maximize", 31 | "core:window:allow-unmaximize", 32 | "core:window:allow-set-title", 33 | "sql:allow-execute", 34 | "shell:allow-open", 35 | "dialog:allow-open", 36 | "dialog:allow-save", 37 | "dialog:allow-message", 38 | "dialog:allow-ask", 39 | "dialog:allow-confirm", 40 | { 41 | "identifier": "http:default", 42 | "allow": [ 43 | { 44 | "url": "https://*.hdslb.com/" 45 | }, 46 | { 47 | "url": "https://afdian.com/" 48 | }, 49 | { 50 | "url": "https://*.afdiancdn.com/" 51 | }, 52 | { 53 | "url": "https://*.douyin.com/" 54 | }, 55 | { 56 | "url": "https://*.douyinpic.com/" 57 | } 58 | ] 59 | }, 60 | "dialog:default", 61 | "shell:default", 62 | "fs:default", 63 | "http:default", 64 | "sql:default", 65 | "os:default", 66 | "notification:default", 67 | "dialog:default", 68 | "fs:default", 69 | "http:default", 70 | "shell:default", 71 | "sql:default", 72 | "os:default", 73 | "dialog:default" 74 | ] 75 | } -------------------------------------------------------------------------------- /src-tauri/config.example.toml: -------------------------------------------------------------------------------- 1 | cache = "./cache" 2 | output = "./output" 3 | live_start_notify = true 4 | live_end_notify = true 5 | clip_notify = true 6 | post_notify = true 7 | auto_subtitle = false 8 | whisper_model = "./whisper_model.bin" 9 | whisper_prompt = "这是一段中文 你们好" 10 | clip_name_format = "[{room_id}][{live_id}][{title}][{created_at}].mp4" 11 | 12 | [auto_generate] 13 | enabled = false 14 | encode_danmu = false 15 | -------------------------------------------------------------------------------- /src-tauri/gen/schemas/capabilities.json: -------------------------------------------------------------------------------- 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"]}} -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xinrea/bili-shadowreplay/ad976771049264c6c983c3035bed40ba4739aa6a/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/archive_migration.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::sync::Arc; 3 | 4 | use chrono::Utc; 5 | 6 | use crate::database::Database; 7 | use crate::recorder::PlatformType; 8 | 9 | pub async fn try_rebuild_archives( 10 | db: &Arc, 11 | cache_path: PathBuf, 12 | ) -> Result<(), Box> { 13 | let rooms = db.get_recorders().await?; 14 | for room in rooms { 15 | let room_id = room.room_id; 16 | let room_cache_path = cache_path.join(format!("{}/{}", room.platform, room_id)); 17 | let mut files = tokio::fs::read_dir(room_cache_path).await?; 18 | while let Some(file) = files.next_entry().await? { 19 | if file.file_type().await?.is_dir() { 20 | // use folder name as live_id 21 | let live_id = file.file_name(); 22 | let live_id = live_id.to_str().unwrap(); 23 | // check if live_id is in db 24 | let record = db.get_record(room_id, live_id).await; 25 | if record.is_ok() { 26 | continue; 27 | } 28 | 29 | // get created_at from folder metadata 30 | let metadata = file.metadata().await?; 31 | let created_at = metadata.created(); 32 | if created_at.is_err() { 33 | continue; 34 | } 35 | let created_at = created_at.unwrap(); 36 | let created_at = chrono::DateTime::::from(created_at) 37 | .format("%Y-%m-%dT%H:%M:%S.%fZ") 38 | .to_string(); 39 | // create a record for this live_id 40 | let record = db 41 | .add_record( 42 | PlatformType::from_str(room.platform.as_str()).unwrap(), 43 | live_id, 44 | room_id, 45 | &format!("UnknownLive {}", live_id), 46 | None, 47 | Some(&created_at), 48 | ) 49 | .await?; 50 | 51 | log::info!("rebuild archive {:?}", record); 52 | } 53 | } 54 | } 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /src-tauri/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use chrono::Utc; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{recorder::PlatformType, recorder_manager::ClipRangeParams}; 7 | 8 | #[derive(Deserialize, Serialize, Clone)] 9 | pub struct Config { 10 | pub cache: String, 11 | pub output: String, 12 | pub live_start_notify: bool, 13 | pub live_end_notify: bool, 14 | pub clip_notify: bool, 15 | pub post_notify: bool, 16 | #[serde(default = "default_auto_subtitle")] 17 | pub auto_subtitle: bool, 18 | #[serde(default = "default_whisper_model")] 19 | pub whisper_model: String, 20 | #[serde(default = "default_whisper_prompt")] 21 | pub whisper_prompt: String, 22 | #[serde(default = "default_clip_name_format")] 23 | pub clip_name_format: String, 24 | #[serde(default = "default_auto_generate_config")] 25 | pub auto_generate: AutoGenerateConfig, 26 | #[serde(default = "default_status_check_interval")] 27 | pub status_check_interval: u64, 28 | #[serde(skip)] 29 | pub config_path: String, 30 | } 31 | 32 | #[derive(Deserialize, Serialize, Clone)] 33 | pub struct AutoGenerateConfig { 34 | pub enabled: bool, 35 | pub encode_danmu: bool, 36 | } 37 | 38 | fn default_auto_subtitle() -> bool { 39 | false 40 | } 41 | 42 | fn default_whisper_model() -> String { 43 | "whisper_model.bin".to_string() 44 | } 45 | 46 | fn default_whisper_prompt() -> String { 47 | "这是一段中文 你们好".to_string() 48 | } 49 | 50 | fn default_clip_name_format() -> String { 51 | "[{room_id}][{live_id}][{title}][{created_at}].mp4".to_string() 52 | } 53 | 54 | fn default_auto_generate_config() -> AutoGenerateConfig { 55 | AutoGenerateConfig { 56 | enabled: false, 57 | encode_danmu: false, 58 | } 59 | } 60 | 61 | fn default_status_check_interval() -> u64 { 62 | 30 63 | } 64 | 65 | impl Config { 66 | pub fn load( 67 | config_path: &PathBuf, 68 | default_cache: &Path, 69 | default_output: &Path, 70 | ) -> Result { 71 | if let Ok(content) = std::fs::read_to_string(config_path) { 72 | if let Ok(mut config) = toml::from_str::(&content) { 73 | config.config_path = config_path.to_str().unwrap().into(); 74 | return Ok(config); 75 | } 76 | } 77 | 78 | if let Some(dir_path) = PathBuf::from(config_path).parent() { 79 | if let Err(e) = std::fs::create_dir_all(dir_path) { 80 | return Err(format!("Failed to create config dir: {e}")); 81 | } 82 | } 83 | 84 | let config = Config { 85 | cache: default_cache.to_str().unwrap().into(), 86 | output: default_output.to_str().unwrap().into(), 87 | live_start_notify: true, 88 | live_end_notify: true, 89 | clip_notify: true, 90 | post_notify: true, 91 | auto_subtitle: false, 92 | whisper_model: default_whisper_model(), 93 | whisper_prompt: default_whisper_prompt(), 94 | clip_name_format: default_clip_name_format(), 95 | auto_generate: default_auto_generate_config(), 96 | status_check_interval: default_status_check_interval(), 97 | config_path: config_path.to_str().unwrap().into(), 98 | }; 99 | 100 | config.save(); 101 | 102 | Ok(config) 103 | } 104 | 105 | pub fn save(&self) { 106 | let content = toml::to_string(&self).unwrap(); 107 | if let Err(e) = std::fs::write(self.config_path.clone(), content) { 108 | log::error!("Failed to save config: {} {}", e, self.config_path); 109 | } 110 | } 111 | 112 | pub fn set_cache_path(&mut self, path: &str) { 113 | self.cache = path.to_string(); 114 | self.save(); 115 | } 116 | 117 | pub fn set_output_path(&mut self, path: &str) { 118 | self.output = path.into(); 119 | self.save(); 120 | } 121 | 122 | pub fn generate_clip_name(&self, params: &ClipRangeParams) -> PathBuf { 123 | let platform = PlatformType::from_str(¶ms.platform).unwrap(); 124 | 125 | // get format config 126 | // filter special characters from title to make sure file name is valid 127 | let title = params 128 | .title 129 | .chars() 130 | .filter(|c| c.is_alphanumeric()) 131 | .collect::(); 132 | let format_config = self.clip_name_format.clone(); 133 | let format_config = format_config.replace("{title}", &title); 134 | let format_config = format_config.replace("{platform}", platform.as_str()); 135 | let format_config = format_config.replace("{room_id}", ¶ms.room_id.to_string()); 136 | let format_config = format_config.replace("{live_id}", ¶ms.live_id); 137 | let format_config = format_config.replace("{x}", ¶ms.x.to_string()); 138 | let format_config = format_config.replace("{y}", ¶ms.y.to_string()); 139 | let format_config = format_config.replace( 140 | "{created_at}", 141 | &Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string(), 142 | ); 143 | let format_config = format_config.replace("{length}", &(params.y - params.x).to_string()); 144 | 145 | let output = self.output.clone(); 146 | 147 | Path::new(&output).join(&format_config) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src-tauri/src/danmu2ass.rs: -------------------------------------------------------------------------------- 1 | use crate::recorder::danmu::DanmuEntry; 2 | use std::collections::VecDeque; 3 | 4 | // code reference: https://github.com/tiansh/us-danmaku/blob/master/bilibili/bilibili_ASS_Danmaku_Downloader.user.js 5 | 6 | #[derive(Debug, Clone)] 7 | struct UsedSpace { 8 | p: f64, // top position 9 | m: f64, // bottom position 10 | tf: f64, // time when fully visible 11 | td: f64, // time when completely gone 12 | b: bool, // is bottom reserved 13 | } 14 | 15 | #[derive(Debug, Clone)] 16 | struct PositionSuggestion { 17 | p: f64, // position 18 | r: f64, // delay 19 | } 20 | 21 | #[derive(Debug, Clone)] 22 | struct DanmakuPosition { 23 | top: f64, 24 | time: f64, 25 | } 26 | 27 | const PLAY_RES_X: f64 = 1920.0; 28 | const PLAY_RES_Y: f64 = 1080.0; 29 | const BOTTOM_RESERVED: f64 = 50.0; 30 | const R2L_TIME: f64 = 8.0; 31 | const MAX_DELAY: f64 = 6.0; 32 | 33 | pub fn danmu_to_ass(danmus: Vec) -> String { 34 | // ASS header 35 | let header = r#"[Script Info] 36 | Title: Bilibili Danmaku 37 | ScriptType: v4.00+ 38 | Collisions: Normal 39 | PlayResX: 1920 40 | PlayResY: 1080 41 | Timer: 10.0000 42 | 43 | [V4+ Styles] 44 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 45 | Style: Default,Microsoft YaHei,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0 46 | 47 | [Events] 48 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 49 | "#; 50 | 51 | let mut normal = normal_danmaku(); 52 | let font_size = 48.0; // Default font size 53 | 54 | // Convert danmus to ASS events 55 | let events = danmus 56 | .iter() 57 | .filter_map(|danmu| { 58 | // Calculate text width (approximate) 59 | let text_width = danmu.content.len() as f64 * font_size * 0.6; 60 | 61 | // Convert timestamp from ms to seconds 62 | let t0s = danmu.ts as f64 / 1000.0; 63 | 64 | // Get position from normal_danmaku 65 | let pos = normal(t0s, text_width, font_size, false)?; 66 | 67 | // Convert timestamp to ASS time format (H:MM:SS.CC) 68 | let start_time = format_time(pos.time); 69 | let end_time = format_time(pos.time + R2L_TIME); 70 | 71 | // Escape special characters in the text 72 | let text = escape_text(&danmu.content); 73 | 74 | // Create ASS event line with movement effect 75 | Some(format!( 76 | "Dialogue: 0,{},{},Default,,0,0,0,,{{\\move({},{},{},{})}}{}", 77 | start_time, 78 | end_time, 79 | PLAY_RES_X, 80 | pos.top + font_size, // Start position 81 | -text_width, 82 | pos.top + font_size, // End position 83 | text 84 | )) 85 | }) 86 | .collect::>() 87 | .join("\n"); 88 | 89 | // Combine header and events 90 | format!("{}\n{}", header, events) 91 | } 92 | 93 | fn format_time(seconds: f64) -> String { 94 | let hours = (seconds / 3600.0) as i32; 95 | let minutes = ((seconds % 3600.0) / 60.0) as i32; 96 | let seconds = seconds % 60.0; 97 | format!("{}:{:02}:{:05.2}", hours, minutes, seconds) 98 | } 99 | 100 | fn escape_text(text: &str) -> String { 101 | text.replace("\\", "\\\\") 102 | .replace("{", "{") 103 | .replace("}", "}") 104 | .replace("\r", "") 105 | .replace("\n", "\\N") 106 | } 107 | 108 | fn normal_danmaku() -> impl FnMut(f64, f64, f64, bool) -> Option { 109 | let mut used = VecDeque::new(); 110 | used.push_back(UsedSpace { 111 | p: f64::NEG_INFINITY, 112 | m: 0.0, 113 | tf: f64::INFINITY, 114 | td: f64::INFINITY, 115 | b: false, 116 | }); 117 | used.push_back(UsedSpace { 118 | p: PLAY_RES_Y, 119 | m: f64::INFINITY, 120 | tf: f64::INFINITY, 121 | td: f64::INFINITY, 122 | b: false, 123 | }); 124 | used.push_back(UsedSpace { 125 | p: PLAY_RES_Y - BOTTOM_RESERVED, 126 | m: PLAY_RES_Y, 127 | tf: f64::INFINITY, 128 | td: f64::INFINITY, 129 | b: true, 130 | }); 131 | 132 | move |t0s: f64, wv: f64, hv: f64, b: bool| { 133 | let t0l = (PLAY_RES_X / (wv + PLAY_RES_X)) * R2L_TIME + t0s; 134 | 135 | // Synchronize used spaces 136 | used.retain(|space| space.tf > t0s || space.td > t0l); 137 | 138 | // Find available positions 139 | let mut suggestions = Vec::new(); 140 | for space in &used { 141 | if space.m > PLAY_RES_Y { 142 | continue; 143 | } 144 | 145 | let p = space.m; 146 | let m = p + hv; 147 | let mut tas = t0s; 148 | let mut tal = t0l; 149 | 150 | for other in &used { 151 | if other.p >= m || other.m <= p { 152 | continue; 153 | } 154 | if other.b && b { 155 | continue; 156 | } 157 | tas = tas.max(other.tf); 158 | tal = tal.max(other.td); 159 | } 160 | 161 | suggestions.push(PositionSuggestion { 162 | p, 163 | r: (tas - t0s).max(tal - t0l), 164 | }); 165 | } 166 | 167 | // Sort suggestions by position 168 | suggestions.sort_by(|a, b| a.p.partial_cmp(&b.p).unwrap()); 169 | 170 | // Filter out suggestions with too much delay 171 | let mut mr = MAX_DELAY; 172 | suggestions.retain(|s| { 173 | if s.r >= mr { 174 | false 175 | } else { 176 | mr = s.r; 177 | true 178 | } 179 | }); 180 | 181 | if suggestions.is_empty() { 182 | return None; 183 | } 184 | 185 | // Score and select best position 186 | let best = suggestions 187 | .iter() 188 | .map(|s| { 189 | let score = if s.r > MAX_DELAY { 190 | f64::NEG_INFINITY 191 | } else { 192 | 1.0 - ((s.r / MAX_DELAY).powi(2) + (s.p / PLAY_RES_Y).powi(2)).sqrt() 193 | * std::f64::consts::FRAC_1_SQRT_2 194 | }; 195 | (score, s) 196 | }) 197 | .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) 198 | .unwrap() 199 | .1; 200 | 201 | let ts = t0s + best.r; 202 | let tf = (wv / (wv + PLAY_RES_X)) * R2L_TIME + ts; 203 | let td = R2L_TIME + ts; 204 | 205 | used.push_back(UsedSpace { 206 | p: best.p, 207 | m: best.p + hv, 208 | tf, 209 | td, 210 | b: false, 211 | }); 212 | 213 | Some(DanmakuPosition { 214 | top: best.p, 215 | time: ts, 216 | }) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src-tauri/src/database.rs: -------------------------------------------------------------------------------- 1 | use custom_error::custom_error; 2 | use sqlx::Pool; 3 | use sqlx::Sqlite; 4 | use tokio::sync::RwLock; 5 | 6 | pub mod account; 7 | pub mod message; 8 | pub mod record; 9 | pub mod recorder; 10 | pub mod video; 11 | 12 | pub struct Database { 13 | db: RwLock>>, 14 | } 15 | 16 | custom_error! { pub DatabaseError 17 | InsertError = "Entry insert failed", 18 | NotFoundError = "Entry not found", 19 | InvalidCookiesError = "Cookies are invalid", 20 | DBError {err: sqlx::Error } = "DB error: {err}", 21 | SQLError { sql: String } = "SQL is incorret: {sql}" 22 | } 23 | 24 | impl From for String { 25 | fn from(value: DatabaseError) -> Self { 26 | value.to_string() 27 | } 28 | } 29 | 30 | impl From for DatabaseError { 31 | fn from(value: sqlx::Error) -> Self { 32 | DatabaseError::DBError { err: value } 33 | } 34 | } 35 | 36 | impl Database { 37 | pub fn new() -> Database { 38 | Database { 39 | db: RwLock::new(None), 40 | } 41 | } 42 | 43 | /// db *must* be set in tauri setup 44 | pub async fn set(&self, p: Pool) { 45 | *self.db.write().await = Some(p); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src-tauri/src/database/account.rs: -------------------------------------------------------------------------------- 1 | use crate::recorder::PlatformType; 2 | 3 | use super::Database; 4 | use super::DatabaseError; 5 | use chrono::Utc; 6 | use rand::Rng; 7 | 8 | #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 9 | pub struct AccountRow { 10 | pub platform: String, 11 | pub uid: u64, 12 | pub name: String, 13 | pub avatar: String, 14 | pub csrf: String, 15 | pub cookies: String, 16 | pub created_at: String, 17 | } 18 | 19 | // accounts 20 | impl Database { 21 | // CREATE TABLE accounts (uid INTEGER PRIMARY KEY, name TEXT, avatar TEXT, csrf TEXT, cookies TEXT, created_at TEXT); 22 | pub async fn add_account(&self, platform: &str, cookies: &str) -> Result { 23 | let lock = self.db.read().await.clone().unwrap(); 24 | let platform = PlatformType::from_str(platform).unwrap(); 25 | 26 | let csrf = if platform == PlatformType::Douyin { 27 | Some("".to_string()) 28 | } else { 29 | // parse cookies 30 | cookies 31 | .split(';') 32 | .map(|cookie| cookie.trim()) 33 | .find_map(|cookie| -> Option { 34 | match cookie.starts_with("bili_jct=") { 35 | true => { 36 | let var_name = &"bili_jct="; 37 | Some(cookie[var_name.len()..].to_string()) 38 | } 39 | false => None, 40 | } 41 | }) 42 | }; 43 | 44 | if csrf.is_none() { 45 | return Err(DatabaseError::InvalidCookiesError); 46 | } 47 | 48 | // parse uid 49 | let uid = if platform == PlatformType::BiliBili { 50 | cookies 51 | .split("DedeUserID=") 52 | .collect::>() 53 | .get(1) 54 | .unwrap() 55 | .split(";") 56 | .collect::>() 57 | .first() 58 | .unwrap() 59 | .to_string() 60 | .parse::() 61 | .map_err(|_| DatabaseError::InvalidCookiesError)? 62 | } else { 63 | // generate a random uid 64 | rand::thread_rng().gen_range(10000..=i32::MAX) as u64 65 | }; 66 | 67 | let account = AccountRow { 68 | platform: platform.as_str().to_string(), 69 | uid, 70 | name: "".into(), 71 | avatar: "".into(), 72 | csrf: csrf.unwrap(), 73 | cookies: cookies.into(), 74 | created_at: Utc::now().to_rfc3339(), 75 | }; 76 | 77 | sqlx::query("INSERT INTO accounts (uid, platform, name, avatar, csrf, cookies, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)").bind(account.uid as i64).bind(&account.platform).bind(&account.name).bind(&account.avatar).bind(&account.csrf).bind(&account.cookies).bind(&account.created_at).execute(&lock).await?; 78 | 79 | Ok(account) 80 | } 81 | 82 | pub async fn remove_account(&self, platform: &str, uid: u64) -> Result<(), DatabaseError> { 83 | let lock = self.db.read().await.clone().unwrap(); 84 | let sql = sqlx::query("DELETE FROM accounts WHERE uid = $1 and platform = $2") 85 | .bind(uid as i64) 86 | .bind(platform) 87 | .execute(&lock) 88 | .await?; 89 | if sql.rows_affected() != 1 { 90 | return Err(DatabaseError::NotFoundError); 91 | } 92 | Ok(()) 93 | } 94 | 95 | pub async fn update_account( 96 | &self, 97 | platform: &str, 98 | uid: u64, 99 | name: &str, 100 | avatar: &str, 101 | ) -> Result<(), DatabaseError> { 102 | let lock = self.db.read().await.clone().unwrap(); 103 | let sql = sqlx::query("UPDATE accounts SET name = $1, avatar = $2 WHERE uid = $3 and platform = $4") 104 | .bind(name) 105 | .bind(avatar) 106 | .bind(uid as i64) 107 | .bind(platform) 108 | .execute(&lock) 109 | .await?; 110 | if sql.rows_affected() != 1 { 111 | return Err(DatabaseError::NotFoundError); 112 | } 113 | Ok(()) 114 | } 115 | 116 | pub async fn get_accounts(&self) -> Result, DatabaseError> { 117 | let lock = self.db.read().await.clone().unwrap(); 118 | Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts") 119 | .fetch_all(&lock) 120 | .await?) 121 | } 122 | 123 | pub async fn get_account(&self, platform: &str, uid: u64) -> Result { 124 | let lock = self.db.read().await.clone().unwrap(); 125 | Ok( 126 | sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE uid = $1 and platform = $2") 127 | .bind(uid as i64) 128 | .bind(platform) 129 | .fetch_one(&lock) 130 | .await?, 131 | ) 132 | } 133 | 134 | pub async fn get_account_by_platform(&self, platform: &str) -> Result { 135 | let lock = self.db.read().await.clone().unwrap(); 136 | Ok(sqlx::query_as::<_, AccountRow>("SELECT * FROM accounts WHERE platform = $1") 137 | .bind(platform) 138 | .fetch_one(&lock) 139 | .await?) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src-tauri/src/database/message.rs: -------------------------------------------------------------------------------- 1 | use super::Database; 2 | use super::DatabaseError; 3 | use chrono::Utc; 4 | 5 | #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 6 | pub struct MessageRow { 7 | pub id: i64, 8 | pub title: String, 9 | pub content: String, 10 | pub read: u8, 11 | pub created_at: String, 12 | } 13 | 14 | // messages 15 | // CREATE TABLE messages (id INTEGER PRIMARY KEY, title TEXT, content TEXT, read INTEGER, created_at TEXT); 16 | impl Database { 17 | pub async fn new_message(&self, title: &str, content: &str) -> Result<(), DatabaseError> { 18 | let lock = self.db.read().await.clone().unwrap(); 19 | sqlx::query( 20 | "INSERT INTO messages (title, content, read, created_at) VALUES ($1, $2, 0, $3)", 21 | ) 22 | .bind(title) 23 | .bind(content) 24 | .bind(Utc::now().to_rfc3339()) 25 | .execute(&lock) 26 | .await?; 27 | Ok(()) 28 | } 29 | 30 | pub async fn read_message(&self, id: i64) -> Result<(), DatabaseError> { 31 | let lock = self.db.read().await.clone().unwrap(); 32 | sqlx::query("UPDATE messages SET read = $1 WHERE id = $2") 33 | .bind(1) 34 | .bind(id) 35 | .execute(&lock) 36 | .await?; 37 | Ok(()) 38 | } 39 | 40 | pub async fn delete_message(&self, id: i64) -> Result<(), DatabaseError> { 41 | let lock = self.db.read().await.clone().unwrap(); 42 | sqlx::query("DELETE FROM messages WHERE id = $1") 43 | .bind(id) 44 | .execute(&lock) 45 | .await?; 46 | Ok(()) 47 | } 48 | 49 | pub async fn get_messages(&self) -> Result, DatabaseError> { 50 | let lock = self.db.read().await.clone().unwrap(); 51 | Ok(sqlx::query_as::<_, MessageRow>("SELECT * FROM messages;") 52 | .fetch_all(&lock) 53 | .await?) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src-tauri/src/database/record.rs: -------------------------------------------------------------------------------- 1 | use crate::recorder::PlatformType; 2 | 3 | use super::Database; 4 | use super::DatabaseError; 5 | use chrono::Utc; 6 | 7 | #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 8 | pub struct RecordRow { 9 | pub platform: String, 10 | pub live_id: String, 11 | pub room_id: u64, 12 | pub title: String, 13 | pub length: i64, 14 | pub size: i64, 15 | pub created_at: String, 16 | pub cover: Option, 17 | } 18 | 19 | // CREATE TABLE records (live_id INTEGER PRIMARY KEY, room_id INTEGER, title TEXT, length INTEGER, size INTEGER, created_at TEXT); 20 | impl Database { 21 | pub async fn get_records(&self, room_id: u64) -> Result, DatabaseError> { 22 | let lock = self.db.read().await.clone().unwrap(); 23 | Ok( 24 | sqlx::query_as::<_, RecordRow>("SELECT * FROM records WHERE room_id = $1") 25 | .bind(room_id as i64) 26 | .fetch_all(&lock) 27 | .await?, 28 | ) 29 | } 30 | 31 | pub async fn get_record( 32 | &self, 33 | room_id: u64, 34 | live_id: &str, 35 | ) -> Result { 36 | let lock = self.db.read().await.clone().unwrap(); 37 | Ok(sqlx::query_as::<_, RecordRow>( 38 | "SELECT * FROM records WHERE live_id = $1 and room_id = $2", 39 | ) 40 | .bind(live_id) 41 | .bind(room_id as i64) 42 | .fetch_one(&lock) 43 | .await?) 44 | } 45 | 46 | pub async fn add_record( 47 | &self, 48 | platform: PlatformType, 49 | live_id: &str, 50 | room_id: u64, 51 | title: &str, 52 | cover: Option, 53 | created_at: Option<&str>, 54 | ) -> Result { 55 | let lock = self.db.read().await.clone().unwrap(); 56 | let record = RecordRow { 57 | platform: platform.as_str().to_string(), 58 | live_id: live_id.to_string(), 59 | room_id, 60 | title: title.into(), 61 | length: 0, 62 | size: 0, 63 | created_at: created_at.unwrap_or(&Utc::now().to_rfc3339()).to_string(), 64 | cover, 65 | }; 66 | if let Err(e) = sqlx::query("INSERT INTO records (live_id, room_id, title, length, size, cover, created_at, platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)").bind(record.live_id.clone()) 67 | .bind(record.room_id as i64).bind(&record.title).bind(0).bind(0).bind(&record.cover).bind(&record.created_at).bind(platform.as_str().to_string()).execute(&lock).await { 68 | // if the record already exists, return the existing record 69 | if e.to_string().contains("UNIQUE constraint failed") { 70 | return self.get_record(room_id, live_id).await; 71 | } 72 | } 73 | Ok(record) 74 | } 75 | 76 | pub async fn remove_record(&self, live_id: &str) -> Result<(), DatabaseError> { 77 | let lock = self.db.read().await.clone().unwrap(); 78 | sqlx::query("DELETE FROM records WHERE live_id = $1") 79 | .bind(live_id) 80 | .execute(&lock) 81 | .await?; 82 | Ok(()) 83 | } 84 | 85 | pub async fn update_record( 86 | &self, 87 | live_id: &str, 88 | length: i64, 89 | size: u64, 90 | ) -> Result<(), DatabaseError> { 91 | let lock = self.db.read().await.clone().unwrap(); 92 | sqlx::query("UPDATE records SET length = $1, size = $2 WHERE live_id = $3") 93 | .bind(length) 94 | .bind(size as i64) 95 | .bind(live_id) 96 | .execute(&lock) 97 | .await?; 98 | Ok(()) 99 | } 100 | 101 | pub async fn get_total_length(&self) -> Result { 102 | let lock = self.db.read().await.clone().unwrap(); 103 | let result: (i64,) = sqlx::query_as("SELECT SUM(length) FROM records;") 104 | .fetch_one(&lock) 105 | .await?; 106 | Ok(result.0) 107 | } 108 | 109 | pub async fn get_today_record_count(&self) -> Result { 110 | let lock = self.db.read().await.clone().unwrap(); 111 | let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM records WHERE created_at >= $1;") 112 | .bind( 113 | Utc::now() 114 | .date_naive() 115 | .and_hms_opt(0, 0, 0) 116 | .unwrap() 117 | .to_string(), 118 | ) 119 | .fetch_one(&lock) 120 | .await?; 121 | Ok(result.0) 122 | } 123 | 124 | pub async fn get_recent_record( 125 | &self, 126 | offset: u64, 127 | limit: u64, 128 | ) -> Result, DatabaseError> { 129 | let lock = self.db.read().await.clone().unwrap(); 130 | Ok(sqlx::query_as::<_, RecordRow>( 131 | "SELECT * FROM records ORDER BY created_at DESC LIMIT $1 OFFSET $2", 132 | ) 133 | .bind(limit as i64) 134 | .bind(offset as i64) 135 | .fetch_all(&lock) 136 | .await?) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src-tauri/src/database/recorder.rs: -------------------------------------------------------------------------------- 1 | use super::Database; 2 | use super::DatabaseError; 3 | use crate::recorder::PlatformType; 4 | use chrono::Utc; 5 | /// Recorder in database is pretty simple 6 | /// because many room infos are collected in realtime 7 | #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 8 | pub struct RecorderRow { 9 | pub room_id: u64, 10 | pub created_at: String, 11 | pub platform: String, 12 | pub auto_start: bool, 13 | } 14 | 15 | // recorders 16 | impl Database { 17 | pub async fn add_recorder( 18 | &self, 19 | platform: PlatformType, 20 | room_id: u64, 21 | ) -> Result { 22 | let lock = self.db.read().await.clone().unwrap(); 23 | let recorder = RecorderRow { 24 | room_id, 25 | created_at: Utc::now().to_rfc3339(), 26 | platform: platform.as_str().to_string(), 27 | auto_start: true, 28 | }; 29 | let _ = sqlx::query( 30 | "INSERT INTO recorders (room_id, created_at, platform, auto_start) VALUES ($1, $2, $3, $4)", 31 | ) 32 | .bind(room_id as i64) 33 | .bind(&recorder.created_at) 34 | .bind(platform.as_str()) 35 | .bind(recorder.auto_start) 36 | .execute(&lock) 37 | .await?; 38 | Ok(recorder) 39 | } 40 | 41 | pub async fn remove_recorder(&self, room_id: u64) -> Result<(), DatabaseError> { 42 | let lock = self.db.read().await.clone().unwrap(); 43 | let sql = sqlx::query("DELETE FROM recorders WHERE room_id = $1") 44 | .bind(room_id as i64) 45 | .execute(&lock) 46 | .await?; 47 | if sql.rows_affected() != 1 { 48 | return Err(DatabaseError::NotFoundError); 49 | } 50 | 51 | // remove related archive 52 | let _ = self.remove_archive(room_id).await; 53 | Ok(()) 54 | } 55 | 56 | pub async fn get_recorders(&self) -> Result, DatabaseError> { 57 | let lock = self.db.read().await.clone().unwrap(); 58 | Ok(sqlx::query_as::<_, RecorderRow>( 59 | "SELECT room_id, created_at, platform, auto_start FROM recorders", 60 | ) 61 | .fetch_all(&lock) 62 | .await?) 63 | } 64 | 65 | pub async fn remove_archive(&self, room_id: u64) -> Result<(), DatabaseError> { 66 | let lock = self.db.read().await.clone().unwrap(); 67 | let _ = sqlx::query("DELETE FROM records WHERE room_id = $1") 68 | .bind(room_id as i64) 69 | .execute(&lock) 70 | .await?; 71 | Ok(()) 72 | } 73 | 74 | pub async fn update_recorder( 75 | &self, 76 | platform: PlatformType, 77 | room_id: u64, 78 | auto_start: bool, 79 | ) -> Result<(), DatabaseError> { 80 | let lock = self.db.read().await.clone().unwrap(); 81 | let _ = sqlx::query( 82 | "UPDATE recorders SET auto_start = $1 WHERE platform = $2 AND room_id = $3", 83 | ) 84 | .bind(auto_start) 85 | .bind(platform.as_str().to_string()) 86 | .bind(room_id as i64) 87 | .execute(&lock) 88 | .await?; 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src-tauri/src/database/video.rs: -------------------------------------------------------------------------------- 1 | use super::Database; 2 | use super::DatabaseError; 3 | 4 | // CREATE TABLE videos (id INTEGER PRIMARY KEY, room_id INTEGER, cover TEXT, file TEXT, length INTEGER, size INTEGER, status INTEGER, bvid TEXT, title TEXT, desc TEXT, tags TEXT, area INTEGER, created_at TEXT); 5 | #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 6 | pub struct VideoRow { 7 | pub id: i64, 8 | pub room_id: u64, 9 | pub cover: String, 10 | pub file: String, 11 | pub length: i64, 12 | pub size: i64, 13 | pub status: i64, 14 | pub bvid: String, 15 | pub title: String, 16 | pub desc: String, 17 | pub tags: String, 18 | pub area: i64, 19 | pub created_at: String, 20 | } 21 | 22 | impl Database { 23 | pub async fn get_videos(&self, room_id: u64) -> Result, DatabaseError> { 24 | let lock = self.db.read().await.clone().unwrap(); 25 | Ok( 26 | sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE room_id = $1;") 27 | .bind(room_id as i64) 28 | .fetch_all(&lock) 29 | .await?, 30 | ) 31 | } 32 | 33 | pub async fn get_video(&self, id: i64) -> Result { 34 | let lock = self.db.read().await.clone().unwrap(); 35 | Ok( 36 | sqlx::query_as::<_, VideoRow>("SELECT * FROM videos WHERE id = $1") 37 | .bind(id) 38 | .fetch_one(&lock) 39 | .await?, 40 | ) 41 | } 42 | 43 | pub async fn update_video(&self, video_row: &VideoRow) -> Result<(), DatabaseError> { 44 | let lock = self.db.read().await.clone().unwrap(); 45 | sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7") 46 | .bind(video_row.status) 47 | .bind(&video_row.bvid) 48 | .bind(&video_row.title) 49 | .bind(&video_row.desc) 50 | .bind(&video_row.tags) 51 | .bind(video_row.area) 52 | .bind(video_row.id) 53 | .execute(&lock) 54 | .await?; 55 | Ok(()) 56 | } 57 | 58 | pub async fn delete_video(&self, id: i64) -> Result<(), DatabaseError> { 59 | let lock = self.db.read().await.clone().unwrap(); 60 | sqlx::query("DELETE FROM videos WHERE id = $1") 61 | .bind(id) 62 | .execute(&lock) 63 | .await?; 64 | Ok(()) 65 | } 66 | 67 | pub async fn add_video(&self, video: &VideoRow) -> Result { 68 | let lock = self.db.read().await.clone().unwrap(); 69 | let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)") 70 | .bind(video.room_id as i64) 71 | .bind(&video.cover) 72 | .bind(&video.file) 73 | .bind(video.length) 74 | .bind(video.size) 75 | .bind(video.status) 76 | .bind(&video.bvid) 77 | .bind(&video.title) 78 | .bind(&video.desc) 79 | .bind(&video.tags) 80 | .bind(video.area) 81 | .bind(&video.created_at) 82 | .execute(&lock) 83 | .await?; 84 | let video = VideoRow { 85 | id: sql.last_insert_rowid(), 86 | ..video.clone() 87 | }; 88 | Ok(video) 89 | } 90 | 91 | pub async fn update_video_cover(&self, id: i64, cover: String) -> Result<(), DatabaseError> { 92 | let lock = self.db.read().await.clone().unwrap(); 93 | sqlx::query("UPDATE videos SET cover = $1 WHERE id = $2") 94 | .bind(cover) 95 | .bind(id) 96 | .execute(&lock) 97 | .await?; 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src-tauri/src/handlers/account.rs: -------------------------------------------------------------------------------- 1 | use crate::database::account::AccountRow; 2 | use crate::recorder::bilibili::client::{QrInfo, QrStatus}; 3 | use crate::state::State; 4 | use crate::state_type; 5 | 6 | #[cfg(feature = "gui")] 7 | use tauri::State as TauriState; 8 | 9 | #[cfg_attr(feature = "gui", tauri::command)] 10 | pub async fn get_accounts(state: state_type!()) -> Result { 11 | let account_info = super::AccountInfo { 12 | accounts: state.db.get_accounts().await?, 13 | }; 14 | Ok(account_info) 15 | } 16 | 17 | #[cfg_attr(feature = "gui", tauri::command)] 18 | pub async fn add_account( 19 | state: state_type!(), 20 | platform: String, 21 | cookies: &str, 22 | ) -> Result { 23 | let account = state.db.add_account(&platform, cookies).await?; 24 | if platform == "bilibili" { 25 | let account_info = state.client.get_user_info(&account, account.uid).await?; 26 | state 27 | .db 28 | .update_account( 29 | &platform, 30 | account_info.user_id, 31 | &account_info.user_name, 32 | &account_info.user_avatar_url, 33 | ) 34 | .await?; 35 | } 36 | Ok(account) 37 | } 38 | 39 | #[cfg_attr(feature = "gui", tauri::command)] 40 | pub async fn remove_account( 41 | state: state_type!(), 42 | platform: String, 43 | uid: u64, 44 | ) -> Result<(), String> { 45 | if platform == "bilibili" { 46 | let account = state.db.get_account(&platform, uid).await?; 47 | state.client.logout(&account).await?; 48 | } 49 | Ok(state.db.remove_account(&platform, uid).await?) 50 | } 51 | 52 | #[cfg_attr(feature = "gui", tauri::command)] 53 | pub async fn get_account_count(state: state_type!()) -> Result { 54 | Ok(state.db.get_accounts().await?.len() as u64) 55 | } 56 | 57 | #[cfg_attr(feature = "gui", tauri::command)] 58 | pub async fn get_qr_status(state: state_type!(), qrcode_key: &str) -> Result { 59 | match state.client.get_qr_status(qrcode_key).await { 60 | Ok(qr_status) => Ok(qr_status), 61 | Err(_e) => Err(()), 62 | } 63 | } 64 | 65 | #[cfg_attr(feature = "gui", tauri::command)] 66 | pub async fn get_qr(state: state_type!()) -> Result { 67 | match state.client.get_qr().await { 68 | Ok(qr_info) => Ok(qr_info), 69 | Err(_e) => Err(()), 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src-tauri/src/handlers/config.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::state::State; 3 | use crate::state_type; 4 | 5 | #[cfg(feature = "gui")] 6 | use tauri::State as TauriState; 7 | 8 | #[cfg_attr(feature = "gui", tauri::command)] 9 | pub async fn get_config(state: state_type!()) -> Result { 10 | Ok(state.config.read().await.clone()) 11 | } 12 | 13 | #[cfg_attr(feature = "gui", tauri::command)] 14 | pub async fn set_cache_path(state: state_type!(), cache_path: String) -> Result<(), String> { 15 | let old_cache_path = state.config.read().await.cache.clone(); 16 | if old_cache_path == cache_path { 17 | return Ok(()); 18 | } 19 | 20 | state.recorder_manager.set_migrating(true).await; 21 | // stop and clear all recorders 22 | state.recorder_manager.stop_all().await; 23 | // first switch to new cache 24 | state.config.write().await.set_cache_path(&cache_path); 25 | log::info!("Cache path changed: {}", cache_path); 26 | // Copy old cache to new cache 27 | log::info!("Start copy old cache to new cache"); 28 | state 29 | .db 30 | .new_message( 31 | "缓存目录切换", 32 | "缓存正在迁移中,根据数据量情况可能花费较长时间,在此期间流预览功能不可用", 33 | ) 34 | .await?; 35 | 36 | let mut old_cache_entries = vec![]; 37 | if let Ok(entries) = std::fs::read_dir(&old_cache_path) { 38 | for entry in entries.flatten() { 39 | // check if entry is the same as new cache path 40 | if entry.path() == std::path::Path::new(&cache_path) { 41 | continue; 42 | } 43 | old_cache_entries.push(entry.path()); 44 | } 45 | } 46 | 47 | // copy all entries to new cache 48 | for entry in &old_cache_entries { 49 | let new_entry = std::path::Path::new(&cache_path).join(entry.file_name().unwrap()); 50 | // if entry is a folder 51 | if entry.is_dir() { 52 | if let Err(e) = crate::handlers::utils::copy_dir_all(entry, &new_entry) { 53 | log::error!("Copy old cache to new cache error: {}", e); 54 | } 55 | } else if let Err(e) = std::fs::copy(entry, &new_entry) { 56 | log::error!("Copy old cache to new cache error: {}", e); 57 | } 58 | } 59 | 60 | log::info!("Copy old cache to new cache done"); 61 | state.db.new_message("缓存目录切换", "缓存切换完成").await?; 62 | 63 | state.recorder_manager.set_migrating(false).await; 64 | 65 | // remove all old cache entries 66 | for entry in old_cache_entries { 67 | if entry.is_dir() { 68 | if let Err(e) = std::fs::remove_dir_all(&entry) { 69 | log::error!("Remove old cache error: {}", e); 70 | } 71 | } else if let Err(e) = std::fs::remove_file(&entry) { 72 | log::error!("Remove old cache error: {}", e); 73 | } 74 | } 75 | 76 | Ok(()) 77 | } 78 | 79 | #[cfg_attr(feature = "gui", tauri::command)] 80 | pub async fn set_output_path(state: state_type!(), output_path: String) -> Result<(), ()> { 81 | let mut config = state.config.write().await; 82 | let old_output_path = config.output.clone(); 83 | if old_output_path == output_path { 84 | return Ok(()); 85 | } 86 | // list all file and folder in old output 87 | let mut old_output_entries = vec![]; 88 | if let Ok(entries) = std::fs::read_dir(&old_output_path) { 89 | for entry in entries.flatten() { 90 | // check if entry is the same as new output path 91 | if entry.path() == std::path::Path::new(&output_path) { 92 | continue; 93 | } 94 | old_output_entries.push(entry.path()); 95 | } 96 | } 97 | 98 | // rename all entries to new output 99 | for entry in &old_output_entries { 100 | let new_entry = std::path::Path::new(&output_path).join(entry.file_name().unwrap()); 101 | // if entry is a folder 102 | if entry.is_dir() { 103 | if let Err(e) = crate::handlers::utils::copy_dir_all(entry, &new_entry) { 104 | log::error!("Copy old cache to new cache error: {}", e); 105 | } 106 | } else if let Err(e) = std::fs::copy(entry, &new_entry) { 107 | log::error!("Copy old cache to new cache error: {}", e); 108 | } 109 | } 110 | 111 | // remove all old output entries 112 | for entry in old_output_entries { 113 | if entry.is_dir() { 114 | if let Err(e) = std::fs::remove_dir_all(&entry) { 115 | log::error!("Remove old cache error: {}", e); 116 | } 117 | } else if let Err(e) = std::fs::remove_file(&entry) { 118 | log::error!("Remove old cache error: {}", e); 119 | } 120 | } 121 | 122 | config.set_output_path(&output_path); 123 | Ok(()) 124 | } 125 | 126 | #[cfg_attr(feature = "gui", tauri::command)] 127 | pub async fn update_notify( 128 | state: state_type!(), 129 | live_start_notify: bool, 130 | live_end_notify: bool, 131 | clip_notify: bool, 132 | post_notify: bool, 133 | ) -> Result<(), ()> { 134 | state.config.write().await.live_start_notify = live_start_notify; 135 | state.config.write().await.live_end_notify = live_end_notify; 136 | state.config.write().await.clip_notify = clip_notify; 137 | state.config.write().await.post_notify = post_notify; 138 | state.config.write().await.save(); 139 | Ok(()) 140 | } 141 | 142 | #[cfg_attr(feature = "gui", tauri::command)] 143 | pub async fn update_whisper_model(state: state_type!(), whisper_model: String) -> Result<(), ()> { 144 | state.config.write().await.whisper_model = whisper_model; 145 | state.config.write().await.save(); 146 | Ok(()) 147 | } 148 | 149 | #[cfg_attr(feature = "gui", tauri::command)] 150 | pub async fn update_subtitle_setting(state: state_type!(), auto_subtitle: bool) -> Result<(), ()> { 151 | state.config.write().await.auto_subtitle = auto_subtitle; 152 | state.config.write().await.save(); 153 | Ok(()) 154 | } 155 | 156 | #[cfg_attr(feature = "gui", tauri::command)] 157 | pub async fn update_clip_name_format( 158 | state: state_type!(), 159 | clip_name_format: String, 160 | ) -> Result<(), ()> { 161 | state.config.write().await.clip_name_format = clip_name_format; 162 | state.config.write().await.save(); 163 | Ok(()) 164 | } 165 | 166 | #[cfg_attr(feature = "gui", tauri::command)] 167 | pub async fn update_whisper_prompt(state: state_type!(), whisper_prompt: String) -> Result<(), ()> { 168 | state.config.write().await.whisper_prompt = whisper_prompt; 169 | state.config.write().await.save(); 170 | Ok(()) 171 | } 172 | 173 | #[cfg_attr(feature = "gui", tauri::command)] 174 | pub async fn update_auto_generate( 175 | state: state_type!(), 176 | enabled: bool, 177 | encode_danmu: bool, 178 | ) -> Result<(), String> { 179 | let mut config = state.config.write().await; 180 | config.auto_generate.enabled = enabled; 181 | config.auto_generate.encode_danmu = encode_danmu; 182 | config.save(); 183 | Ok(()) 184 | } 185 | 186 | #[cfg_attr(feature = "gui", tauri::command)] 187 | pub async fn update_status_check_interval( 188 | state: state_type!(), 189 | mut interval: u64, 190 | ) -> Result<(), ()> { 191 | if interval < 10 { 192 | interval = 10; // Minimum interval of 10 seconds 193 | } 194 | log::info!("Updating status check interval to {} seconds", interval); 195 | state.config.write().await.status_check_interval = interval; 196 | state.config.write().await.save(); 197 | Ok(()) 198 | } 199 | -------------------------------------------------------------------------------- /src-tauri/src/handlers/macros.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "gui")] 2 | #[macro_export] 3 | macro_rules! state_type { 4 | () => { 5 | TauriState<'_, State> 6 | }; 7 | } 8 | 9 | #[cfg(feature = "headless")] 10 | #[macro_export] 11 | macro_rules! state_type { 12 | () => { 13 | State 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src-tauri/src/handlers/message.rs: -------------------------------------------------------------------------------- 1 | use crate::database::message::MessageRow; 2 | use crate::state::State; 3 | use crate::state_type; 4 | 5 | #[cfg(feature = "gui")] 6 | use tauri::State as TauriState; 7 | 8 | #[cfg_attr(feature = "gui", tauri::command)] 9 | pub async fn get_messages(state: state_type!()) -> Result, String> { 10 | Ok(state.db.get_messages().await?) 11 | } 12 | 13 | #[cfg_attr(feature = "gui", tauri::command)] 14 | pub async fn read_message(state: state_type!(), id: i64) -> Result<(), String> { 15 | Ok(state.db.read_message(id).await?) 16 | } 17 | 18 | #[cfg_attr(feature = "gui", tauri::command)] 19 | pub async fn delete_message(state: state_type!(), id: i64) -> Result<(), String> { 20 | Ok(state.db.delete_message(id).await?) 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod config; 3 | pub mod macros; 4 | pub mod message; 5 | pub mod recorder; 6 | pub mod utils; 7 | pub mod video; 8 | 9 | use crate::database::account::AccountRow; 10 | 11 | #[derive(serde::Serialize)] 12 | pub struct AccountInfo { 13 | pub accounts: Vec, 14 | } 15 | 16 | #[derive(serde::Serialize)] 17 | pub struct DiskInfo { 18 | pub disk: String, 19 | pub total: u64, 20 | pub free: u64, 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/src/handlers/utils.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::state::State; 4 | use crate::state_type; 5 | 6 | #[cfg(feature = "gui")] 7 | use { 8 | crate::recorder::PlatformType, 9 | std::process::Command, 10 | tauri::State as TauriState, 11 | tauri::{Manager, Theme}, 12 | tauri_utils::config::WindowEffectsConfig, 13 | tokio::fs::OpenOptions, 14 | tokio::io::AsyncWriteExt, 15 | }; 16 | 17 | pub fn copy_dir_all( 18 | src: impl AsRef, 19 | dst: impl AsRef, 20 | ) -> std::io::Result<()> { 21 | std::fs::create_dir_all(&dst)?; 22 | for entry in std::fs::read_dir(src)? { 23 | let entry = entry?; 24 | let ty = entry.file_type()?; 25 | if ty.is_dir() { 26 | copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; 27 | } else { 28 | std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; 29 | } 30 | } 31 | Ok(()) 32 | } 33 | 34 | #[cfg(feature = "gui")] 35 | #[cfg_attr(feature = "gui", tauri::command)] 36 | pub fn show_in_folder(path: String) { 37 | #[cfg(target_os = "windows")] 38 | { 39 | Command::new("explorer") 40 | .args(["/select,", &path]) // The comma after select is not a typo 41 | .spawn() 42 | .unwrap(); 43 | } 44 | 45 | #[cfg(target_os = "linux")] 46 | { 47 | use std::fs::metadata; 48 | use std::path::PathBuf; 49 | if path.contains(",") { 50 | // see https://gitlab.freedesktop.org/dbus/dbus/-/issues/76 51 | let new_path = match metadata(&path).unwrap().is_dir() { 52 | true => path, 53 | false => { 54 | let mut path2 = PathBuf::from(path); 55 | path2.pop(); 56 | path2.into_os_string().into_string().unwrap() 57 | } 58 | }; 59 | Command::new("xdg-open").arg(&new_path).spawn().unwrap(); 60 | } else { 61 | Command::new("dbus-send") 62 | .args([ 63 | "--session", 64 | "--dest=org.freedesktop.FileManager1", 65 | "--type=method_call", 66 | "/org/freedesktop/FileManager1", 67 | "org.freedesktop.FileManager1.ShowItems", 68 | format!("array:string:\"file://{path}\"").as_str(), 69 | "string:\"\"", 70 | ]) 71 | .spawn() 72 | .unwrap(); 73 | } 74 | } 75 | 76 | #[cfg(target_os = "macos")] 77 | { 78 | Command::new("open") 79 | .args(["-R", &path]) 80 | .spawn() 81 | .unwrap() 82 | .wait() 83 | .unwrap(); 84 | } 85 | } 86 | 87 | #[derive(serde::Serialize)] 88 | pub struct DiskInfo { 89 | disk: String, 90 | total: u64, 91 | free: u64, 92 | } 93 | 94 | #[cfg_attr(feature = "gui", tauri::command)] 95 | pub async fn get_disk_info(state: state_type!()) -> Result { 96 | let cache = state.config.read().await.cache.clone(); 97 | // if cache is relative path, convert it to absolute path 98 | let mut cache = PathBuf::from(&cache); 99 | if cache.is_relative() { 100 | // get current working directory 101 | let cwd = std::env::current_dir().unwrap(); 102 | cache = cwd.join(cache); 103 | } 104 | #[cfg(target_os = "linux")] 105 | { 106 | // get disk info from df command 107 | let output = tokio::process::Command::new("df") 108 | .arg(cache) 109 | .output() 110 | .await 111 | .unwrap(); 112 | let output_str = String::from_utf8(output.stdout).unwrap(); 113 | // Filesystem 1K-blocks Used Available Use% Mounted on 114 | // /dev/nvme0n1p2 959218776 43826092 866593352 5% /app/cache 115 | let lines = output_str.lines().collect::>(); 116 | if lines.len() < 2 { 117 | log::error!("df command output is too short: {}", output_str); 118 | return Err(()); 119 | } 120 | let parts = lines[1].split_whitespace().collect::>(); 121 | let disk = parts[0].to_string(); 122 | let total = parts[1].parse::().unwrap() * 1024; 123 | let free = parts[3].parse::().unwrap() * 1024; 124 | 125 | return Ok(DiskInfo { disk, total, free }); 126 | } 127 | 128 | #[cfg(any(target_os = "windows", target_os = "macos"))] 129 | { 130 | // check system disk info 131 | let disks = sysinfo::Disks::new_with_refreshed_list(); 132 | // get cache disk info 133 | let mut disk_info = DiskInfo { 134 | disk: "".into(), 135 | total: 0, 136 | free: 0, 137 | }; 138 | 139 | // Find the disk with the longest matching mount point 140 | let mut longest_match = 0; 141 | for disk in disks.list() { 142 | let mount_point = disk.mount_point().to_str().unwrap(); 143 | if cache.starts_with(mount_point) && mount_point.split("/").count() > longest_match { 144 | disk_info.disk = mount_point.into(); 145 | disk_info.total = disk.total_space(); 146 | disk_info.free = disk.available_space(); 147 | longest_match = mount_point.split("/").count(); 148 | } 149 | } 150 | 151 | Ok(disk_info) 152 | } 153 | } 154 | 155 | #[cfg(feature = "gui")] 156 | #[tauri::command] 157 | pub async fn export_to_file( 158 | _state: state_type!(), 159 | file_name: &str, 160 | content: &str, 161 | ) -> Result<(), String> { 162 | let file = OpenOptions::new() 163 | .create(true) 164 | .write(true) 165 | .truncate(true) 166 | .open(file_name) 167 | .await; 168 | if file.is_err() { 169 | return Err(format!("Open file failed: {}", file.err().unwrap())); 170 | } 171 | let mut file = file.unwrap(); 172 | if let Err(e) = file.write_all(content.as_bytes()).await { 173 | return Err(format!("Write file failed: {}", e)); 174 | } 175 | if let Err(e) = file.flush().await { 176 | return Err(format!("Flush file failed: {}", e)); 177 | } 178 | Ok(()) 179 | } 180 | 181 | #[cfg(feature = "gui")] 182 | #[tauri::command] 183 | pub async fn open_log_folder(state: state_type!()) -> Result<(), String> { 184 | #[cfg(feature = "gui")] 185 | { 186 | let log_dir = state.app_handle.path().app_log_dir().unwrap(); 187 | show_in_folder(log_dir.to_str().unwrap().to_string()); 188 | } 189 | Ok(()) 190 | } 191 | 192 | #[cfg(feature = "gui")] 193 | #[tauri::command] 194 | pub async fn open_live( 195 | state: state_type!(), 196 | platform: String, 197 | room_id: u64, 198 | live_id: String, 199 | ) -> Result<(), String> { 200 | log::info!("Open player window: {} {}", room_id, live_id); 201 | #[cfg(feature = "gui")] 202 | { 203 | let platform = PlatformType::from_str(&platform).unwrap(); 204 | let recorder_info = state 205 | .recorder_manager 206 | .get_recorder_info(platform, room_id) 207 | .await 208 | .unwrap(); 209 | let builder = tauri::WebviewWindowBuilder::new( 210 | &state.app_handle, 211 | format!("Live:{}:{}", room_id, live_id), 212 | tauri::WebviewUrl::App( 213 | format!( 214 | "live_index.html?platform={}&room_id={}&live_id={}", 215 | platform.as_str(), 216 | room_id, 217 | live_id 218 | ) 219 | .into(), 220 | ), 221 | ) 222 | .title(format!( 223 | "Live[{}] {}", 224 | room_id, recorder_info.room_info.room_title 225 | )) 226 | .theme(Some(Theme::Light)) 227 | .inner_size(1200.0, 800.0) 228 | .effects(WindowEffectsConfig { 229 | effects: vec![ 230 | tauri_utils::WindowEffect::Tabbed, 231 | tauri_utils::WindowEffect::Mica, 232 | ], 233 | state: None, 234 | radius: None, 235 | color: None, 236 | }); 237 | 238 | if let Err(e) = builder.decorations(true).build() { 239 | log::error!("live window build failed: {}", e); 240 | } 241 | } 242 | 243 | Ok(()) 244 | } 245 | -------------------------------------------------------------------------------- /src-tauri/src/migration.rs: -------------------------------------------------------------------------------- 1 | use sqlx::migrate::MigrationType; 2 | 3 | #[derive(Debug)] 4 | pub enum MigrationKind { 5 | Up, 6 | Down, 7 | } 8 | 9 | #[derive(Debug)] 10 | pub struct Migration { 11 | pub version: i64, 12 | pub description: &'static str, 13 | pub sql: &'static str, 14 | pub kind: MigrationKind, 15 | } 16 | 17 | impl From for MigrationType { 18 | fn from(kind: MigrationKind) -> Self { 19 | match kind { 20 | MigrationKind::Up => Self::ReversibleUp, 21 | MigrationKind::Down => Self::ReversibleDown, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src-tauri/src/progress_manager.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[cfg(feature = "headless")] 4 | use tokio::sync::broadcast; 5 | 6 | #[derive(Clone, Serialize, Deserialize)] 7 | pub enum Event { 8 | ProgressUpdate { 9 | id: String, 10 | content: String, 11 | }, 12 | ProgressFinished { 13 | id: String, 14 | success: bool, 15 | message: String, 16 | }, 17 | DanmuReceived { 18 | room: u64, 19 | ts: i64, 20 | content: String, 21 | }, 22 | } 23 | 24 | #[cfg(feature = "headless")] 25 | pub struct ProgressManager { 26 | pub progress_sender: broadcast::Sender, 27 | pub progress_receiver: broadcast::Receiver, 28 | } 29 | 30 | #[cfg(feature = "headless")] 31 | impl ProgressManager { 32 | pub fn new() -> Self { 33 | let (progress_sender, progress_receiver) = broadcast::channel(16); 34 | Self { 35 | progress_sender, 36 | progress_receiver, 37 | } 38 | } 39 | 40 | pub fn get_event_sender(&self) -> broadcast::Sender { 41 | self.progress_sender.clone() 42 | } 43 | 44 | pub fn subscribe(&self) -> broadcast::Receiver { 45 | self.progress_receiver.resubscribe() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src-tauri/src/progress_reporter.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use serde::Serialize; 3 | use std::sync::atomic::AtomicBool; 4 | use std::sync::Arc; 5 | use std::sync::LazyLock; 6 | use tokio::sync::RwLock; 7 | 8 | use crate::progress_manager::Event; 9 | 10 | #[cfg(feature = "gui")] 11 | use { 12 | crate::recorder::danmu::DanmuEntry, 13 | tauri::{AppHandle, Emitter}, 14 | }; 15 | 16 | #[cfg(feature = "headless")] 17 | use tokio::sync::broadcast; 18 | 19 | type CancelFlagMap = std::collections::HashMap>; 20 | 21 | static CANCEL_FLAG_MAP: LazyLock>> = 22 | LazyLock::new(|| Arc::new(RwLock::new(CancelFlagMap::new()))); 23 | 24 | #[derive(Clone)] 25 | pub struct ProgressReporter { 26 | emitter: EventEmitter, 27 | pub event_id: String, 28 | pub cancel: Arc, 29 | } 30 | 31 | #[async_trait] 32 | pub trait ProgressReporterTrait: Send + Sync + Clone { 33 | fn update(&self, content: &str); 34 | async fn finish(&self, success: bool, message: &str); 35 | } 36 | 37 | #[derive(Clone)] 38 | pub struct EventEmitter { 39 | #[cfg(feature = "gui")] 40 | app_handle: AppHandle, 41 | #[cfg(feature = "headless")] 42 | sender: broadcast::Sender, 43 | } 44 | 45 | #[cfg(feature = "gui")] 46 | #[derive(Clone, Serialize)] 47 | struct UpdateEvent<'a> { 48 | id: &'a str, 49 | content: &'a str, 50 | } 51 | 52 | #[cfg(feature = "gui")] 53 | #[derive(Clone, Serialize)] 54 | struct FinishEvent<'a> { 55 | id: &'a str, 56 | success: bool, 57 | message: &'a str, 58 | } 59 | 60 | impl EventEmitter { 61 | pub fn new( 62 | #[cfg(feature = "gui")] app_handle: AppHandle, 63 | #[cfg(feature = "headless")] sender: broadcast::Sender, 64 | ) -> Self { 65 | Self { 66 | #[cfg(feature = "gui")] 67 | app_handle, 68 | #[cfg(feature = "headless")] 69 | sender, 70 | } 71 | } 72 | 73 | pub fn emit(&self, event: &Event) { 74 | #[cfg(feature = "gui")] 75 | { 76 | match event { 77 | Event::ProgressUpdate { id, content } => { 78 | self.app_handle 79 | .emit("progress-update", UpdateEvent { id, content }) 80 | .unwrap(); 81 | } 82 | Event::ProgressFinished { 83 | id, 84 | success, 85 | message, 86 | } => { 87 | self.app_handle 88 | .emit( 89 | "progress-finished", 90 | FinishEvent { 91 | id, 92 | success: *success, 93 | message, 94 | }, 95 | ) 96 | .unwrap(); 97 | } 98 | Event::DanmuReceived { room, ts, content } => { 99 | self.app_handle 100 | .emit( 101 | &format!("danmu:{}", room), 102 | DanmuEntry { 103 | ts: *ts, 104 | content: content.clone(), 105 | }, 106 | ) 107 | .unwrap(); 108 | } 109 | } 110 | } 111 | 112 | #[cfg(feature = "headless")] 113 | let _ = self.sender.send(event.clone()); 114 | } 115 | } 116 | impl ProgressReporter { 117 | pub async fn new(emitter: &EventEmitter, event_id: &str) -> Result { 118 | // if already exists, return 119 | if CANCEL_FLAG_MAP.read().await.get(event_id).is_some() { 120 | log::error!("Task already exists: {}", event_id); 121 | emitter.emit(&Event::ProgressFinished { 122 | id: event_id.to_string(), 123 | success: false, 124 | message: "任务已经存在".to_string(), 125 | }); 126 | return Err("任务已经存在".to_string()); 127 | } 128 | 129 | let cancel = Arc::new(AtomicBool::new(false)); 130 | CANCEL_FLAG_MAP 131 | .write() 132 | .await 133 | .insert(event_id.to_string(), cancel.clone()); 134 | 135 | Ok(Self { 136 | emitter: emitter.clone(), 137 | event_id: event_id.to_string(), 138 | cancel, 139 | }) 140 | } 141 | } 142 | 143 | #[async_trait] 144 | impl ProgressReporterTrait for ProgressReporter { 145 | fn update(&self, content: &str) { 146 | self.emitter.emit(&Event::ProgressUpdate { 147 | id: self.event_id.clone(), 148 | content: content.to_string(), 149 | }); 150 | } 151 | 152 | async fn finish(&self, success: bool, message: &str) { 153 | self.emitter.emit(&Event::ProgressFinished { 154 | id: self.event_id.clone(), 155 | success, 156 | message: message.to_string(), 157 | }); 158 | CANCEL_FLAG_MAP.write().await.remove(&self.event_id); 159 | } 160 | } 161 | 162 | pub async fn cancel_progress(event_id: &str) { 163 | let mut cancel_flag_map = CANCEL_FLAG_MAP.write().await; 164 | if let Some(cancel_flag) = cancel_flag_map.get_mut(event_id) { 165 | cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src-tauri/src/recorder.rs: -------------------------------------------------------------------------------- 1 | pub mod bilibili; 2 | pub mod danmu; 3 | pub mod douyin; 4 | pub mod errors; 5 | 6 | mod entry; 7 | 8 | use async_trait::async_trait; 9 | use danmu::DanmuEntry; 10 | use std::hash::{Hash, Hasher}; 11 | 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 | pub enum PlatformType { 14 | BiliBili, 15 | Douyin, 16 | Huya, 17 | Youtube, 18 | } 19 | 20 | impl PlatformType { 21 | pub fn as_str(&self) -> &'static str { 22 | match self { 23 | PlatformType::BiliBili => "bilibili", 24 | PlatformType::Douyin => "douyin", 25 | PlatformType::Huya => "huya", 26 | PlatformType::Youtube => "youtube", 27 | } 28 | } 29 | 30 | pub fn from_str(s: &str) -> Option { 31 | match s { 32 | "bilibili" => Some(PlatformType::BiliBili), 33 | "douyin" => Some(PlatformType::Douyin), 34 | "huya" => Some(PlatformType::Huya), 35 | "youtube" => Some(PlatformType::Youtube), 36 | _ => None, 37 | } 38 | } 39 | } 40 | 41 | impl Hash for PlatformType { 42 | fn hash(&self, state: &mut H) { 43 | std::mem::discriminant(self).hash(state); 44 | } 45 | } 46 | 47 | #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] 48 | pub struct RecorderInfo { 49 | pub room_id: u64, 50 | pub room_info: RoomInfo, 51 | pub user_info: UserInfo, 52 | pub total_length: f64, 53 | pub current_live_id: String, 54 | pub live_status: bool, 55 | pub is_recording: bool, 56 | pub auto_start: bool, 57 | pub platform: String, 58 | } 59 | 60 | #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] 61 | pub struct RoomInfo { 62 | pub room_id: u64, 63 | pub room_title: String, 64 | pub room_cover: String, 65 | } 66 | 67 | #[derive(serde::Deserialize, serde::Serialize, Clone, Debug)] 68 | pub struct UserInfo { 69 | pub user_id: String, 70 | pub user_name: String, 71 | pub user_avatar: String, 72 | } 73 | 74 | #[async_trait] 75 | pub trait Recorder: Send + Sync + 'static { 76 | async fn run(&self); 77 | async fn stop(&self); 78 | async fn first_segment_ts(&self, live_id: &str) -> i64; 79 | async fn m3u8_content(&self, live_id: &str, start: i64, end: i64) -> String; 80 | async fn master_m3u8(&self, live_id: &str, start: i64, end: i64) -> String; 81 | async fn info(&self) -> RecorderInfo; 82 | async fn comments(&self, live_id: &str) -> Result, errors::RecorderError>; 83 | async fn is_recording(&self, live_id: &str) -> bool; 84 | async fn force_start(&self); 85 | async fn force_stop(&self); 86 | async fn set_auto_start(&self, auto_start: bool); 87 | } 88 | -------------------------------------------------------------------------------- /src-tauri/src/recorder/bilibili/errors.rs: -------------------------------------------------------------------------------- 1 | use custom_error::custom_error; 2 | 3 | custom_error! {pub BiliClientError 4 | InvalidResponse = "Invalid response", 5 | InitClientError = "Client init error", 6 | InvalidCode = "Invalid Code", 7 | InvalidValue = "Invalid value", 8 | InvalidUrl = "Invalid url", 9 | InvalidFormat = "Invalid stream format", 10 | InvalidStream = "Invalid stream", 11 | UploadError{err: String} = "Upload error: {err}", 12 | UploadCancelled = "Upload was cancelled by user", 13 | EmptyCache = "Empty cache", 14 | ClientError{err: reqwest::Error} = "Client error: {err}", 15 | IOError{err: std::io::Error} = "IO error: {err}", 16 | } 17 | 18 | impl From for BiliClientError { 19 | fn from(e: reqwest::Error) -> Self { 20 | BiliClientError::ClientError { err: e } 21 | } 22 | } 23 | 24 | impl From for BiliClientError { 25 | fn from(e: std::io::Error) -> Self { 26 | BiliClientError::IOError { err: e } 27 | } 28 | } 29 | 30 | impl From for String { 31 | fn from(value: BiliClientError) -> Self { 32 | value.to_string() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/src/recorder/bilibili/profile.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, Clone)] 4 | pub struct Profile { 5 | pub videos: Vec