├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json5 └── workflows │ ├── biliass-build-and-release.yml │ ├── e2e-test.yml │ ├── latest-release-test.yml │ ├── lint-and-fmt.yml │ ├── release.yml │ ├── unit-test.yml │ └── vitepress-deploy.yml ├── .gitignore ├── .gitmodules ├── .vscode ├── extensions.json ├── mcp.json └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── _typos.toml ├── docs ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vitepress │ ├── config.mts │ ├── env.d.ts │ └── theme │ │ ├── Layout.vue │ │ ├── components │ │ ├── Contributors.vue │ │ ├── GitHubUser.vue │ │ └── Sparkler.vue │ │ ├── index.css │ │ └── index.ts ├── LICENSE ├── env.d.ts ├── guide │ ├── cli │ │ ├── basic.md │ │ ├── batch.md │ │ ├── danmaku.md │ │ ├── introduction.md │ │ └── resource.md │ ├── faq.md │ ├── feedback.md │ ├── notice.md │ ├── quick-start.md │ ├── supported-links.md │ ├── thanks.md │ └── tips.md ├── index.md ├── migration │ └── index.md ├── package.json ├── pnpm-lock.yaml ├── public │ ├── logo-mini.svg │ ├── logo.png │ └── vercel.json ├── sponsor.md └── tsconfig.json ├── justfile ├── packages └── biliass │ ├── LICENSE │ ├── README.md │ ├── pyproject.toml │ ├── rust │ ├── Cargo.lock │ ├── Cargo.toml │ ├── build.rs │ ├── proto │ │ ├── danmaku.proto │ │ └── danmaku_view.proto │ └── src │ │ ├── comment.rs │ │ ├── convert.rs │ │ ├── error.rs │ │ ├── filter.rs │ │ ├── lib.rs │ │ ├── logging.rs │ │ ├── proto │ │ ├── danmaku.rs │ │ ├── danmaku_view.rs │ │ └── mod.rs │ │ ├── python │ │ ├── convert.rs │ │ ├── logging.rs │ │ ├── mod.rs │ │ └── proto.rs │ │ ├── reader │ │ ├── mod.rs │ │ ├── protobuf.rs │ │ ├── special.rs │ │ ├── utils.rs │ │ └── xml.rs │ │ └── writer │ │ ├── ass.rs │ │ ├── mod.rs │ │ ├── rows.rs │ │ └── utils.rs │ └── src │ └── biliass │ ├── __init__.py │ ├── __main__.py │ ├── __version__.py │ ├── _core.pyi │ ├── biliass.py │ └── py.typed ├── pyproject.toml ├── schemas └── config.json ├── scripts ├── generate-schema.py └── get-version.py ├── src └── yutto │ ├── __init__.py │ ├── __main__.py │ ├── __version__.py │ ├── _typing.py │ ├── api │ ├── __init__.py │ ├── bangumi.py │ ├── cheese.py │ ├── collection.py │ ├── danmaku.py │ ├── space.py │ ├── ugc_video.py │ └── user_info.py │ ├── bilibili_typing │ ├── __init__.py │ ├── codec.py │ └── quality.py │ ├── cli │ ├── __init__.py │ ├── cli.py │ └── settings.py │ ├── download_manager.py │ ├── downloader │ ├── __init__.py │ ├── downloader.py │ ├── progressbar.py │ └── selector.py │ ├── exceptions.py │ ├── extractor │ ├── __init__.py │ ├── _abc.py │ ├── bangumi.py │ ├── bangumi_batch.py │ ├── cheese.py │ ├── cheese_batch.py │ ├── collection.py │ ├── common.py │ ├── favourites.py │ ├── series.py │ ├── ugc_video.py │ ├── ugc_video_batch.py │ ├── user_all_favourites.py │ ├── user_all_ugc_videos.py │ └── user_watch_later.py │ ├── mcp.py │ ├── parser.py │ ├── path_resolver.py │ ├── py.typed │ ├── utils │ ├── __init__.py │ ├── asynclib.py │ ├── console │ │ ├── __init__.py │ │ ├── attributes.py │ │ ├── colorful.py │ │ ├── formatter.py │ │ ├── logger.py │ │ └── status_bar.py │ ├── danmaku.py │ ├── fetcher.py │ ├── ffmpeg.py │ ├── file_buffer.py │ ├── filter.py │ ├── funcutils │ │ ├── __init__.py │ │ ├── aobject.py │ │ ├── as_sync.py │ │ ├── data_access.py │ │ ├── filter_none_value.py │ │ ├── functional.py │ │ ├── singleton.py │ │ └── xmerge.py │ ├── metadata.py │ ├── priority.py │ ├── subtitle.py │ └── time.py │ └── validator.py ├── tests ├── __init__.py ├── conftest.py ├── test_api │ ├── __init__.py │ ├── test_bangumi.py │ ├── test_cheese.py │ ├── test_collection.py │ ├── test_danmaku.py │ ├── test_space.py │ ├── test_ugc_video.py │ └── test_user_info.py ├── test_biliass │ └── __init__.py ├── test_e2e.py ├── test_processor │ ├── __init__.py │ ├── test_downloader.py │ ├── test_path_resolver.py │ └── test_selector.py └── test_utils │ ├── __init__.py │ ├── test_data_access.py │ └── test_ffmpeg.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig 2 | # https://editorconfig.org/ 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.{py,pyi,rs,sh,proto}] 15 | indent_size = 4 16 | 17 | [*.md] 18 | indent_size = 3 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: SigureMo 2 | custom: 3 | [ 4 | "https://afdian.net/@siguremo", 5 | "https://img.nyakku.moe/sponsor/alipay.png", 6 | "https://img.nyakku.moe/sponsor/wechat.png", 7 | ] 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug report" 2 | description: Report an issue with yutto 3 | title: "🐛 " 4 | labels: ["bug: pending triage"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | 非常感谢你的 issue report (๑>؂<๑),为了使我们能够更快地定位错误来源,请尽可能完整地填写本 Issue 表格 10 | - type: textarea 11 | id: bug-description 12 | attributes: 13 | label: 问题简述 14 | description: 详述你所遇到的问题(如有报错也请粘贴在这里)~ 15 | placeholder: | 16 | 如果方便,请添加 --debug 参数以提供更加详细的报错信息 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: reproduction 21 | attributes: 22 | label: 复现方式 23 | description: | 24 | 请在这里提供你所使用/调用 yutto 的方式。如果与特定 url 有关,请直接在命令中提供该 url。 25 | 为了节省彼此交流的时间,麻烦在提交 issue 前多次测试该命令是能够反复复现的(非网络问题), 26 | 如果可以,麻烦在提交 issue 前对其他的情况进行测试,并将相关信息详细描述在问题简述中, 27 | 这里仅提供**最小可复现**的命令(注意,如果使用了自定义的配置文件,请将配置文件内容一并提供)。 28 | placeholder: "注意在粘贴的命令中隐去所有隐私信息哦(*/ω\*)" 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: environment-info 33 | attributes: 34 | label: 环境信息 35 | description: 请尽可能详细地供以下信息~ 36 | placeholder: 你的环境信息~ 37 | value: | 38 | - OS: 操作系统类型及其版本号 39 | - Python: Python 版本号 (`python --version`) 40 | - yutto: yutto 版本号 (`yutto -v`) 41 | - FFmpeg: FFmpeg 版本号 (`ffmpeg -version`) 42 | - 如果是显示相关问题 43 | - Shell: Shell 类型 (`echo $SHELL`) 44 | - Terminal: 终端类型 45 | - Others: 其它信息 46 | validations: 47 | required: true 48 | - type: textarea 49 | id: additional-context 50 | attributes: 51 | label: 额外信息 52 | description: 请尽可能提供一些你认为可能产生该问题的一些原因 53 | placeholder: 如有额外的信息,请填写在这里~ 54 | validations: 55 | required: false 56 | - type: checkboxes 57 | id: checkboxes 58 | attributes: 59 | label: 一点点的自我检查 60 | description: 在你提交 issue 之前,麻烦确认自己是否已经完成了以下检查: 61 | options: 62 | - label: 充分阅读 [README.md](https://github.com/yutto-dev/yutto),特别是与本 issue 相关的部分 63 | required: true 64 | - label: 如果是网络问题,已经检查网络连接、设置是否正常,并经过充分测试认为这是 yutto 本身的问题 65 | required: true 66 | - label: 本 issue 在 [issues](https://github.com/yutto-dev/yutto/issues) 和 [discussion](https://github.com/yutto-dev/yutto/discussions) 中并没有重复问题 67 | required: true 68 | - label: 确认所希望下载的资源是本人有权限获取的,yutto 不提供任何超出本人权限的资源下载方式 69 | required: true 70 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Questions & Discussions 4 | url: https://github.com/yutto-dev/yutto/discussions 5 | about: Use GitHub discussions for message-board style questions and discussions. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "✨ New feature proposal" 2 | description: Propose a new feature to be added to yutto 3 | title: "✨ " 4 | labels: ["enhancement: pending triage"] 5 | body: 6 | - type: textarea 7 | id: feature-description 8 | attributes: 9 | label: 特性描述 10 | description: 详述你所需要的特性~ 11 | placeholder: "(๑>؂<๑)" 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: suggested-solution 16 | attributes: 17 | label: 建议解决方案 18 | description: 你所建议的解决方案~ 19 | placeholder: "ː̗̀(o›ᴗ‹o)ː̖́" 20 | validations: 21 | required: false 22 | - type: textarea 23 | id: additional-context 24 | attributes: 25 | label: 额外信息 26 | description: 27 | placeholder: "ヾ(❀^ω^)ノ゙" 28 | validations: 29 | required: false 30 | - type: checkboxes 31 | id: checkboxes 32 | attributes: 33 | label: 一点点的自我检查 34 | description: 在你提交 issue 之前,麻烦确认自己是否已经完成了以下检查: 35 | options: 36 | - label: 充分阅读 [README.md](https://github.com/yutto-dev/yutto),特别是与本 issue 相关的部分 37 | required: true 38 | - label: 本 issue 在 [issues](https://github.com/yutto-dev/yutto/issues) 和 [discussion](https://github.com/yutto-dev/yutto/discussions) 中并没有重复问题 39 | required: true 40 | - label: 确认所希望下载的资源是本人有权限获取的,yutto 不提供任何超出本人权限的资源下载方式 41 | required: true 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ## 动机 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ## 解决方案 19 | 20 | 21 | 22 | ## 类型 23 | 24 | 25 | 26 | - [ ] :sparkles: feat: 添加新功能 27 | - [ ] :bug: fix: 修复 bug 28 | - [ ] :pencil: docs: 对文档进行修改 29 | - [ ] :recycle: refactor: 代码重构(既不是新增功能,也不是修改 bug 的代码变动) 30 | - [ ] :zap: perf: 提高性能的代码修改 31 | - [ ] :technologist: dx: 优化开发体验 32 | - [ ] :hammer: workflow: 工作流变动 33 | - [ ] :label: types: 类型声明修改 34 | - [ ] :construction: wip: 工作正在进行中 35 | - [ ] :white_check_mark: test: 测试用例添加及修改 36 | - [ ] :hammer: build: 影响构建系统或外部依赖关系的更改 37 | - [ ] :construction_worker: ci: 更改 CI 配置文件和脚本 38 | - [ ] :question: chore: 其它不涉及源码以及测试的修改 39 | - [ ] :arrow_up: deps: 依赖项修改 40 | - [ ] :bookmark: release: 发布新版本 41 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | // https://docs.renovatebot.com/configuration-options/ 2 | { 3 | extends: ["github>SigureMo/renovate-config"], 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: e2e Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | merge_group: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test-e2e: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.13t"] 16 | architecture: ["x64"] 17 | name: Python ${{ matrix.python-version }} on ${{ matrix.architecture }} e2e test 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | 25 | - name: Install python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | architecture: ${{ matrix.architecture }} 30 | 31 | - name: Install tools 32 | run: | 33 | sudo apt update 34 | sudo apt install ffmpeg 35 | 36 | - name: Install just 37 | uses: extractions/setup-just@v3 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Install dependencies 42 | run: | 43 | just ci-install 44 | 45 | - name: e2e without subprocess 46 | run: | 47 | uv run yutto -v 48 | uv run yutto -h 49 | uv run yutto https://www.bilibili.com/video/BV1AZ4y147Yg -w -d __test_files__ 50 | rm -rf __test_files__ 51 | 52 | - name: e2e test 53 | run: | 54 | just ci-e2e-test 55 | -------------------------------------------------------------------------------- /.github/workflows/latest-release-test.yml: -------------------------------------------------------------------------------- 1 | name: Latest Release Test 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | jobs: 8 | test-yutto-latest: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.13t"] 13 | architecture: ["x64"] 14 | name: Python ${{ matrix.python-version }} on ${{ matrix.architecture }} latest release test 15 | steps: 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v6 18 | 19 | - name: Install python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | architecture: ${{ matrix.architecture }} 24 | 25 | - name: Install tools 26 | run: | 27 | sudo apt update 28 | sudo apt install ffmpeg 29 | 30 | - name: Test yutto from source 31 | run: | 32 | uv cache clean 33 | uvx --no-binary yutto@latest -v 34 | uvx --no-binary yutto@latest -h 35 | uvx --no-binary yutto@latest https://www.bilibili.com/video/BV1AZ4y147Yg -w --no-progress 36 | 37 | - name: Test yutto from wheel 38 | run: | 39 | uv cache clean 40 | uvx yutto@latest -v 41 | uvx yutto@latest -h 42 | uvx yutto@latest https://www.bilibili.com/video/BV1AZ4y147Yg -w --no-progress 43 | 44 | - name: Prepare data for biliass 45 | run: | 46 | git clone https://github.com/yutto-dev/biliass-corpus.git --depth 1 47 | 48 | - name: Test biliass from source 49 | run: | 50 | uv cache clean 51 | uvx --no-binary biliass@latest -v 52 | uvx --no-binary biliass@latest -h 53 | uvx --no-binary biliass@latest biliass-corpus/corpus/xml/18678311.xml -s 1920x1080 -f xml -o xml.ass 54 | uvx --no-binary biliass@latest biliass-corpus/corpus/protobuf/18678311-0.pb biliass-corpus/corpus/protobuf/18678311-1.pb biliass-corpus/corpus/protobuf/18678311-2.pb biliass-corpus/corpus/protobuf/18678311-3.pb -s 1920x1080 -f protobuf -o protobuf.ass 55 | 56 | - name: Test biliass from wheel 57 | run: | 58 | uv cache clean 59 | uvx biliass@latest -v 60 | uvx biliass@latest -h 61 | uvx biliass@latest biliass-corpus/corpus/xml/18678311.xml -s 1920x1080 -f xml -o xml.ass 62 | uvx biliass@latest biliass-corpus/corpus/protobuf/18678311-0.pb biliass-corpus/corpus/protobuf/18678311-1.pb biliass-corpus/corpus/protobuf/18678311-2.pb biliass-corpus/corpus/protobuf/18678311-3.pb -s 1920x1080 -f protobuf -o protobuf.ass 63 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-fmt.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Format 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | merge_group: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lint-and-fmt-python: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10"] 16 | architecture: ["x64"] 17 | name: Lint and Format (Python) 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | 25 | - name: Install python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | architecture: ${{ matrix.architecture }} 30 | 31 | - name: Install just 32 | uses: extractions/setup-just@v3 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | just ci-install 39 | 40 | - name: lint 41 | run: | 42 | just ci-lint 43 | 44 | - name: format check 45 | run: | 46 | just ci-fmt-check 47 | 48 | lint-and-fmt-rust: 49 | name: Lint and Format (Rust) 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout 53 | uses: actions/checkout@v4 54 | - name: Install toolchain 55 | uses: dtolnay/rust-toolchain@stable 56 | with: 57 | toolchain: stable 58 | components: clippy, rustfmt 59 | - uses: actions/cache@v4 60 | id: cargo-cache 61 | with: 62 | path: | 63 | ~/.cargo/bin/ 64 | ~/.cargo/registry/index/ 65 | ~/.cargo/registry/cache/ 66 | ~/.cargo/git/db/ 67 | target/ 68 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 69 | - name: Format with rustfmt 70 | working-directory: packages/biliass/rust 71 | run: | 72 | cargo fmt --all -- --check 73 | - name: Lint with clippy 74 | working-directory: packages/biliass/rust 75 | run: | 76 | cargo clippy --all-targets --all-features -- -D warnings 77 | 78 | lint-and-fmt-docs: 79 | name: Lint and Format (Docs) 80 | runs-on: ubuntu-latest 81 | steps: 82 | - name: Checkout 83 | uses: actions/checkout@v4 84 | 85 | - name: Install pnpm 86 | uses: pnpm/action-setup@v4 87 | with: 88 | package_json_file: "docs/package.json" 89 | 90 | - name: Setup Node.js 91 | uses: actions/setup-node@v4 92 | with: 93 | node-version: "22" 94 | cache: "pnpm" 95 | cache-dependency-path: "docs/pnpm-lock.yaml" 96 | 97 | - name: Install dependencies 98 | working-directory: ./docs 99 | run: pnpm i --frozen-lockfile 100 | 101 | - name: Format check 102 | working-directory: ./docs 103 | run: pnpm fmt:check 104 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release-build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v6 19 | 20 | - name: Install python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.x" 24 | 25 | - name: Install just 26 | uses: extractions/setup-just@v3 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: build release distributions 31 | run: | 32 | just build 33 | 34 | - name: upload dists 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: release-dists 38 | path: dist/ 39 | 40 | publish-pypi: 41 | runs-on: ubuntu-latest 42 | name: Publish to PyPI 43 | if: "startsWith(github.ref, 'refs/tags/')" 44 | needs: 45 | - release-build 46 | permissions: 47 | id-token: write 48 | 49 | steps: 50 | - name: Retrieve release distributions 51 | uses: actions/download-artifact@v4 52 | with: 53 | name: release-dists 54 | path: dist/ 55 | 56 | - name: Install uv 57 | uses: astral-sh/setup-uv@v6 58 | 59 | - name: Publish release distributions to PyPI 60 | run: uv publish -v 61 | 62 | publish-release: 63 | runs-on: ubuntu-latest 64 | name: Publish to GitHub 65 | if: "startsWith(github.ref, 'refs/tags/')" 66 | needs: 67 | - release-build 68 | permissions: 69 | contents: write 70 | steps: 71 | - uses: actions/download-artifact@v4 72 | with: 73 | name: release-dists 74 | path: dist/ 75 | - name: Get tag name 76 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 77 | - name: Publish to GitHub 78 | uses: softprops/action-gh-release@v2 79 | with: 80 | draft: true 81 | files: dist/* 82 | tag_name: ${{ env.RELEASE_VERSION }} 83 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | merge_group: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | unit-test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.13t"] 16 | architecture: ["x64"] 17 | name: Python ${{ matrix.python-version }} on ${{ matrix.architecture }} unit test 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | submodules: recursive 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v6 26 | 27 | - name: Install python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | architecture: ${{ matrix.architecture }} 32 | 33 | - name: Install just 34 | uses: extractions/setup-just@v3 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Install dependencies 39 | run: | 40 | just ci-install 41 | 42 | - name: Run unit tests 43 | run: | 44 | just ci-test 45 | 46 | - name: Run benchmarks 47 | uses: CodSpeedHQ/action@v3 48 | if: ${{ matrix.python-version == '3.13' && github.event_name != 'merge_group' }} 49 | with: 50 | run: uv run pytest --codspeed 51 | -------------------------------------------------------------------------------- /.github/workflows/vitepress-deploy.yml: -------------------------------------------------------------------------------- 1 | name: VitePress Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | merge_group: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-and-deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | submodules: true 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v4 23 | with: 24 | package_json_file: "docs/package.json" 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: "22" 30 | cache: "pnpm" 31 | cache-dependency-path: "docs/pnpm-lock.yaml" 32 | 33 | - name: Install dependencies 34 | working-directory: ./docs 35 | run: pnpm i --frozen-lockfile 36 | 37 | - name: Build VitePress site 38 | working-directory: ./docs 39 | run: pnpm build 40 | 41 | - name: Deploy 42 | uses: peaceiris/actions-gh-pages@v4 43 | if: github.ref == 'refs/heads/main' 44 | with: 45 | personal_token: ${{ secrets.PERSONAL_TOKEN }} 46 | publish_dir: docs/.vitepress/dist 47 | external_repository: SigureMo/docs 48 | publish_branch: yutto 49 | force_orphan: true 50 | commit_message: ":rocket: deploy: " 51 | user_name: "github-actions[bot]" 52 | user_email: "github-actions[bot]@users.noreply.github.com" 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | 117 | # macOS 118 | .DS_Store 119 | 120 | # Media files 121 | *.m4a 122 | *.aac 123 | *.mp3 124 | *.flac 125 | *.mp4 126 | *.mkv 127 | *.mov 128 | *.m4s 129 | *.xml 130 | *.pb 131 | *.ass 132 | *.srt 133 | *.nfo 134 | *.jpg 135 | *.ini 136 | 137 | # test files 138 | *.test.py 139 | 140 | # logs 141 | log 142 | 143 | # test files 144 | __test_files__ 145 | 146 | # config file 147 | yutto.toml 148 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/test_biliass/test_corpus"] 2 | path = tests/test_biliass/test_corpus 3 | url = https://github.com/yutto-dev/biliass-corpus.git 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "ms-python.vscode-pylance", 5 | "charliermarsh.ruff", 6 | "esbenp.prettier-vscode", 7 | "EditorConfig.EditorConfig", 8 | "aaron-bond.better-comments", 9 | "usernamehw.errorlens", 10 | "GitHub.copilot", 11 | "seatonjiang.gitmoji-vscode", 12 | "skellock.just", 13 | "yzhang.markdown-all-in-one" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": { 3 | "yutto": { 4 | "command": "uvx", 5 | "args": ["--with-editable", "${workspaceFolder}[mcp]", "yutto", "mcp"], 6 | "env": {} 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.languageServer": "Pylance", 3 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", 4 | "python.analysis.inlayHints.functionReturnTypes": true, 5 | "python.analysis.inlayHints.variableTypes": true, 6 | "[python]": { 7 | "editor.defaultFormatter": "charliermarsh.ruff" 8 | }, 9 | "rust-analyzer.linkedProjects": [ 10 | "${workspaceFolder}/packages/biliass/rust/Cargo.toml" 11 | ], 12 | "search.exclude": { 13 | "**/*.ambr": true, 14 | "**/*.xml": true, 15 | "**/*.pb": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.22 2 | LABEL maintainer="siguremo" \ 3 | version="2.0.3" \ 4 | description="light-weight container based on alpine for yutto" 5 | 6 | RUN set -x \ 7 | && sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \ 8 | && apk add -q --progress --update --no-cache ffmpeg python3 tzdata \ 9 | && python3 -m venv /opt/venv \ 10 | && /opt/venv/bin/pip install --no-cache-dir --compile yutto \ 11 | && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 12 | 13 | WORKDIR /app 14 | 15 | ENTRYPOINT ["/opt/venv/bin/yutto", "-d", "/app"] 16 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = ["tests/test_biliass/test_corpus"] 3 | 4 | [default.extend-identifiers] 5 | pn = "pn" # Abbr. for "page number" 6 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .temp 3 | .cache 4 | dist/ 5 | lib/ 6 | *.tsbuildinfo 7 | .DS_Store 8 | .vitepress/cache 9 | -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | *.html 2 | dist/ 3 | node_modules/ 4 | *.min.js 5 | lib/* 6 | pnpm-lock.yaml 7 | -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "semi": false, 7 | "arrowParens": "always", 8 | "overrides": [ 9 | { 10 | "files": "*.md", 11 | "options": { 12 | "tabWidth": 3 13 | } 14 | }, 15 | { 16 | "files": "*.json5", 17 | "options": { 18 | "singleQuote": false 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | import { 3 | groupIconMdPlugin, 4 | groupIconVitePlugin, 5 | localIconLoader, 6 | } from 'vitepress-plugin-group-icons' 7 | import llmstxt from 'vitepress-plugin-llms' 8 | 9 | export default defineConfig({ 10 | lang: 'zh-CN', 11 | title: 'yutto', 12 | description: '🧊 yutto,一个可爱且任性的 B 站视频下载器(CLI)', 13 | cleanUrls: true, 14 | head: [ 15 | ['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo-mini.svg' }], 16 | ['meta', { name: 'theme-color', content: '#67e8e2' }], 17 | ['meta', { property: 'og:type', content: 'website' }], 18 | ['meta', { property: 'og:locale', content: 'zh-CN' }], 19 | ['meta', { property: 'og:title', content: '🧊 yutto,一个可爱且任性的 B 站视频下载器(CLI)' }], 20 | ['meta', { property: 'og:site_name', content: 'yutto' }], 21 | ['meta', { property: 'og:image', content: 'https://yutto.nyakku.moe/logo.png' }], 22 | ['meta', { property: 'og:url', content: 'https://yutto.nyakku.moe/' }], 23 | ], 24 | themeConfig: { 25 | logo: { src: '/logo-mini.svg', width: 24, height: 24 }, 26 | nav: [ 27 | { text: '首页', link: '/' }, 28 | { text: '指南', link: '/guide/quick-start' }, 29 | { text: '迁移', link: '/migration/' }, 30 | { 31 | text: '支持我', 32 | items: [ 33 | { text: '赞助', link: '/sponsor' }, 34 | { 35 | text: '参与贡献', 36 | link: 'https://github.com/yutto-dev/yutto/blob/main/CONTRIBUTING.md', 37 | }, 38 | ], 39 | }, 40 | ], 41 | 42 | sidebar: { 43 | '/guide': [ 44 | { 45 | text: '开始', 46 | items: [ 47 | { 48 | text: '快速开始', 49 | link: '/guide/quick-start', 50 | }, 51 | { 52 | text: '支持的链接', 53 | link: '/guide/supported-links', 54 | }, 55 | { 56 | text: '命令行参数', 57 | collapsed: false, 58 | items: [ 59 | { 60 | text: '介绍', 61 | link: '/guide/cli/introduction', 62 | }, 63 | { 64 | text: '基础参数', 65 | link: '/guide/cli/basic', 66 | }, 67 | { 68 | text: '资源选择参数', 69 | link: '/guide/cli/resource', 70 | }, 71 | { 72 | text: '弹幕设置参数', 73 | link: '/guide/cli/danmaku', 74 | }, 75 | { 76 | text: '批量下载参数', 77 | link: '/guide/cli/batch', 78 | }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | { 84 | text: '小技巧', 85 | link: '/guide/tips', 86 | }, 87 | { 88 | text: 'FAQ', 89 | link: '/guide/faq', 90 | }, 91 | { 92 | text: '交流和反馈', 93 | link: '/guide/feedback', 94 | }, 95 | { 96 | text: '注意事项', 97 | link: '/guide/notice', 98 | }, 99 | { 100 | text: '特别感谢', 101 | link: '/guide/thanks', 102 | }, 103 | ], 104 | }, 105 | 106 | footer: { 107 | message: 'Released under the GPL3.0 License.', 108 | copyright: 'Copyright © 2025-present Nyakku Shigure', 109 | }, 110 | 111 | editLink: { 112 | pattern: 'https://github.com/yutto-dev/yutto/edit/main/docs/:path', 113 | text: '欸?我刚刚哪里说错了?你可以帮我改正一下哦~', 114 | }, 115 | 116 | socialLinks: [ 117 | { icon: 'github', link: 'https://github.com/yutto-dev/yutto' }, 118 | { icon: 'discord', link: 'https://discord.gg/5cQGyFwsqC' }, 119 | ], 120 | 121 | search: { 122 | provider: 'local', 123 | }, 124 | }, 125 | 126 | markdown: { 127 | image: { 128 | lazyLoading: true, 129 | }, 130 | config(md) { 131 | md.use(groupIconMdPlugin) 132 | }, 133 | }, 134 | vite: { 135 | plugins: [ 136 | llmstxt(), 137 | groupIconVitePlugin({ 138 | customIcon: { 139 | yutto: localIconLoader(import.meta.url, '../public/logo-mini.svg'), 140 | }, 141 | }) as any, 142 | ], 143 | }, 144 | }) 145 | -------------------------------------------------------------------------------- /docs/.vitepress/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue' 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/Layout.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/Contributors.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 43 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/GitHubUser.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 68 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/components/Sparkler.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 29 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Colors 3 | * -------------------------------------------------------------------------- */ 4 | 5 | :root { 6 | --vp-c-brand-1: #379c9c; 7 | --vp-c-brand-2: #46bcc0; 8 | --vp-c-brand-3: #67e8e2; 9 | --vp-c-brand-soft: #29c9cf33; 10 | } 11 | 12 | .dark { 13 | --vp-c-brand-1: #32cbcb; 14 | --vp-c-brand-2: #3dd6db; 15 | --vp-c-brand-3: #67e8e2; 16 | --vp-c-brand-soft: #29c9cf33; 17 | } 18 | 19 | /** 20 | * Component: Button 21 | * -------------------------------------------------------------------------- */ 22 | 23 | :root { 24 | --vp-button-brand-text: var(--vp-c-bg-soft); 25 | --vp-button-brand-bg: var(--vp-c-brand-2); 26 | --vp-button-brand-hover-text: var(--vp-c-bg-soft); 27 | --vp-button-brand-hover-bg: var(--vp-c-brand-3); 28 | --vp-button-brand-active-text: var(--vp-c-bg-soft); 29 | --vp-button-brand-active-bg: var(--vp-c-brand-2); 30 | } 31 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { EnhanceAppContext } from 'vitepress' 2 | import DefaultTheme from 'vitepress/theme' 3 | import Layout from './Layout.vue' 4 | import 'virtual:group-icons.css' 5 | import './index.css' 6 | 7 | export default { 8 | ...DefaultTheme, 9 | Layout, 10 | enhanceApp(ctx: EnhanceAppContext) { 11 | // extend default theme custom behaviour. 12 | DefaultTheme.enhanceApp(ctx) 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /docs/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue' 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /docs/guide/cli/batch.md: -------------------------------------------------------------------------------- 1 | --- 2 | aside: true 3 | --- 4 | 5 | # 批量参数 6 | 7 | 有些参数是只有批量下载时才可以使用的 8 | 9 | ## 启用批量下载 10 | 11 | - 参数 `-b` 或 `--batch` 12 | - 配置项 「不支持」 13 | - 默认值 `False` 14 | 15 | 只需要 `yutto --batch ` 即可启用批量下载功能。 16 | 17 | ::: warning 该选项不支持配置项 18 | 19 | 因为是否启用批量下载在大多数情况需要针对具体链接来选择,因此将其设置在配置项里基本是无意义的。 20 | 21 | ::: 22 | 23 | ## 选集 24 | 25 | - 参数 `-p` 或 `--episodes` 26 | - 配置项 「不支持」 27 | - 默认值 `1~-1`(也即全选) 28 | 29 | 也就是选集咯,其语法是这样的 30 | 31 | - `` 单独下某一剧集 32 | - 支持负数来选择倒数第几话 33 | - 此外还可以使用 `$` 来代表 `-1` 34 | - `~` 使用 `~` 可以连续选取(如果起始为 1,或者终止为 -1 则可以省略) 35 | - `,,,...,` 使用 `,` 可以不连续选取 36 | 37 | emmm,直接看的话大概并不能知道我在说什么,所以我们通过几个小例子来了解其语法 38 | 39 | ```bash 40 | # 假设要下载一个具有 24 话的番剧 41 | # 如果我们只想下载第 3 话,只需要这样 42 | yutto -b -p 3 43 | # 那如果我想下载第 5 话到第 7 话呢,使用 `~` 可以连续选中 44 | yutto -b -p 5~7 45 | # 那我想下载第 12 话和第 17 话又要怎么办?此时只需要 `,` 就可以将多个不连续的选集一起选中 46 | yutto -b -p 12,17 47 | # 那我突然又想将刚才那些都选中了呢?还是使用 `,` 呀,将它们连在一起即可 48 | yutto -b -p 3,5~7,12,17 49 | # 嗯,你已经把基本用法都了解过了,很简单吧~ 50 | # 下面是一些语法糖,不了解也完全不会影响任何功能哒~ 51 | # 那如果我只知道我想下载倒数第 3 话,而不想算倒数第三话是第几话应该怎么办? 52 | # 此时可以用负数哒~不过要注意的是,这种参数以 `-` 开头参数需要使用 `=` 来连接选项和参数 53 | yutto -b -p=-3 54 | # 那么如果想下载最后一话你可能会想到 `-p=-1` 对吧?不过我内置了符号 $ 用于代表最后一话 55 | # 像下面这样就可以直接下载最后一话啦~ 56 | yutto -b -p $ 57 | # 为了进一步方便表示一个范围选取,在从第一话开始选取或者以最后一话为终止时可以省略它们 58 | # 这样就是前三话啦(这里与以 `-` 开头类似,以 `~` 开头可能被识别为 $HOME,因此最好也用等号,或者使用引号包裹) 59 | yutto -b -p=~3 60 | # 这样就是后四话啦 61 | yutto -b -p=-4~ 62 | # 所有语法都了解完啦,我们看一个稍微复杂的例子 63 | yutto -b -p "~3,10,12~14,16,-4~" 64 | # 很明显,上面的例子就是下载前 3 话、第 10 话、第 12 到 14 话、第 16 话以及后 4 话 65 | ``` 66 | 67 | 下面是一些要注意的问题 68 | 69 | 1. 这里使用的序号是视频的顺序序号,而不是番剧所标注的`第 n 话`,因为有可能会出现 `第 x.5 话` 等等的特殊情况,此时一定要按照顺序自行计数。 70 | 2. 参数值里一定不要加空格 71 | 3. 参数值开头为特殊符号时最好使用 `=` 来连接选项和参数,或者尝试使用引号包裹参数 72 | 4. 个人空间、视频列表、收藏夹等批量下载暂不支持选集操作 73 | 74 | ::: warning 该选项不支持配置项 75 | 76 | 与「启用批量下载」相同,具体选集只有在具体链接时才能确定,为该选项指定配置项同样无意义。 77 | 78 | ::: 79 | 80 | ## 同时下载附加剧集 81 | 82 | - 参数 `-s` 或 `--with-section` 83 | - 配置项 `batch.with_section` 84 | - 默认值 `False` 85 | 86 | ## 指定稿件发布时间范围 87 | 88 | - 参数 `--batch-filter-start-time` 和 `--batch-filter-end-time` 分别表示`开始`和`结束`时间,该区间**左闭右开** 89 | - 配置项 `batch.filter_start_time` 和 `batch.filter_end_time` 90 | - 默认 `不限制` 91 | - 支持的格式 92 | 93 | - `%Y-%m-%d` 94 | - `%Y-%m-%d %H:%M:%S` 95 | 96 | 例如仅下载 2020 年投稿的视频,可以这样: 97 | 98 | `--batch-filter-start-time=2020-01-01 --batch-filter-end-time=2021-01-01` 99 | -------------------------------------------------------------------------------- /docs/guide/cli/danmaku.md: -------------------------------------------------------------------------------- 1 | --- 2 | aside: true 3 | --- 4 | 5 | # 弹幕设置参数 6 | 7 | 通过与 biliass 的集成,我提供了一些 ASS 弹幕选项,包括字号、字体、速度等~ 8 | 9 | ## 弹幕字体大小 10 | 11 | - 参数 `--danmaku-font-size` 12 | - 配置项 `danmaku.font_size` 13 | - 默认值 `video_width / 40` 14 | 15 | ## 弹幕字体 16 | 17 | - 参数 `--danmaku-font` 18 | - 配置项 `danmaku.font` 19 | - 默认值 `"SimHei"` 20 | 21 | ## 弹幕不透明度 22 | 23 | - 参数 `--danmaku-opacity` 24 | - 配置项 `danmaku.opacity` 25 | - 默认值 `0.8` 26 | 27 | ## 弹幕显示区域与视频高度的比例 28 | 29 | - 参数 `--danmaku-display-region-ratio` 30 | - 配置项 `danmaku.display_region_ratio` 31 | - 默认值 `1.0` 32 | 33 | ## 弹幕速度 34 | 35 | - 参数 `--danmaku-speed` 36 | - 配置项 `danmaku.speed` 37 | - 默认值 `1.0` 38 | 39 | ## 屏蔽顶部弹幕 40 | 41 | - 参数 `--danmaku-block-top` 42 | - 配置项 `danmaku.block_top` 43 | - 默认值 `False` 44 | 45 | ## 屏蔽底部弹幕 46 | 47 | - 参数 `--danmaku-block-bottom` 48 | - 配置项 `danmaku.block_bottom` 49 | - 默认值 `False` 50 | 51 | ## 屏蔽滚动弹幕 52 | 53 | - 参数 `--danmaku-block-scroll` 54 | - 配置项 `danmaku.block_scroll` 55 | - 默认值 `False` 56 | 57 | ## 屏蔽逆向弹幕 58 | 59 | - 参数 `--danmaku-block-reverse` 60 | - 配置项 `danmaku.block_reverse` 61 | - 默认值 `False` 62 | 63 | ## 屏蔽固定弹幕(顶部、底部) 64 | 65 | - 参数 `--danmaku-block-fixed` 66 | - 配置项 `danmaku.block_fixed` 67 | - 默认值 `False` 68 | 69 | ## 屏蔽高级弹幕 70 | 71 | - 参数 `--danmaku-block-special` 72 | - 配置项 `danmaku.block_special` 73 | - 默认值 `False` 74 | 75 | ## 屏蔽彩色弹幕 76 | 77 | - 参数 `--danmaku-block-colorful` 78 | - 配置项 `danmaku.block_colorful` 79 | - 默认值 `False` 80 | 81 | ## 屏蔽关键词 82 | 83 | - 参数 `--danmaku-block-keyword-patterns` 84 | - 配置项 `danmaku.block_keyword_patterns` 85 | - 默认值 `None` 86 | 87 | 按关键词屏蔽,支持正则,作为 CLI 参数使用 `,` 分隔,作为配置项直接使用列表即可: 88 | 89 | ```toml [yutto.toml] 90 | [danmaku] 91 | block_keyword_patterns = [ 92 | ".*keyword1.*", 93 | ".*keyword2.*", 94 | ] 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/guide/cli/introduction.md: -------------------------------------------------------------------------------- 1 | # 命令行参数 2 | 3 | 既然我是基于 CLI(Command Line Interface)的工具,那么自然而然,我支持很多命令行参数来让你更好地调度我。 4 | 5 | 就比如说,你可以添加 `-d` 参数来指定下载的视频路径,或者使用 `-c` 参数来设置用于登录状态的 Cookie(准确来说是其中的 `SESSDATA`)。 6 | 7 | ## 参数的使用方式 8 | 9 | 如果你是第一次接触命令行,那么你可能会对这些参数的使用方式感到困惑。 10 | 11 | 不过,不用担心,我会在这里为你详细地介绍一下。 12 | 13 | ### 指定参数值 14 | 15 | 比如你需要修改下载路径到 `/path/to/videos`,只需要 16 | 17 | ```bash 18 | yutto --dir /path/to/videos 19 | # 或者使用短参数 -d 20 | yutto -d /path/to/videos 21 | # 你也可以使用 = 将参数 key 和 value 连接在一起 22 | yutto -d=/path/to/videos 23 | yutto --dir=/path/to/videos 24 | ``` 25 | 26 | ### 切换 `True` or `False` 27 | 28 | 对于那些不需要指定具体值,只切换 `True` or `False` 的参数,你也不需要在命令中指定值,比如开启强制覆盖已下载视频选项 29 | 30 | ```bash 31 | yutto --overwrite 32 | # 或者 33 | yutto -w 34 | ``` 35 | 36 | ### 多参数同时使用 37 | 38 | 当然,同时使用多个参数也是允许的,只需要写在一起即可,而且 `` 和其它参数都不强制要求顺序,比如下面这些命令都是合法的 39 | 40 | ```bash 41 | yutto --overwrite --dir=/path/to/videos 42 | yutto --overwrite -d /path/to/videos 43 | yutto -w --d=/path/to/videos 44 | ``` 45 | 46 | ## 更多参数 47 | 48 | 当然,这些只是冰山一角啦,我支持的参数远不止这些,你可以通过 `yutto --help` 来查看所有支持的参数。也可以前往以下页面查看具体介绍: 49 | 50 | - [基础参数](./basic) 51 | - [资源选择参数](./resource) 52 | - [弹幕设置参数](./danmaku) 53 | - [批量下载参数](./batch) 54 | 55 | ## 配置文件 56 | 57 | 当你熟悉 CLI 界面后,可能每次下载视频的时候都需要输入一长串的参数,你可能会希望有一种方式来保存常用的参数,下次下载时直接使用,这时候配置文件就派上用场啦~ 58 | 59 | 你可以通过 `--config` 参数来指定配置文件的路径,比如 60 | 61 | ```bash 62 | yutto --config /path/to/config.toml 63 | ``` 64 | 65 | 我还支持配置自动发现,也就是说,如果不指定配置文件路径,我也会自动去以下路径查找配置文件的: 66 | 67 | - 当前目录下的 `yutto.toml` 68 | - [`XDG_CONFIG_HOME`](https://specifications.freedesktop.org/basedir-spec/latest/) 下的 `yutto/yutto.toml` 文件 69 | - 非 Windows 系统下的 `~/.config/yutto/yutto.toml`,Windows 系统下的 `~/AppData/Roaming/yutto/yutto.toml` 70 | 71 | 你可以通过配置文件来设置一些默认参数,整体上与命令行参数基本一致,下面以一些示例来展示配置文件的写法: 72 | 73 | ```toml [yutto.toml] 74 | #:schema https://raw.githubusercontent.com/yutto-dev/yutto/refs/heads/main/schemas/config.json 75 | [basic] 76 | # 设置下载目录 77 | dir = "/path/to/download" 78 | # 设置临时文件目录 79 | tmp_dir = "/path/to/tmp" 80 | # 设置 SESSDATA 81 | sessdata = "***************" 82 | # 设置大会员严格校验 83 | vip_strict = true 84 | # 设置登录严格校验 85 | login_strict = true 86 | 87 | [resource] 88 | # 不下载字幕 89 | require_subtitle = false 90 | 91 | [danmaku] 92 | # 设置弹幕速度 93 | speed = 2.0 94 | # 设置弹幕屏蔽关键词 95 | block_keyword_patterns = [ 96 | ".*keyword1.*", 97 | ".*keyword2.*", 98 | ] 99 | 100 | [batch] 101 | # 下载额外剧集 102 | with_section = true 103 | ``` 104 | 105 | 如果你使用 VS Code 对配置文件编辑,强烈建议使用 [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) 扩展,配合我提供的 schema,可以获得最佳的提示体验。 106 | -------------------------------------------------------------------------------- /docs/guide/cli/resource.md: -------------------------------------------------------------------------------- 1 | --- 2 | aside: true 3 | --- 4 | 5 | # 资源选择参数 6 | 7 | 这里有一些参数专用于资源选择,比如选择是否下载弹幕、音频、视频等等。 8 | 9 | ## 仅下载视频流 10 | 11 | - 参数 `--video-only` 12 | - 默认值 `False` 13 | 14 | ::: tip 15 | 16 | 这里「仅下载视频流」是指视频中音视频流仅选择视频流,而不是仅仅下载视频而不下载弹幕字幕等资源,如果需要取消字幕等资源下载,请额外使用 `--no-danmaku` 等参数。 17 | 18 | 「仅下载音频流」也是同样的。 19 | 20 | ::: 21 | 22 | ## 仅下载音频流 23 | 24 | - 参数 `--audio-only` 25 | - 默认值 `False` 26 | 27 | 仅下载其中的音频流,保存为 `.m4a` 文件。 28 | 29 | ## 不生成弹幕文件 30 | 31 | - 参数 `--no-danmaku` 32 | - 默认值 `False` 33 | 34 | ## 仅生成弹幕文件 35 | 36 | - 参数 `--danmaku-only` 37 | - 默认值 `False` 38 | 39 | ## 不生成字幕文件 40 | 41 | - 参数 `--no-subtitle` 42 | - 默认值 `False` 43 | 44 | ## 仅生成字幕文件 45 | 46 | - 参数 `--subtitle-only` 47 | - 默认值 `False` 48 | 49 | ## 生成媒体元数据文件 50 | 51 | - 参数 `--with-metadata` 52 | - 默认值 `False` 53 | 54 | 目前媒体元数据生成尚在试验阶段,可能提取出的信息并不完整。 55 | 56 | ## 仅生成媒体元数据文件 57 | 58 | - 参数 `--metadata-only` 59 | - 默认值 `False` 60 | 61 | ## 不生成视频封面 62 | 63 | - 参数 `--no-cover` 64 | - 默认值 `False` 65 | 66 | ::: tip 67 | 68 | 当前仅支持为包含视频流的视频生成封面。 69 | 70 | ::: 71 | 72 | ## 生成视频流封面时单独保存封面 73 | 74 | - 参数 `--save-cover` 75 | - 默认值 `False` 76 | 77 | ## 仅生成视频封面 78 | 79 | - 参数 `--cover-only` 80 | - 默认值 `False` 81 | 82 | ## 不生成章节信息 83 | 84 | - 参数 `--no-chapter-info` 85 | - 默认值 `False` 86 | 87 | 不生成章节信息,包含 MetaData 和嵌入视频流的章节信息。 88 | 89 | ## 配置项 90 | 91 | 与命令行界面完全不同,配置文件可以直接表明你要下载的资源类型,比如: 92 | 93 | ```toml [yutto.toml] 94 | [resource] 95 | require_audio = false 96 | require_subtitle = false 97 | require_danmaku = false 98 | ``` 99 | 100 | 如上配置表明了你不需要音频、字幕和弹幕资源。 101 | 102 | 具体配置项如下: 103 | 104 | ### 是否需要视频流 105 | 106 | - 配置项 `resource.video_only` 107 | - 默认值 `True` 108 | 109 | ### 是否需要音频流 110 | 111 | - 配置项 `resource.require_audio` 112 | - 默认值 `True` 113 | 114 | ### 是否需要弹幕 115 | 116 | - 配置项 `resource.require_danmaku` 117 | - 默认值 `True` 118 | 119 | ### 是否需要字幕 120 | 121 | - 配置项 `resource.require_subtitle` 122 | - 默认值 `True` 123 | 124 | ### 是否需要媒体元数据 125 | 126 | - 配置项 `resource.require_metadata` 127 | - 默认值 `False` 128 | 129 | ### 是否需要视频封面 130 | 131 | - 配置项 `resource.require_cover` 132 | - 默认值 `True` 133 | 134 | ### 是否需要章节信息 135 | 136 | - 配置项 `resource.require_chapter_info` 137 | - 默认值 `True` 138 | 139 | ### 生成视频流封面时单独保存封面 140 | 141 | - 配置项 `resource.save_cover` 142 | - 默认值 `False` 143 | -------------------------------------------------------------------------------- /docs/guide/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## 版本号为什么是 2.0 4 | 5 | 因为 yutto 是 [bilili](https://github.com/yutto-dev/bilili) 的後輩呀~ 6 | 7 | ## 名字的由来 8 | 9 | [《転スラ日記》第一话 00:24](https://www.bilibili.com/bangumi/play/ep395211?t=24) 10 | 11 | ## 何谓「任性」? 12 | 13 | 我不一定会满足你的所有需求哦,从设计者的角度,为我添加任何特性都需要以保证可维护性为前提,因此我不会支持过于复杂的特性,只需要满足大多数用户的需求即可~ 14 | 15 | ## yutto 会替代 bilili 吗 16 | 17 | 我自诞生以来已经过去三年多了,功能上基本可以替代 bilili 了,由于 B 站接口的不断变化,bilili 也不再适用于现在的环境,因此请 bilili 用户尽快迁移到 yutto ~ 18 | -------------------------------------------------------------------------------- /docs/guide/feedback.md: -------------------------------------------------------------------------------- 1 | # 交流和反馈 2 | 3 | 在提出问题之前,请确定你已经大致地将本文档浏览过一遍,这样会节省彼此的一些时间。当然,如果你对文档的组织形式、内容等有任何问题的话,欢迎提出与贡献。 4 | 5 | 如果你有一些新的想法,可以在 GitHub 上[发起 discussion](https://github.com/yutto-dev/yutto/discussions),如果你确定你发现了一个 Bug,或者想请求添加新的功能,也可以直接[发起 Issue](https://github.com/yutto-dev/yutto/issues/)。 6 | 7 | 此外你也可以通过 [Discord](https://discord.com/invite/5cQGyFwsqC) 来与大家讨论,建议问题尽可能直奔主题,尽可能少询问不必要的信息。 8 | -------------------------------------------------------------------------------- /docs/guide/notice.md: -------------------------------------------------------------------------------- 1 | # 注意事项 2 | 3 | 我的工作只是将 B 站的视频搬运到你的电脑上,**仅此而已**啦,但有些事情你可能需要知道。 4 | 5 | 我不会帮你下载你没有权限访问的东西,因此我不是什么破解程序,该开大会员还是去乖乖开大会员,这是对 B 站的一种支持,所以请不要对我提让我为难的要求哦,无论现在还是以后。 6 | 7 | 我所下载的东西只代表你有权限获取,请不要将我获取的东西随意分享破坏平台和创作者的权益,如果你这么做了的话,那就不要怪我绝情咯,只能一切后果自负哦。 8 | 9 | 另外我本身的开源协议是 GPL-3.0,一方面我需要 FFmpeg 前辈和 [biliass](https://github.com/yutto-dev/yutto/tree/main/packages/biliass) 的帮忙,另一方面也尽可能维护 B 站本身的权益。不过我的文档采用的是 CC0-1.0 协议。 10 | -------------------------------------------------------------------------------- /docs/guide/thanks.md: -------------------------------------------------------------------------------- 1 | # 特别感谢 2 | 3 | ## 平台以及创作者 4 | 5 | 感谢 [bilibili](https://www.bilibili.com/) 平台以及平台上无数优质内容的创作者。 6 | 7 | ## 依赖项目 8 | 9 | 我的正常运作离不开以下项目的支持 10 | 11 | - [HTTPX](https://github.com/encode/httpx) 用于 HTTP 请求发送 12 | - [FFmpeg](https://github.com/FFmpeg/FFmpeg) 用于视频的合并 13 | - [biliass](https://github.com/yutto-dev/yutto/tree/main/packages/biliass) 用于 Bilibili XML/Protobuf 格式弹幕转换为 ASS 弹幕 14 | - [Pydantic](https://github.com/pydantic/pydantic) 用于配置格式校验 15 | - [VitePress](https://github.com/vuejs/vitepress) 本文档的生成器 16 | 17 | ## 参考项目 18 | 19 | 我在探索过程中得到了以下项目的帮助 20 | 21 | - 基本结构: 22 | - 协程下载: 23 | - 弹幕转换: 24 | - 样式设计: 25 | 26 | ## 云服务 27 | 28 | - [Vercel](https://vercel.com/) 提供[文档托管](https://vercel.com/siguremo/yutto-docs) 29 | 30 | ## 贡献者 31 | 32 | 感谢每一位贡献者的辛勤付出 33 | 34 | 35 | 36 | ## 赞助者 37 | 38 | 感谢各位赞助者的资金援助 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ## 以及你的支持~ 49 | 50 | 54 | -------------------------------------------------------------------------------- /docs/guide/tips.md: -------------------------------------------------------------------------------- 1 | # 小技巧 2 | 3 | ## 作为 log 输出到文件 4 | 5 | 虽说我不像 bilili 前辈那样会全屏刷新,但进度条还是会一直刷新占据多行,可能影响 log 的阅读,另外颜色码也是难以阅读的,因此我们可以通过选项禁用他们: 6 | 7 | ```bash 8 | yutto --no-color --no-progress > log 9 | ``` 10 | 11 | ## 使用配置自定义默认参数 12 | 13 | 如果你希望修改我的部分参数,那么可能每次运行都需要在后面加上长长一串选项,为了避免这个问题,你可以尝试使用配置文件 14 | 15 | ```toml 16 | # ~/.config/yutto/yutto.toml 17 | #:schema https://raw.githubusercontent.com/yutto-dev/yutto/refs/heads/main/schemas/config.json 18 | [basic] 19 | dir = "~/Movies/yutto" 20 | sessdata = "***************" 21 | num_workers = 16 22 | vcodec = "av1:copy" 23 | ``` 24 | 25 | 当然,请手动修改 `sessdata` 内容为自己的 `SESSDATA` 哦~ 26 | 27 | :::: tip 28 | 29 | 本方案可替代原有的「自定义命令别名」方式~ 30 | 31 | ::: details 原「自定义命令别名」方案 32 | 33 | 在 `~/.zshrc` / `~/.bashrc` 中自定义一条 alias,像这样 34 | 35 | ```bash 36 | alias ytt='yutto -d ~/Movies/yutto/ -c `cat ~/.sessdata` -n 16 --vcodec="av1:copy"' 37 | ``` 38 | 39 | 这样我每次只需要 `ytt ` 就可以直接使用这些参数进行下载啦~ 40 | 41 | 由于我提前在 `~/.sessdata` 存储了我的 `SESSDATA`,所以避免每次都要手动输入 cookie 的问题。 42 | 43 | ::: 44 | 45 | :::: 46 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: yutto 6 | text: 🧊 一个可爱且任性的 B 站视频下载器 7 | actions: 8 | - theme: brand 9 | text: 从零开始 10 | link: /guide/quick-start.html 11 | - theme: alt 12 | text: 从 bilili 迁移 13 | link: /migration/ 14 | - theme: alt 15 | text: GitHub 16 | link: https://github.com/yutto-dev/yutto 17 | image: 18 | src: /logo.png 19 | alt: yutto-logo 20 | features: 21 | - icon: ⚡️ 22 | title: 快速下载 23 | details: 协程 + 分块下载,尽可能地利用并行性 24 | - icon: 📜 25 | title: 弹幕支持 26 | details: 默认支持 ASS 弹幕生成 27 | - icon: 🔁 28 | title: 断点续传 29 | details: 即便一次没下完也可以接着下载~ 30 | - icon: 🌈 31 | title: 支持类型丰富 32 | details: 支持投稿视频、番剧、视频合集、收藏夹等的下载 33 | --- 34 | 35 | 56 | -------------------------------------------------------------------------------- /docs/migration/index.md: -------------------------------------------------------------------------------- 1 | # 从 𝓫𝓲𝓵𝓲𝓵𝓲 迁移 2 | 3 | ## 取消的功能 4 | 5 | - `- bilibili` 目录的生成 6 | - 播放列表生成 7 | - 源格式修改功能(不再支持 flv 源视频下载,如果仍有视频不支持 dash 源,请继续使用 bilili) 8 | - 对 Python3.8 的支持,最低支持 Python3.10 9 | - 下载前询问 10 | 11 | ## 默认行为的修改 12 | 13 | - 使用协程而非多线程进行下载 14 | - 默认生成弹幕为 ASS 15 | - 默认启用从多镜像源下载的特性 16 | - 不仅可以控制是否使用系统代理,还能配置特定的代理服务器 17 | 18 | ## 参数名修改 19 | 20 | - 「视频清晰度选择」 `-q`/`--quality` 修改为 `-q`/`--video-quality` 21 | - 「个人信息认证」 `-c`/`--sess-data` 修改为 `-c`/`--sessdata` 22 | - 「指定下载弹幕类型」 `--danmaku` 拆分为「弹幕格式选择」 `-df`/`--danmaku-format` 和「不生成弹幕文件」 `--no-danmaku` 两个参数 23 | - 「绕过系统代理」 `--disable-proxy` 集成到更为灵活的「代理设置」 `-x`/`--proxy` 参数 24 | 25 | ## 新增的特性 26 | 27 | - 单视频下载与批量下载命令分离(`bilili` 命令与 `yutto --batch` 相类似) 28 | - 音频/视频编码选择 29 | - 可选仅下载音频/视频 30 | - 存放子路径的自由定制 31 | - 支持 url 别名 32 | - 支持文件列表 33 | - 更多的批下载支持(现已支持 UP 主全部投稿视频、视频合集、收藏夹等) 34 | - 更加完善的 warning 与 error 提示 35 | - 支持仅输入 id 即可下载(aid、bvid、episode_id 等) 36 | - 支持描述文件生成 37 | - 并行化链接解析(超快的~) 38 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yutto-docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "author": "Nyakku Shigure ", 6 | "license": "CC0-1.0", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+ssh://git@github.com/yutto-dev/yutto.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/yutto-dev/yutto/issues" 13 | }, 14 | "homepage": "https://github.com/yutto-dev/yutto#readme", 15 | "packageManager": "pnpm@10.11.0", 16 | "scripts": { 17 | "dev": "vitepress dev", 18 | "build": "vitepress build", 19 | "serve": "vitepress serve", 20 | "fmt": "prettier --write .", 21 | "fmt:check": "prettier --check ." 22 | }, 23 | "devDependencies": { 24 | "@moefy-canvas/core": "^0.6.0", 25 | "@moefy-canvas/theme-sparkler": "^0.6.0", 26 | "prettier": "^3.5.2", 27 | "vite": "^6.1.1", 28 | "vitepress": "^1.6.3", 29 | "vitepress-plugin-group-icons": "^1.3.6", 30 | "vitepress-plugin-llms": "^1.0.0", 31 | "vue": "^3.5.13" 32 | }, 33 | "pnpm": { 34 | "peerDependencyRules": { 35 | "ignoreMissing": [ 36 | "@algolia/client-search", 37 | "react", 38 | "react-dom", 39 | "@types/react" 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/docs/public/logo.png -------------------------------------------------------------------------------- /docs/public/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/assets/(.*)", 5 | "headers": [ 6 | { 7 | "key": "Cache-Control", 8 | "value": "max-age=31536000, immutable" 9 | } 10 | ] 11 | } 12 | ], 13 | "cleanUrls": true 14 | } 15 | -------------------------------------------------------------------------------- /docs/sponsor.md: -------------------------------------------------------------------------------- 1 | # 赞助 2 | 3 | 首先说明,由于我只是将 B 站的视频搬运到你的电脑上,是一件很简单的事情,请把最大的感激给予平台以及创作者。 4 | 5 | 如果你想支持我的话,在 [GitHub 项目主页](https://github.com/yutto-dev/yutto)给予我一个 「Star」 就是对我的最大鼓励。 6 | 7 | 此外,如果你想给予 Nyakku 一定资金支持以激励 Nyakku 的开发的话,你可以通过以下方式进行 8 | 9 | ## 一次性赞助 10 | 11 | 你可以通过[支付宝](https://img.nyakku.moe/sponsor/alipay.png)或者[微信](https://img.nyakku.moe/sponsor/wechat.png)来为 Nyakku 提供一笔开发资金。 12 | 13 | ## 周期性赞助 14 | 15 | 你可以通过 [Patreon](https://www.patreon.com/SigureMo) 或者[爱发电](https://afdian.net/@siguremo)来为 Nyakku 提供每月的资金支持,以激励 Nyakku 创作更多有趣、实用的开源项目。 16 | 17 | 你的任何金额的赞助我都会无比珍惜,我会在[项目致谢](./guide/thanks)中标注你的 GitHub ID(需要在赞助时备注你的 GitHub ID,如果有资助后忘记留 ID 的可以联系我~)。 18 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "lib": ["DOM", "ES2020"], 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "jsx": "preserve", 10 | "newLine": "lf", 11 | "noEmitOnError": true, 12 | "noImplicitAny": false, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "strictNullChecks": true, 18 | "target": "ES2018" 19 | }, 20 | "include": ["./.vitepress/**/*", "./.vitepress/env.d.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set positional-arguments 2 | 3 | VERSION := `uv run scripts/get-version.py src/yutto/__version__.py` 4 | BILIASS_VERSION := `uv run scripts/get-version.py packages/biliass/src/biliass/__version__.py` 5 | DOCKER_NAME := "siguremo/yutto" 6 | 7 | run *ARGS: 8 | uv run python -m yutto {{ARGS}} 9 | 10 | install: 11 | uv sync 12 | 13 | test: 14 | uv run pytest -m '(api or e2e or processor or biliass) and not (ci_only or ignore)' 15 | just clean 16 | 17 | fmt: 18 | uv run ruff format . 19 | 20 | lint: 21 | uv run pyright src/yutto packages/biliass/src/biliass tests 22 | uv run ruff check . 23 | uv run typos 24 | 25 | build: 26 | uv build 27 | 28 | release: 29 | @echo 'Tagging v{{VERSION}}...' 30 | git tag "v{{VERSION}}" 31 | @echo 'Push to GitHub to trigger publish process...' 32 | git push --tags 33 | 34 | publish: 35 | uv build 36 | uv publish 37 | git push --tags 38 | just clean-builds 39 | 40 | clean: 41 | fd \ 42 | -u \ 43 | -E tests/test_biliass/test_corpus/ \ 44 | -e m4s \ 45 | -e mp4 \ 46 | -e mkv \ 47 | -e mov \ 48 | -e m4a \ 49 | -e aac \ 50 | -e mp3 \ 51 | -e flac \ 52 | -e srt \ 53 | -e xml \ 54 | -e ass \ 55 | -e nfo \ 56 | -e pb \ 57 | -e pyc \ 58 | -e jpg \ 59 | -e ini \ 60 | -x rm 61 | rm -rf .pytest_cache/ 62 | rm -rf .mypy_cache/ 63 | find . -maxdepth 3 -type d -empty -print0 | xargs -0 -r rm -r 64 | 65 | clean-builds: 66 | rm -rf build/ 67 | rm -rf dist/ 68 | rm -rf yutto.egg-info/ 69 | 70 | generate-schema: 71 | uv run scripts/generate-schema.py 72 | 73 | # CI specific 74 | ci-install: 75 | uv sync --all-extras --dev 76 | 77 | ci-fmt-check: 78 | uv run ruff format --check --diff . 79 | 80 | ci-lint: 81 | just lint 82 | 83 | ci-test: 84 | uv run pytest -m "(api or processor or biliass) and not (ci_skip or ignore)" --reruns 3 --reruns-delay 1 85 | 86 | ci-e2e-test: 87 | uv run pytest -m "e2e and not (ci_skip or ignore)" 88 | 89 | # docker specific 90 | docker-run *ARGS: 91 | docker run --rm -it -v `pwd`:/app {{DOCKER_NAME}} {{ARGS}} 92 | 93 | docker-build: 94 | docker build --no-cache -t "{{DOCKER_NAME}}:{{VERSION}}" -t "{{DOCKER_NAME}}:latest" . 95 | 96 | docker-publish: 97 | docker buildx build --no-cache --platform=linux/amd64,linux/arm64 -t "{{DOCKER_NAME}}:{{VERSION}}" -t "{{DOCKER_NAME}}:latest" . --push 98 | 99 | # docs specific 100 | docs-setup: 101 | cd docs; pnpm i 102 | 103 | docs-dev: 104 | cd docs; pnpm dev 105 | 106 | docs-build: 107 | cd docs; pnpm build 108 | 109 | # biliass specific 110 | build-biliass: 111 | cd packages/biliass; maturin build 112 | 113 | develop-biliass *ARGS: 114 | cd packages/biliass; maturin develop --uv {{ARGS}} 115 | 116 | release-biliass: 117 | @echo 'Tagging biliass@{{BILIASS_VERSION}}...' 118 | git tag "biliass@{{BILIASS_VERSION}}" 119 | @echo 'Push to GitHub to trigger publish process...' 120 | git push --tags 121 | 122 | snapshot-update: 123 | uv run pytest tests/test_biliass/test_corpus --snapshot-update 124 | 125 | fetch-corpus *ARGS: 126 | cd tests/test_biliass/test_corpus; uv run scripts/fetch-corpus.py {{ARGS}} 127 | 128 | test-corpus: 129 | uv run pytest tests/test_biliass/test_corpus --capture=no -vv 130 | -------------------------------------------------------------------------------- /packages/biliass/README.md: -------------------------------------------------------------------------------- 1 | # biliass 2 | 3 |

4 | PyPI - Python Version 5 | pypi 6 | PyPI - Downloads 7 | Build Status 8 | LICENSE 9 | Gitmoji 10 | CodSpeed Badge 11 |

12 | 13 | biliass,高性能且易于使用的 bilibili 弹幕转换工具(XML/Protobuf 格式转 ASS),基于 [Danmaku2ASS](https://github.com/m13253/danmaku2ass),使用 rust 重写 14 | 15 | ## Install 16 | 17 | ```bash 18 | pip install biliass 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```bash 24 | # XML 弹幕 25 | biliass danmaku.xml -s 1920x1080 -o danmaku.ass 26 | # protobuf 弹幕 27 | biliass danmaku.pb -s 1920x1080 -f protobuf -o danmaku.ass 28 | ``` 29 | 30 | ```python 31 | from biliass import convert_to_ass 32 | 33 | # xml 34 | convert_to_ass( 35 | xml_text_or_bytes, 36 | 1920, 37 | 1080, 38 | input_format="xml", 39 | display_region_ratio=1.0, 40 | font_face="sans-serif", 41 | font_size=25, 42 | text_opacity=0.8, 43 | duration_marquee=15.0, 44 | duration_still=10.0, 45 | block_options=None, 46 | reduce_comments=False, 47 | ) 48 | 49 | # protobuf 50 | convert_to_ass( 51 | protobuf_bytes, # only bytes 52 | 1920, 53 | 1080, 54 | input_format="protobuf", 55 | display_region_ratio=1.0, 56 | font_face="sans-serif", 57 | font_size=25, 58 | text_opacity=0.8, 59 | duration_marquee=15.0, 60 | duration_still=10.0, 61 | block_options=None, 62 | reduce_comments=False, 63 | ) 64 | ``` 65 | -------------------------------------------------------------------------------- /packages/biliass/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "biliass" 3 | description = "💬 将 B 站 XML/protobuf 弹幕转换为 ASS 弹幕" 4 | readme = "README.md" 5 | requires-python = ">=3.10" 6 | authors = [ 7 | { name = "Star Brilliant", email = "m13253@hotmail.com" }, 8 | { name = "Nyakku Shigure", email = "sigure.qaq@gmail.com" }, 9 | ] 10 | keywords = ["bilibili", "yutto", "danmaku", "ASS"] 11 | license = { file = "LICENSE" } 12 | classifiers = [ 13 | "Operating System :: OS Independent", 14 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | ] 23 | dependencies = [] 24 | dynamic = ["version"] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/yutto-dev/yutto/tree/main/packages/biliass" 28 | Documentation = "https://github.com/yutto-dev/yutto/tree/main/packages/biliass" 29 | Repository = "https://github.com/yutto-dev/yutto" 30 | Issues = "https://github.com/yutto-dev/yutto/issues" 31 | 32 | [project.scripts] 33 | biliass = "biliass.__main__:main" 34 | 35 | [tool.maturin] 36 | features = ["pyo3/extension-module"] 37 | module-name = "biliass._core" 38 | 39 | [tool.pyright] 40 | include = ["src/biliass"] 41 | pythonVersion = "3.10" 42 | typeCheckingMode = "basic" 43 | 44 | [build-system] 45 | requires = ["maturin>=1.4,<2.0"] 46 | build-backend = "maturin" 47 | -------------------------------------------------------------------------------- /packages/biliass/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "biliass-core" 3 | version = "2.2.2" 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | name = "biliass_core" 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | pyo3 = { version = "0.25.0", features = ["abi3-py310"] } 13 | bytes = "1.10.0" 14 | prost = "0.13.5" 15 | thiserror = "2.0.11" 16 | quick-xml = "0.37.2" 17 | cached = "0.55.0" 18 | serde = "1.0.218" 19 | serde_json = "1.0.139" 20 | regex = "1.11.1" 21 | tracing = "0.1.41" 22 | tracing-subscriber = "0.3.19" 23 | rayon = "1.10.0" 24 | 25 | [build-dependencies] 26 | prost-build = "0.13.5" 27 | protox = "0.8.0" 28 | 29 | [profile.release] 30 | lto = true # Enables link to optimizations 31 | opt-level = "s" # Optimize for binary size 32 | -------------------------------------------------------------------------------- /packages/biliass/rust/build.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | fn main() -> Result<()> { 3 | let file_descriptors = protox::compile( 4 | ["proto/danmaku.proto", "proto/danmaku_view.proto"], 5 | ["proto/"], 6 | ) 7 | .expect("Failed to compile proto file"); 8 | prost_build::compile_fds(file_descriptors)?; 9 | println!("cargo:rerun-if-changed=build.rs"); 10 | println!("cargo:rerun-if-changed=proto/danmaku.proto"); 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /packages/biliass/rust/proto/danmaku_view.proto: -------------------------------------------------------------------------------- 1 | // From https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/danmaku/danmaku_view_proto.md 2 | 3 | syntax = "proto3"; 4 | 5 | package danmaku_view; 6 | 7 | //分段弹幕包信息? 8 | message DmSegConfig { 9 | int64 pageSize = 1; //分段时间? 10 | int64 total = 2; //最大分页数? 11 | } 12 | 13 | // 14 | message DanmakuFlagConfig { 15 | int32 recFlag = 1; // 16 | string recText = 2; // 17 | int32 recSwitch = 3; // 18 | } 19 | 20 | // 互动弹幕条目 21 | message CommandDm { 22 | int64 id = 1; //弹幕dmid 23 | int64 oid = 2; //视频cid 24 | int64 mid = 3; //发送者mid 25 | string command = 4; //弹幕指令 26 | string content = 5; //弹幕文字 27 | int32 progress = 6; //弹幕出现时间 28 | string ctime = 7; // 29 | string mtime = 8; // 30 | string extra = 9; //弹幕负载数据 31 | string idStr = 10; //弹幕dmid(字串形式) 32 | } 33 | 34 | //弹幕个人配置 35 | message DanmuWebPlayerConfig{ 36 | bool dmSwitch=1; //弹幕开关 37 | bool aiSwitch=2; //智能云屏蔽 38 | int32 aiLevel=3; //智能云屏蔽级别 39 | bool blocktop=4; //屏蔽类型-顶部 40 | bool blockscroll=5; //屏蔽类型-滚动 41 | bool blockbottom=6; //屏蔽类型-底部 42 | bool blockcolor=7; //屏蔽类型-彩色 43 | bool blockspecial=8; //屏蔽类型-特殊 44 | bool preventshade=9; //防挡弹幕(底部15%) 45 | bool dmask=10; //智能防挡弹幕(人像蒙版) 46 | float opacity=11; //弹幕不透明度 47 | int32 dmarea=12; //弹幕显示区域 48 | float speedplus=13; //弹幕速度 49 | float fontsize=14; //字体大小 50 | bool screensync=15; //跟随屏幕缩放比例 51 | bool speedsync=16; //根据播放倍速调整速度 52 | string fontfamily=17; //字体类型? 53 | bool bold=18; //粗体? 54 | int32 fontborder=19; //描边类型 55 | string drawType=20; //渲染类型? 56 | } 57 | 58 | message DmWebViewReply { 59 | int32 state = 1; //弹幕开放状态 60 | string text = 2; // 61 | string textSide = 3; // 62 | DmSegConfig dmSge = 4; //分段弹幕包信息? 63 | DanmakuFlagConfig flag = 5; // 64 | repeated string specialDms = 6; //BAS(代码)弹幕专包url 65 | bool checkBox = 7; // 66 | int64 count = 8; //实际弹幕总数 67 | repeated CommandDm commandDms = 9; //互动弹幕条目 68 | DanmuWebPlayerConfig dmSetting = 10; //弹幕个人配置 69 | } 70 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/comment.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Clone, PartialOrd)] 2 | pub enum CommentPosition { 3 | /// Regular moving comment 4 | Scroll, 5 | /// Bottom centered comment 6 | Bottom, 7 | /// Top centered comment 8 | Top, 9 | /// Reversed moving comment 10 | Reversed, 11 | /// Special comment 12 | Special, 13 | } 14 | 15 | #[derive(Debug, PartialEq, Clone)] 16 | pub struct NormalCommentData { 17 | /// The estimated height in pixels 18 | /// i.e. (comment.count('\n')+1)*size 19 | pub height: f32, 20 | /// The estimated width in pixels 21 | /// i.e. calculate_length(comment)*size 22 | pub width: f32, 23 | } 24 | 25 | #[derive(Debug, PartialEq, Clone)] 26 | pub struct SpecialCommentData { 27 | pub rotate_y: i64, 28 | pub rotate_z: i64, 29 | pub from_x: f64, 30 | pub from_y: f64, 31 | pub to_x: f64, 32 | pub to_y: f64, 33 | pub from_alpha: u8, 34 | pub to_alpha: u8, 35 | pub delay: i64, 36 | pub lifetime: f64, 37 | pub duration: i64, 38 | pub fontface: String, 39 | pub is_border: bool, 40 | } 41 | 42 | #[derive(Debug, PartialEq, Clone)] 43 | pub enum CommentData { 44 | Normal(NormalCommentData), 45 | Special(SpecialCommentData), 46 | } 47 | 48 | impl CommentData { 49 | pub fn as_normal(&self) -> Result<&NormalCommentData, &str> { 50 | match self { 51 | CommentData::Normal(data) => Ok(data), 52 | CommentData::Special(_) => Err("CommentData is Special"), 53 | } 54 | } 55 | 56 | pub fn as_special(&self) -> Result<&SpecialCommentData, &str> { 57 | match self { 58 | CommentData::Normal(_) => Err("CommentData is Normal"), 59 | CommentData::Special(data) => Ok(data), 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug, PartialEq, Clone)] 65 | pub struct Comment { 66 | /// The position when the comment is replayed 67 | pub timeline: f64, 68 | /// The UNIX timestamp when the comment is submitted 69 | pub timestamp: u64, 70 | /// A sequence of 1, 2, 3, ..., used for sorting 71 | pub no: u64, 72 | /// The content of the comment 73 | pub content: String, 74 | /// The comment position 75 | pub pos: CommentPosition, 76 | /// Font color represented in 0xRRGGBB, 77 | /// e.g. 0xffffff for white 78 | pub color: u32, 79 | /// Font size 80 | pub size: f32, 81 | /// The comment data 82 | pub data: CommentData, 83 | } 84 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum DecodeError { 5 | #[error("Protobuf: {0}")] 6 | Protobuf(#[from] prost::DecodeError), 7 | #[error("Xml: {0}")] 8 | Xml(#[from] quick_xml::Error), 9 | #[error("SpecialComment: {0}")] 10 | SpecialComment(#[from] serde_json::Error), 11 | } 12 | 13 | #[derive(Error, Debug)] 14 | pub enum ParseError { 15 | #[error("Xml: {0}")] 16 | Xml(String), 17 | #[error("Protobuf")] 18 | Protobuf(), 19 | #[error("SpecialComment: {0}")] 20 | SpecialComment(String), 21 | } 22 | 23 | #[allow(clippy::enum_variant_names)] 24 | #[derive(Error, Debug)] 25 | pub enum BiliassError { 26 | #[error("ParseError: {0}")] 27 | ParseError(#[from] ParseError), 28 | #[error("DecodeError: {0}")] 29 | DecodeError(#[from] DecodeError), 30 | #[error("InvalidRegexError: {0}")] 31 | InvalidRegexError(#[from] regex::Error), 32 | } 33 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/filter.rs: -------------------------------------------------------------------------------- 1 | use crate::comment::CommentPosition; 2 | use regex::Regex; 3 | 4 | #[derive(Default, Clone)] 5 | pub struct BlockOptions { 6 | pub block_top: bool, 7 | pub block_bottom: bool, 8 | pub block_scroll: bool, 9 | pub block_reverse: bool, 10 | pub block_special: bool, 11 | pub block_colorful: bool, 12 | pub block_keyword_patterns: Vec, 13 | } 14 | 15 | pub fn should_skip_parse(pos: &CommentPosition, block_options: &BlockOptions) -> bool { 16 | matches!(pos, CommentPosition::Top) && block_options.block_top 17 | || matches!(pos, CommentPosition::Bottom) && block_options.block_bottom 18 | || matches!(pos, CommentPosition::Scroll) && block_options.block_scroll 19 | || matches!(pos, CommentPosition::Special) && block_options.block_reverse 20 | } 21 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod comment; 2 | mod convert; 3 | mod error; 4 | mod filter; 5 | mod logging; 6 | mod proto; 7 | mod python; 8 | mod reader; 9 | mod writer; 10 | 11 | use error::BiliassError; 12 | use pyo3::exceptions::PyValueError; 13 | use pyo3::prelude::*; 14 | 15 | impl std::convert::From for PyErr { 16 | fn from(err: BiliassError) -> PyErr { 17 | PyValueError::new_err(err.to_string()) 18 | } 19 | } 20 | 21 | /// Bindings for biliass core. 22 | #[pymodule(gil_used = false)] 23 | #[pyo3(name = "_core")] 24 | fn biliass_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { 25 | m.add_function(wrap_pyfunction!(python::py_get_danmaku_meta_size, m)?)?; 26 | m.add_function(wrap_pyfunction!(python::py_xml_to_ass, m)?)?; 27 | m.add_function(wrap_pyfunction!(python::py_protobuf_to_ass, m)?)?; 28 | m.add_function(wrap_pyfunction!(python::py_enable_tracing, m)?)?; 29 | m.add_class::()?; 30 | m.add_class::()?; 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/logging.rs: -------------------------------------------------------------------------------- 1 | use tracing::Level; 2 | 3 | pub fn enable_tracing() { 4 | let collector = tracing_subscriber::fmt() 5 | .with_max_level(Level::TRACE) 6 | .finish(); 7 | 8 | tracing::subscriber::set_global_default(collector).expect("setting tracing default failed"); 9 | } 10 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/proto/danmaku.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::all)] 2 | include!(concat!(env!("OUT_DIR"), "/danmaku.rs")); 3 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/proto/danmaku_view.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::all)] 2 | include!(concat!(env!("OUT_DIR"), "/danmaku_view.rs")); 3 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/proto/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod danmaku; 2 | pub mod danmaku_view; 3 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/python/logging.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | 3 | #[pyfunction(name = "enable_tracing")] 4 | pub fn py_enable_tracing() { 5 | crate::logging::enable_tracing(); 6 | } 7 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/python/mod.rs: -------------------------------------------------------------------------------- 1 | mod convert; 2 | mod logging; 3 | mod proto; 4 | pub use convert::{PyBlockOptions, PyConversionOptions, py_protobuf_to_ass, py_xml_to_ass}; 5 | pub use logging::py_enable_tracing; 6 | pub use proto::py_get_danmaku_meta_size; 7 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/python/proto.rs: -------------------------------------------------------------------------------- 1 | use crate::error; 2 | use crate::proto; 3 | use prost::Message; 4 | use pyo3::prelude::*; 5 | use std::io::Cursor; 6 | 7 | #[pyfunction(name = "get_danmaku_meta_size")] 8 | pub fn py_get_danmaku_meta_size(buffer: &[u8]) -> PyResult { 9 | let dm_sge_opt = proto::danmaku_view::DmWebViewReply::decode(&mut Cursor::new(buffer)) 10 | .map(|reply| reply.dm_sge) 11 | .map_err(error::DecodeError::from) 12 | .map_err(error::BiliassError::from)?; 13 | 14 | Ok(dm_sge_opt.map_or(0, |dm_sge| dm_sge.total as usize)) 15 | } 16 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/reader/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod protobuf; 2 | pub mod special; 3 | mod utils; 4 | pub mod xml; 5 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/reader/protobuf.rs: -------------------------------------------------------------------------------- 1 | use crate::comment::{Comment, CommentData, CommentPosition, NormalCommentData}; 2 | use crate::error::{BiliassError, DecodeError}; 3 | use crate::filter::{BlockOptions, should_skip_parse}; 4 | use crate::proto::danmaku::DmSegMobileReply; 5 | use crate::reader::{special, utils}; 6 | use prost::Message; 7 | use std::io::Cursor; 8 | use tracing::warn; 9 | 10 | pub fn read_comments_from_protobuf( 11 | data: T, 12 | fontsize: f32, 13 | zoom_factor: (f32, f32, f32), 14 | block_options: &BlockOptions, 15 | ) -> Result, BiliassError> 16 | where 17 | T: AsRef<[u8]>, 18 | { 19 | let replies = DmSegMobileReply::decode(&mut Cursor::new(data)) 20 | .map_err(DecodeError::from) 21 | .map_err(BiliassError::from)?; 22 | let mut comments = Vec::new(); 23 | for (i, elem) in replies.elems.into_iter().enumerate() { 24 | match elem.mode { 25 | 1 | 4 | 5 | 6 | 7 => { 26 | let timeline = elem.progress as f64 / 1000.0; 27 | let timestamp = elem.ctime as u64; 28 | let comment_pos = match elem.mode { 29 | 1 => CommentPosition::Scroll, 30 | 4 => CommentPosition::Top, 31 | 5 => CommentPosition::Bottom, 32 | 6 => CommentPosition::Reversed, 33 | 7 => CommentPosition::Special, 34 | _ => unreachable!("Impossible danmaku type"), 35 | }; 36 | if should_skip_parse(&comment_pos, block_options) { 37 | continue; 38 | } 39 | let color = elem.color; 40 | let size = elem.fontsize; 41 | let (comment_content, size, comment_data) = 42 | if comment_pos != CommentPosition::Special { 43 | let comment_content = 44 | utils::unescape_newline(&utils::filter_bad_chars(&elem.content)); 45 | let size = (size as f32) * fontsize / 25.0; 46 | let height = 47 | (comment_content.chars().filter(|&c| c == '\n').count() as f32 + 1.0) 48 | * size; 49 | let width = utils::calculate_length(&comment_content) * size; 50 | ( 51 | comment_content, 52 | size, 53 | CommentData::Normal(NormalCommentData { height, width }), 54 | ) 55 | } else { 56 | let parsed_data = special::parse_special_comment( 57 | &utils::filter_bad_chars(&elem.content), 58 | zoom_factor, 59 | ); 60 | if parsed_data.is_err() { 61 | warn!("Failed to parse special comment: {:?}", parsed_data); 62 | continue; 63 | } 64 | let (content, special_comment_data) = parsed_data.unwrap(); 65 | ( 66 | content, 67 | size as f32, 68 | CommentData::Special(special_comment_data), 69 | ) 70 | }; 71 | comments.push(Comment { 72 | timeline, 73 | timestamp, 74 | no: i as u64, 75 | content: comment_content, 76 | pos: comment_pos, 77 | color, 78 | size, 79 | data: comment_data, 80 | }) 81 | } 82 | 8 => { 83 | // ignore scripted comment 84 | } 85 | _ => { 86 | eprintln!("Unknown danmaku type: {}", elem.mode); 87 | } 88 | } 89 | } 90 | Ok(comments) 91 | } 92 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/reader/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn filter_bad_chars(string: &str) -> String { 2 | string 3 | .chars() 4 | .map(|c| { 5 | if ('\u{00}'..='\u{08}').contains(&c) 6 | || c == '\u{0b}' 7 | || c == '\u{0c}' 8 | || c == '\u{2028}' 9 | || c == '\u{2029}' 10 | || ('\u{0e}'..='\u{1f}').contains(&c) 11 | { 12 | '\u{fffd}' 13 | } else { 14 | c 15 | } 16 | }) 17 | .collect() 18 | } 19 | 20 | pub fn calculate_length(s: &str) -> f32 { 21 | s.split('\n') 22 | .map(|line| line.chars().count()) 23 | .max() 24 | .unwrap_or(0) as f32 25 | } 26 | 27 | pub fn unescape_newline(s: &str) -> String { 28 | s.replace("/n", "\n") 29 | } 30 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/writer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod ass; 2 | pub mod rows; 3 | pub mod utils; 4 | -------------------------------------------------------------------------------- /packages/biliass/rust/src/writer/rows.rs: -------------------------------------------------------------------------------- 1 | use crate::comment::{Comment, CommentPosition}; 2 | 3 | pub type Rows<'a> = Vec>>; 4 | 5 | pub fn init_rows<'a>(num_types: usize, capacity: usize) -> Rows<'a> { 6 | let mut rows: Rows = Vec::new(); 7 | for _ in 0..num_types { 8 | let mut type_rows = Vec::with_capacity(capacity); 9 | for _ in 0..capacity { 10 | type_rows.push(None); 11 | } 12 | rows.push(type_rows); 13 | } 14 | rows 15 | } 16 | 17 | #[allow(clippy::too_many_arguments)] 18 | pub fn test_free_rows( 19 | rows: &Rows, 20 | comment: &Comment, 21 | row: usize, 22 | width: u32, 23 | height: u32, 24 | bottom_reserved: u32, 25 | duration_marquee: f64, 26 | duration_still: f64, 27 | ) -> usize { 28 | let mut res = 0; 29 | let rowmax = (height - bottom_reserved) as usize; 30 | let mut target_row = None; 31 | let comment_pos_id = comment.pos.clone() as usize; 32 | let comment_data = comment 33 | .data 34 | .as_normal() 35 | .expect("comment_data is not normal"); 36 | if comment.pos == CommentPosition::Bottom || comment.pos == CommentPosition::Top { 37 | let mut current_row = row; 38 | while current_row < rowmax && (res as f32) < comment_data.height { 39 | if target_row != rows[comment_pos_id][current_row] { 40 | target_row = rows[comment_pos_id][current_row]; 41 | if let Some(target_row) = target_row { 42 | if target_row.timeline + duration_still > comment.timeline { 43 | break; 44 | } 45 | } 46 | } 47 | current_row += 1; 48 | res += 1; 49 | } 50 | } else { 51 | let threshold_time: f64 = comment.timeline 52 | - duration_marquee * (1.0 - width as f64 / (comment_data.width as f64 + width as f64)); 53 | let mut current_row = row; 54 | while current_row < rowmax && (res as f32) < comment_data.height { 55 | if target_row != rows[comment_pos_id][current_row] { 56 | target_row = rows[comment_pos_id][current_row]; 57 | if let Some(target_row) = target_row { 58 | let target_row_data = target_row 59 | .data 60 | .as_normal() 61 | .expect("target_row_data is not normal"); 62 | if target_row.timeline > threshold_time 63 | || target_row.timeline 64 | + target_row_data.width as f64 * duration_marquee 65 | / (target_row_data.width as f64 + width as f64) 66 | > comment.timeline 67 | { 68 | break; 69 | } 70 | } 71 | } 72 | current_row += 1; 73 | res += 1; 74 | } 75 | } 76 | res 77 | } 78 | 79 | pub fn find_alternative_row( 80 | rows: &Rows, 81 | comment: &Comment, 82 | height: u32, 83 | bottom_reserved: u32, 84 | ) -> usize { 85 | let mut res = 0; 86 | let comment_pos_id = comment.pos.clone() as usize; 87 | let comment_data = comment 88 | .data 89 | .as_normal() 90 | .expect("comment_data is not normal"); 91 | for row in 0..(height as usize - bottom_reserved as usize - comment_data.height.ceil() as usize) 92 | { 93 | match &rows[comment_pos_id][row] { 94 | None => return row, 95 | Some(comment) => { 96 | let comment_res = &rows[comment_pos_id][res].as_ref().expect("res is None"); 97 | if comment.timeline < comment_res.timeline { 98 | res = row; 99 | } 100 | } 101 | } 102 | } 103 | res 104 | } 105 | 106 | pub fn mark_comment_row<'a>(rows: &mut Rows<'a>, comment: &'a Comment, row: usize) { 107 | let comment_pos_id = comment.pos.clone() as usize; 108 | let comment_data = comment 109 | .data 110 | .as_normal() 111 | .expect("comment_data is not normal"); 112 | for i in row..(row + comment_data.height.ceil() as usize) { 113 | if i >= rows[comment_pos_id].len() { 114 | break; 115 | } 116 | rows[comment_pos_id][i] = Some(comment); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /packages/biliass/src/biliass/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from biliass._core import ( 4 | enable_tracing as enable_tracing, 5 | get_danmaku_meta_size as get_danmaku_meta_size, 6 | ) 7 | 8 | from .biliass import ( 9 | BlockOptions as BlockOptions, 10 | convert_to_ass as convert_to_ass, 11 | ) 12 | -------------------------------------------------------------------------------- /packages/biliass/src/biliass/__version__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | VERSION = "2.2.2" 4 | -------------------------------------------------------------------------------- /packages/biliass/src/biliass/_core.pyi: -------------------------------------------------------------------------------- 1 | class ConversionOptions: 2 | def __init__( 3 | self, 4 | stage_width: int, 5 | stage_height: int, 6 | display_region_ratio: float, 7 | font_face: str, 8 | font_size: float, 9 | text_opacity: float, 10 | duration_marquee: float, 11 | duration_still: float, 12 | is_reduce_comments: bool, 13 | ) -> None: ... 14 | 15 | class BlockOptions: 16 | def __init__( 17 | self, 18 | block_top: bool, 19 | block_bottom: bool, 20 | block_scroll: bool, 21 | block_reverse: bool, 22 | block_special: bool, 23 | block_colorful: bool, 24 | block_keyword_patterns: list[str], 25 | ) -> None: ... 26 | @staticmethod 27 | def default() -> BlockOptions: ... 28 | 29 | def xml_to_ass( 30 | inputs: list[str], 31 | conversion_options: ConversionOptions, 32 | block_options: BlockOptions, 33 | ) -> str: ... 34 | def protobuf_to_ass( 35 | inputs: list[bytes], 36 | conversion_options: ConversionOptions, 37 | block_options: BlockOptions, 38 | ) -> str: ... 39 | def get_danmaku_meta_size(buffer: bytes) -> int: ... 40 | def enable_tracing() -> None: ... 41 | -------------------------------------------------------------------------------- /packages/biliass/src/biliass/biliass.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypeVar, cast 4 | 5 | from biliass._core import ( 6 | BlockOptions, 7 | ConversionOptions, 8 | protobuf_to_ass, 9 | xml_to_ass, 10 | ) 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Sequence 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | def convert_to_ass( 19 | inputs: Sequence[str | bytes] | str | bytes, 20 | stage_width: int, 21 | stage_height: int, 22 | input_format: str = "xml", 23 | display_region_ratio: float = 1.0, 24 | font_face: str = "sans-serif", 25 | font_size: float = 25.0, 26 | text_opacity: float = 1.0, 27 | duration_marquee: float = 5.0, 28 | duration_still: float = 5.0, 29 | block_options: BlockOptions | None = None, 30 | reduce_comments: bool = True, 31 | ) -> str: 32 | if isinstance(inputs, (str, bytes)): 33 | inputs = [inputs] 34 | conversion_options = ConversionOptions( 35 | stage_width, 36 | stage_height, 37 | display_region_ratio, 38 | font_face, 39 | font_size, 40 | text_opacity, 41 | duration_marquee, 42 | duration_still, 43 | reduce_comments, 44 | ) 45 | block_options = block_options or BlockOptions.default() 46 | 47 | if input_format == "xml": 48 | inputs = [text if isinstance(text, str) else text.decode() for text in inputs] 49 | return xml_to_ass( 50 | inputs, 51 | conversion_options, 52 | block_options, 53 | ) 54 | elif input_format == "protobuf": 55 | for input in inputs: 56 | if isinstance(input, str): 57 | raise ValueError("Protobuf can only be read from bytes") 58 | return protobuf_to_ass( 59 | cast("list[bytes]", inputs), 60 | conversion_options, 61 | block_options, 62 | ) 63 | else: 64 | raise TypeError(f"Invalid input format {input_format}") 65 | -------------------------------------------------------------------------------- /packages/biliass/src/biliass/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/packages/biliass/src/biliass/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "yutto" 3 | version = "2.0.3" 4 | description = "🧊 一个可爱且任性的 B 站视频下载器" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | authors = [{ name = "Nyakku Shigure", email = "sigure.qaq@gmail.com" }] 8 | keywords = ["python", "bilibili", "video", "downloader", "danmaku"] 9 | license = { text = "GPL-3.0" } 10 | classifiers = [ 11 | "Environment :: Console", 12 | "Operating System :: OS Independent", 13 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 14 | "Typing :: Typed", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | ] 23 | dependencies = [ 24 | "aiofiles>=24.1.0", 25 | "biliass==2.2.2", 26 | "colorama>=0.4.6; sys_platform == 'win32'", 27 | "typing-extensions>=4.13.2", 28 | "dict2xml>=1.7.6", 29 | "httpx[http2,socks]>=0.28.1", 30 | "tomli>=2.0.2; python_version < '3.11'", 31 | "pydantic>=2.11.4", 32 | "returns>=0.25.0", 33 | ] 34 | optional-dependencies.mcp = ["fastmcp>=2.2.6"] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/yutto-dev/yutto" 38 | Documentation = "https://github.com/yutto-dev/yutto" 39 | Repository = "https://github.com/yutto-dev/yutto" 40 | Issues = "https://github.com/yutto-dev/yutto/issues" 41 | 42 | [project.scripts] 43 | yutto = "yutto.__main__:main" 44 | 45 | [dependency-groups] 46 | dev = [ 47 | "pyright>=1.1.400", 48 | "ruff>=0.11.8", 49 | "typos>=1.31.2", 50 | "pytest>=8.3.5", 51 | "pytest-rerunfailures>=15.0", 52 | "syrupy>=4.9.0", 53 | "pytest-codspeed>=3.2.0", 54 | ] 55 | 56 | [tool.uv.sources] 57 | biliass = { workspace = true } 58 | 59 | [tool.uv.workspace] 60 | members = ["packages/*"] 61 | 62 | [tool.pytest.ini_options] 63 | markers = ["api", "e2e", "processor", "biliass", "ignore", "ci_skip", "ci_only"] 64 | 65 | [tool.pyright] 66 | include = ["src/yutto", "packages/biliass/src/biliass", "tests"] 67 | pythonVersion = "3.10" 68 | typeCheckingMode = "strict" 69 | 70 | [tool.ruff] 71 | line-length = 120 72 | target-version = "py310" 73 | 74 | [tool.ruff.lint] 75 | select = [ 76 | # Pyflakes 77 | "F", 78 | # Pycodestyle 79 | "E", 80 | "W", 81 | # Isort 82 | "I", 83 | # Comprehensions 84 | "C4", 85 | # Debugger 86 | "T100", 87 | # Pyupgrade 88 | "UP", 89 | # Flake8-pyi 90 | "PYI", 91 | # Bugbear 92 | "B", 93 | # Pylint 94 | "PLE", 95 | # Flake8-simplify 96 | "SIM101", 97 | # Flake8-use-pathlib 98 | "PTH", 99 | # Pygrep-hooks 100 | "PGH004", 101 | # Flake8-type-checking 102 | "TC", 103 | # Flake8-raise 104 | "RSE", 105 | # Refurb 106 | "FURB", 107 | # Flake8-future-annotations 108 | "FA", 109 | # Yesqa 110 | "RUF100", 111 | ] 112 | ignore = [ 113 | "E501", # line too long, duplicate with ruff fmt 114 | "F401", # imported but unused, duplicate with pyright 115 | "F841", # local variable is assigned to but never used, duplicate with pyright 116 | "UP038", # It will cause the performance regression on python3.10 117 | ] 118 | 119 | [tool.ruff.lint.isort] 120 | required-imports = ["from __future__ import annotations"] 121 | known-first-party = ["yutto"] 122 | combine-as-imports = true 123 | 124 | [tool.ruff.lint.flake8-type-checking] 125 | runtime-evaluated-base-classes = ["pydantic.BaseModel"] 126 | 127 | [tool.ruff.lint.per-file-ignores] 128 | "setup.py" = ["I"] 129 | 130 | [build-system] 131 | requires = ["hatchling"] 132 | build-backend = "hatchling.build" 133 | -------------------------------------------------------------------------------- /scripts/generate-schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from yutto.cli.settings import YuttoSettings 7 | 8 | 9 | def main(): 10 | schema = YuttoSettings.model_json_schema() 11 | with Path("schemas/config.json").open("w") as f: 12 | json.dump(schema, f, indent=2) 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /scripts/get-version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import ast 5 | from pathlib import Path 6 | 7 | 8 | class VersionAnalyzer(ast.NodeVisitor): 9 | def __init__(self, literal_name: str): 10 | self.literal_name = literal_name 11 | self.version = None 12 | 13 | def visit_Assign(self, node: ast.Assign): 14 | if isinstance(node.targets[0], ast.Name) and node.targets[0].id == self.literal_name: 15 | if self.version is not None: 16 | raise ValueError(f"Multiply version assignment for {self.literal_name} found") 17 | if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): 18 | self.version = node.value.value 19 | 20 | 21 | def cli() -> argparse.Namespace: 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument("file", type=str, help="The file to read") 24 | parser.add_argument("--literal-name", type=str, default="VERSION", help="The literal name to search") 25 | return parser.parse_args() 26 | 27 | 28 | def main(): 29 | args = cli() 30 | with Path(args.file).open("r", encoding="utf-8") as f: 31 | code = f.read() 32 | tree = ast.parse(code) 33 | analyzer = VersionAnalyzer(args.literal_name) 34 | analyzer.visit(tree) 35 | version = analyzer.version 36 | if version is None: 37 | raise ValueError(f"Version not found for {args.literal_name}") 38 | print(version) 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /src/yutto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/__init__.py -------------------------------------------------------------------------------- /src/yutto/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import copy 5 | import os 6 | import re 7 | import shlex 8 | import sys 9 | from typing import TYPE_CHECKING 10 | 11 | from yutto.cli.cli import cli, handle_default_subcommand 12 | from yutto.download_manager import DownloadManager, DownloadTask 13 | from yutto.exceptions import ErrorCode 14 | from yutto.parser import file_scheme_parser 15 | from yutto.utils.console.logger import Badge, Logger 16 | from yutto.utils.fetcher import FetcherContext 17 | from yutto.utils.funcutils import as_sync 18 | from yutto.validator import ( 19 | initial_validation, 20 | validate_basic_arguments, 21 | ) 22 | 23 | if TYPE_CHECKING: 24 | import argparse 25 | 26 | 27 | def main(): 28 | parser = cli() 29 | args = parser.parse_args(handle_default_subcommand(sys.argv[1:])) 30 | match args.command: 31 | case "download": 32 | ctx = FetcherContext() 33 | initial_validation(ctx, args) 34 | args_list = flatten_args(args, parser) 35 | try: 36 | run_download(ctx, args_list) 37 | except (SystemExit, KeyboardInterrupt, asyncio.exceptions.CancelledError): 38 | Logger.info("已终止下载,再次运行即可继续下载~") 39 | sys.exit(ErrorCode.PAUSED_DOWNLOAD.value) 40 | case "mcp": 41 | from yutto.mcp import run_mcp 42 | 43 | run_mcp() 44 | 45 | case _: 46 | raise ValueError("Invalid command") 47 | 48 | 49 | @as_sync 50 | async def run_download(ctx: FetcherContext, args_list: list[argparse.Namespace]): 51 | manager = DownloadManager() 52 | manager.start(ctx) 53 | if len(args_list) > 1: 54 | Logger.info(f"列表里共检测到 {len(args_list)} 项") 55 | 56 | for i, args in enumerate(args_list): 57 | if len(args_list) > 1: 58 | Logger.custom(f"列表项 {args.url}", Badge(f"[{i + 1}/{len(args_list)}]", fore="black", back="cyan")) 59 | await manager.add_task(DownloadTask(args=args)) 60 | await manager.add_stop_task() 61 | await manager.wait_for_completion() 62 | 63 | 64 | def flatten_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> list[argparse.Namespace]: 65 | """递归展平列表参数""" 66 | args = copy.copy(args) 67 | validate_basic_arguments(args) 68 | # 查看是否存在于 alias 中 69 | alias_map: dict[str, str] = args.aliases if args.aliases is not None else {} 70 | if args.url in alias_map: 71 | args.url = alias_map[args.url] 72 | 73 | # 是否为下载列表 74 | if re.match(r"file://", args.url) or os.path.isfile(args.url): # noqa: PTH113 75 | args_list: list[argparse.Namespace] = [] 76 | # TODO: 如果是相对路径,需要相对于当前 list 路径 77 | for line in file_scheme_parser(args.url): 78 | local_args = parser.parse_args(shlex.split(line), args) 79 | if local_args.no_inherit: 80 | local_args = parser.parse_args(shlex.split(line)) 81 | Logger.debug(f"列表参数: {local_args}") 82 | args_list += flatten_args(local_args, parser) 83 | return args_list 84 | else: 85 | return [args] 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /src/yutto/__version__.py: -------------------------------------------------------------------------------- 1 | # 发版需要同时改这里和 pyproject.toml 2 | from __future__ import annotations 3 | 4 | VERSION = "2.0.3" 5 | -------------------------------------------------------------------------------- /src/yutto/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/api/__init__.py -------------------------------------------------------------------------------- /src/yutto/api/collection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import math 5 | from typing import TYPE_CHECKING, TypedDict 6 | 7 | from yutto._typing import AvId, BvId, MId, SeriesId 8 | from yutto.utils.fetcher import Fetcher, FetcherContext 9 | 10 | if TYPE_CHECKING: 11 | from httpx import AsyncClient 12 | 13 | 14 | class CollectionDetailsItem(TypedDict): 15 | id: int 16 | title: str 17 | avid: AvId 18 | 19 | 20 | class CollectionDetails(TypedDict): 21 | title: str 22 | pages: list[CollectionDetailsItem] 23 | 24 | 25 | async def get_collection_details( 26 | ctx: FetcherContext, client: AsyncClient, series_id: SeriesId, mid: MId 27 | ) -> CollectionDetails: 28 | title, avids = await asyncio.gather( 29 | _get_collection_title(ctx, client, series_id), 30 | _get_collection_avids(ctx, client, series_id, mid), 31 | ) 32 | return CollectionDetails( 33 | title=title, 34 | pages=[ 35 | CollectionDetailsItem( 36 | id=i + 1, 37 | title="", # TODO: 这里应该是合集内的标题,但目前没找到相关的 API 38 | avid=avid, 39 | ) 40 | for i, avid in enumerate(avids) 41 | ], 42 | ) 43 | 44 | 45 | async def _get_collection_avids(ctx: FetcherContext, client: AsyncClient, series_id: SeriesId, mid: MId) -> list[AvId]: 46 | api = "https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?mid={mid}&season_id={series_id}&sort_reverse=false&page_num={pn}&page_size={ps}" 47 | ps = 30 48 | pn = 1 49 | total = 1 50 | all_avid: list[AvId] = [] 51 | 52 | while pn <= total: 53 | space_videos_url = api.format(series_id=series_id, ps=ps, pn=pn, mid=mid) 54 | json_data = await Fetcher.fetch_json(ctx, client, space_videos_url) 55 | assert json_data is not None 56 | total = math.ceil(json_data["data"]["page"]["total"] / ps) 57 | pn += 1 58 | all_avid += [BvId(archives["bvid"]) for archives in json_data["data"]["archives"]] 59 | return all_avid 60 | 61 | 62 | async def _get_collection_title(ctx: FetcherContext, client: AsyncClient, series_id: SeriesId) -> str: 63 | api = "https://api.bilibili.com/x/v1/medialist/info?type=8&biz_id={series_id}" 64 | json_data = await Fetcher.fetch_json(ctx, client, api.format(series_id=series_id)) 65 | assert json_data is not None 66 | return json_data["data"]["title"] 67 | -------------------------------------------------------------------------------- /src/yutto/api/danmaku.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING 5 | 6 | from biliass import get_danmaku_meta_size 7 | 8 | from yutto.api.user_info import get_user_info 9 | from yutto.utils.fetcher import Fetcher, FetcherContext 10 | 11 | if TYPE_CHECKING: 12 | import httpx 13 | 14 | from yutto._typing import AvId, CId 15 | from yutto.utils.danmaku import DanmakuData, DanmakuSaveType 16 | 17 | 18 | async def get_xml_danmaku(ctx: FetcherContext, client: httpx.AsyncClient, cid: CId) -> str: 19 | danmaku_api = "http://comment.bilibili.com/{cid}.xml" 20 | results = await Fetcher.fetch_text(ctx, client, danmaku_api.format(cid=cid), encoding="utf-8") 21 | assert results is not None 22 | return results 23 | 24 | 25 | async def get_protobuf_danmaku_segment( 26 | ctx: FetcherContext, client: httpx.AsyncClient, cid: CId, segment_id: int = 1 27 | ) -> bytes: 28 | danmaku_api = "http://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid={cid}&segment_index={segment_id}" 29 | results = await Fetcher.fetch_bin(ctx, client, danmaku_api.format(cid=cid, segment_id=segment_id)) 30 | assert results is not None 31 | return results 32 | 33 | 34 | async def get_protobuf_danmaku(ctx: FetcherContext, client: httpx.AsyncClient, avid: AvId, cid: CId) -> list[bytes]: 35 | danmaku_meta_api = "https://api.bilibili.com/x/v2/dm/web/view?type=1&oid={cid}&pid={aid}" 36 | aid = avid.as_aid() 37 | meta_results = await Fetcher.fetch_bin(ctx, client, danmaku_meta_api.format(cid=cid, aid=aid.value)) 38 | assert meta_results is not None 39 | size = get_danmaku_meta_size(meta_results) 40 | 41 | results = await asyncio.gather( 42 | *[get_protobuf_danmaku_segment(ctx, client, cid, segment_id) for segment_id in range(1, size + 1)] 43 | ) 44 | return results 45 | 46 | 47 | async def get_danmaku( 48 | ctx: FetcherContext, 49 | client: httpx.AsyncClient, 50 | cid: CId, 51 | avid: AvId, 52 | save_type: DanmakuSaveType, 53 | ) -> DanmakuData: 54 | # 在已经登录的情况下,使用 protobuf,因为未登录时 protobuf 弹幕会少非常多 55 | source_type = "xml" if save_type == "xml" or not (await get_user_info(ctx, client))["is_login"] else "protobuf" 56 | danmaku_data: DanmakuData = { 57 | "source_type": source_type, 58 | "save_type": save_type, 59 | "data": [], 60 | } 61 | 62 | if source_type == "xml": 63 | danmaku_data["data"].append(await get_xml_danmaku(ctx, client, cid)) 64 | else: 65 | danmaku_data["data"].extend(await get_protobuf_danmaku(ctx, client, avid, cid)) 66 | return danmaku_data 67 | -------------------------------------------------------------------------------- /src/yutto/api/user_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hashlib 5 | import random 6 | import re 7 | import string 8 | import time 9 | import urllib.parse 10 | from typing import TYPE_CHECKING, Any, TypedDict 11 | 12 | from yutto._typing import UserInfo 13 | from yutto.utils.asynclib import async_cache 14 | from yutto.utils.fetcher import Fetcher, FetcherContext 15 | 16 | if TYPE_CHECKING: 17 | from httpx import AsyncClient 18 | 19 | 20 | class WbiImg(TypedDict): 21 | img_key: str 22 | sub_key: str 23 | 24 | 25 | wbi_img_cache: WbiImg | None = None # Simulate the LocalStorage of the browser 26 | dm_img_str_cache: str = base64.b64encode("".join(random.choices(string.printable, k=random.randint(16, 64))).encode())[:-2].decode() # fmt: skip 27 | dm_cover_img_str_cache: str = base64.b64encode("".join(random.choices(string.printable, k=random.randint(32, 128))).encode())[:-2].decode() # fmt: skip 28 | 29 | 30 | @async_cache(lambda _: "user_info") 31 | async def get_user_info(ctx: FetcherContext, client: AsyncClient) -> UserInfo: 32 | info_api = "https://api.bilibili.com/x/web-interface/nav" 33 | res_json = await Fetcher.fetch_json(ctx, client, info_api) 34 | assert res_json is not None 35 | res_json_data = res_json.get("data") 36 | return UserInfo( 37 | vip_status=res_json_data.get("vipStatus") == 1, # API 返回的是 int,如果未登录就没这个值 38 | is_login=res_json_data.get("isLogin"), # API 返回的是 bool 39 | ) 40 | 41 | 42 | async def get_wbi_img(ctx: FetcherContext, client: AsyncClient) -> WbiImg: 43 | global wbi_img_cache 44 | if wbi_img_cache is not None: 45 | return wbi_img_cache 46 | url = "https://api.bilibili.com/x/web-interface/nav" 47 | res_json = await Fetcher.fetch_json(ctx, client, url) 48 | assert res_json is not None 49 | wbi_img: WbiImg = { 50 | "img_key": _get_key_from_url(res_json["data"]["wbi_img"]["img_url"]), 51 | "sub_key": _get_key_from_url(res_json["data"]["wbi_img"]["sub_url"]), 52 | } 53 | wbi_img_cache = wbi_img 54 | return wbi_img 55 | 56 | 57 | def _get_key_from_url(url: str) -> str: 58 | return url.split("/")[-1].split(".")[0] 59 | 60 | 61 | def _get_mixin_key(string: str) -> str: 62 | char_indices = [ 63 | 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 64 | 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 65 | 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 66 | 62, 11, 36, 20, 34, 44, 52, 67 | ] # fmt: skip 68 | return "".join([string[idx] for idx in char_indices[:32]]) 69 | 70 | 71 | def encode_wbi(params: dict[str, Any], wbi_img: WbiImg): 72 | img_key = wbi_img["img_key"] 73 | sub_key = wbi_img["sub_key"] 74 | illegal_char_remover = re.compile(r"[!'\(\)*]") 75 | 76 | mixin_key = _get_mixin_key(img_key + sub_key) 77 | time_stamp = int(time.time()) 78 | params_with_wts = dict(params, wts=time_stamp) 79 | params_with_dm = { 80 | **params_with_wts, 81 | "dm_img_list": "[]", 82 | "dm_img_str": dm_img_str_cache, 83 | "dm_cover_img_str": dm_cover_img_str_cache, 84 | } 85 | url_encoded_params = urllib.parse.urlencode( 86 | { 87 | key: illegal_char_remover.sub("", str(params_with_dm[key])) 88 | for key in sorted(params_with_dm.keys()) 89 | } 90 | ) # fmt: skip 91 | w_rid = hashlib.md5((url_encoded_params + mixin_key).encode()).hexdigest() 92 | all_params = dict(params_with_dm, w_rid=w_rid) 93 | return all_params 94 | -------------------------------------------------------------------------------- /src/yutto/bilibili_typing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/bilibili_typing/__init__.py -------------------------------------------------------------------------------- /src/yutto/bilibili_typing/codec.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | from yutto.utils.priority import gen_priority_sequence 6 | 7 | VideoCodecId = Literal[7, 12, 13] 8 | VideoCodec = Literal["avc", "hevc", "av1"] 9 | AudioCodecId = Literal[0] 10 | AudioCodec = Literal["mp4a", "flac", "eac3"] 11 | 12 | video_codec_priority_default: list[VideoCodec] = ["avc", "hevc", "av1"] 13 | audio_codec_priority_default: list[AudioCodec] = ["mp4a", "flac", "eac3"] 14 | 15 | video_codec_map: dict[VideoCodecId, VideoCodec] = { 16 | 7: "avc", 17 | 12: "hevc", 18 | 13: "av1", # Example: BV1w34y1q7HY 19 | } 20 | 21 | audio_codec_map: dict[AudioCodecId, AudioCodec] = { 22 | 0: "mp4a", 23 | } 24 | 25 | 26 | def gen_vcodec_priority(video_codec: VideoCodec) -> list[VideoCodec]: 27 | """生成视频编码优先级序列""" 28 | 29 | choice = video_codec_priority_default.index(video_codec) 30 | return [ 31 | video_codec_priority_default[idx] for idx in gen_priority_sequence(choice, len(video_codec_priority_default)) 32 | ] 33 | 34 | 35 | def gen_acodec_priority(audio_codec: AudioCodec) -> list[AudioCodec]: 36 | """生成音频编码优先级序列""" 37 | 38 | choice = audio_codec_priority_default.index(audio_codec) 39 | return [ 40 | audio_codec_priority_default[idx] for idx in gen_priority_sequence(choice, len(audio_codec_priority_default)) 41 | ] 42 | -------------------------------------------------------------------------------- /src/yutto/bilibili_typing/quality.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import Literal 5 | 6 | from yutto.utils.priority import gen_priority_sequence 7 | 8 | 9 | class Media(Enum): 10 | VIDEO = 0 11 | AUDIO = 30200 12 | 13 | 14 | VideoQuality = Literal[127, 126, 125, 120, 116, 112, 100, 80, 74, 64, 32, 16] 15 | AudioQuality = Literal[30251, 30255, 30250, 30280, 30232, 30216] 16 | 17 | video_quality_priority_default: list[VideoQuality] = [127, 126, 125, 120, 116, 112, 100, 80, 74, 64, 32, 16] 18 | audio_quality_priority_default: list[AudioQuality] = [30251, 30255, 30250, 30280, 30232, 30216] 19 | 20 | video_quality_map = { 21 | 127: { 22 | "description": "8K 超高清", 23 | "width": 7680, 24 | "height": 4320, 25 | }, # Example: BV1KS4y197BN 26 | 126: { 27 | "description": "杜比视界", 28 | "width": 3840, 29 | "height": 2160, 30 | }, # Example: BV1eV411W7tt 31 | 125: { 32 | "description": "HDR 真彩", 33 | "width": 3840, 34 | "height": 2160, 35 | }, 36 | 120: { 37 | "description": "4K 超清", 38 | "width": 3840, 39 | "height": 2160, 40 | }, 41 | 116: { 42 | "description": "1080P 60帧", 43 | "width": 1920, 44 | "height": 1080, 45 | }, 46 | 112: { 47 | "description": "1080P 高码率", 48 | "width": 1920, 49 | "height": 1080, 50 | }, 51 | 100: { 52 | "description": "智能修复", 53 | "width": 1440, 54 | "height": 1080, 55 | }, # Example: ep327108 56 | 80: { 57 | "description": "1080P 高清", 58 | "width": 1920, 59 | "height": 1080, 60 | }, 61 | 74: { 62 | "description": "720P 60帧", 63 | "width": 1280, 64 | "height": 720, 65 | }, 66 | 64: { 67 | "description": "720P 高清", 68 | "width": 1280, 69 | "height": 720, 70 | }, 71 | 32: { 72 | "description": "480P 清晰", 73 | "width": 852, 74 | "height": 480, 75 | }, 76 | 16: { 77 | "description": "360P 流畅", 78 | "width": 640, 79 | "height": 360, 80 | }, 81 | } 82 | 83 | audio_quality_map = { 84 | 30251: { 85 | "description": "Hi-Res", 86 | "bitrate": 999, 87 | }, # Example: BV1eV4y1P7fc 88 | 30255: { 89 | "description": "杜比音效", # Dolby Audio 90 | "bitrate": 999, 91 | }, # Example: BV1Fa41127J4,但现在好像没了,也没找到其他的杜比音效选项 92 | 30250: { 93 | "description": "杜比全景声", # Dolby Atmos 94 | "bitrate": 999, 95 | }, # Example: BV1eV411W7tt 96 | 30280: { 97 | "description": "320kbps", 98 | "bitrate": 320, 99 | }, 100 | 30232: { 101 | "description": "128kbps", 102 | "bitrate": 128, 103 | }, 104 | 30216: { 105 | "description": "64kbps", 106 | "bitrate": 64, 107 | }, 108 | 0: { 109 | "description": "Unknown", 110 | "bitrate": 0, 111 | }, 112 | } 113 | 114 | 115 | def gen_video_quality_priority(quality: VideoQuality) -> list[VideoQuality]: 116 | choice = video_quality_priority_default.index(quality) 117 | return [ 118 | video_quality_priority_default[idx] 119 | for idx in gen_priority_sequence(choice, len(video_quality_priority_default)) 120 | ] 121 | 122 | 123 | def gen_audio_quality_priority(quality: AudioQuality) -> list[AudioQuality]: 124 | choice = audio_quality_priority_default.index(quality) 125 | return [ 126 | audio_quality_priority_default[idx] 127 | for idx in gen_priority_sequence(choice, len(audio_quality_priority_default)) 128 | ] 129 | -------------------------------------------------------------------------------- /src/yutto/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/cli/__init__.py -------------------------------------------------------------------------------- /src/yutto/downloader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/downloader/__init__.py -------------------------------------------------------------------------------- /src/yutto/downloader/selector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from yutto.bilibili_typing.codec import ( 6 | AudioCodec, 7 | VideoCodec, 8 | gen_acodec_priority, 9 | gen_vcodec_priority, 10 | ) 11 | from yutto.bilibili_typing.quality import ( 12 | AudioQuality, 13 | VideoQuality, 14 | gen_audio_quality_priority, 15 | gen_video_quality_priority, 16 | ) 17 | 18 | if TYPE_CHECKING: 19 | from yutto._typing import AudioUrlMeta, VideoUrlMeta 20 | 21 | 22 | def select_video( 23 | videos: list[VideoUrlMeta], 24 | video_quality: VideoQuality = 127, 25 | video_codec: VideoCodec = "hevc", 26 | video_download_codec_priority: list[VideoCodec] | None = None, 27 | ) -> VideoUrlMeta | None: 28 | video_quality_priority = gen_video_quality_priority(video_quality) 29 | video_codec_priority = ( 30 | gen_vcodec_priority(video_codec) if video_download_codec_priority is None else video_download_codec_priority 31 | ) 32 | 33 | video_combined_priority = [ 34 | (vqn, vcodec) 35 | for vqn in video_quality_priority 36 | # TODO: Dolby Selector 37 | for vcodec in video_codec_priority 38 | ] # fmt: skip 39 | 40 | for vqn, vcodec in video_combined_priority: 41 | for video in videos: 42 | if video["quality"] == vqn and video["codec"] == vcodec: 43 | return video 44 | return None 45 | 46 | 47 | def select_audio( 48 | audios: list[AudioUrlMeta], 49 | audio_quality: AudioQuality = 30280, 50 | audio_codec: AudioCodec = "mp4a", 51 | ) -> AudioUrlMeta | None: 52 | audio_quality_priority = gen_audio_quality_priority(audio_quality) 53 | audio_codec_priority = gen_acodec_priority(audio_codec) 54 | 55 | audio_combined_priority = [ 56 | (aqn, acodec) 57 | for aqn in audio_quality_priority 58 | for acodec in audio_codec_priority 59 | ] # fmt: skip 60 | 61 | for aqn, acodec in audio_combined_priority: 62 | for audio in audios: 63 | if audio["quality"] == aqn and audio["codec"] == acodec: 64 | return audio 65 | return None 66 | -------------------------------------------------------------------------------- /src/yutto/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from enum import Enum 5 | from typing import TYPE_CHECKING, TypeAlias 6 | 7 | if TYPE_CHECKING: 8 | from types import TracebackType 9 | 10 | 11 | class ErrorCode(Enum): 12 | # 发生错误 13 | HTTP_STATUS_ERROR = 10 14 | NO_ACCESS_PERMISSION_ERROR = 11 15 | UNSUPPORTED_TYPE_ERROR = 12 16 | WRONG_ARGUMENT_ERROR = 13 17 | WRONG_URL_ERROR = 14 18 | EPISODE_NOT_FOUND_ERROR = 15 19 | MAX_RETRY_ERROR = 16 20 | NOT_FOUND_ERROR = 17 21 | NOT_LOGIN_ERROR = 18 22 | 23 | # 异常状况,但并不算错误 24 | PAUSED_DOWNLOAD = 101 25 | 26 | 27 | class SuccessCode(Enum): 28 | SUCCESS = 0 29 | 30 | 31 | ReturnCode: TypeAlias = ErrorCode | SuccessCode 32 | 33 | 34 | class YuttoBaseException(Exception): 35 | code: ErrorCode 36 | message: str 37 | 38 | def __init__(self, message: str): 39 | super().__init__(message) 40 | self.message = message 41 | 42 | 43 | class HttpStatusError(YuttoBaseException): 44 | code = ErrorCode.HTTP_STATUS_ERROR 45 | 46 | 47 | class NoAccessPermissionError(YuttoBaseException): 48 | code = ErrorCode.NO_ACCESS_PERMISSION_ERROR 49 | 50 | 51 | class UnSupportedTypeError(YuttoBaseException): 52 | code = ErrorCode.UNSUPPORTED_TYPE_ERROR 53 | 54 | 55 | class MaxRetryError(YuttoBaseException): 56 | code = ErrorCode.MAX_RETRY_ERROR 57 | 58 | 59 | class NotFoundError(YuttoBaseException): 60 | code = ErrorCode.NOT_FOUND_ERROR 61 | 62 | 63 | class NotLoginError(YuttoBaseException): 64 | code = ErrorCode.NOT_LOGIN_ERROR 65 | 66 | 67 | def handleUncaughtException(exctype: type[Exception], exception: Exception, trace: TracebackType): 68 | oldHook(exctype, exception, trace) 69 | if isinstance(exception, YuttoBaseException): 70 | sys.exit(exception.code.value) 71 | 72 | 73 | sys.excepthook, oldHook = handleUncaughtException, sys.excepthook 74 | 75 | 76 | if __name__ == "__main__": 77 | try: 78 | raise HttpStatusError("HTTP 错误") 79 | except (HttpStatusError, UnSupportedTypeError) as e: 80 | print(e.code.value, e.message) 81 | raise e 82 | -------------------------------------------------------------------------------- /src/yutto/extractor/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .bangumi import BangumiExtractor 4 | from .bangumi_batch import BangumiBatchExtractor 5 | from .cheese import CheeseExtractor 6 | from .cheese_batch import CheeseBatchExtractor 7 | from .collection import CollectionExtractor 8 | from .favourites import FavouritesExtractor 9 | from .series import SeriesExtractor 10 | from .ugc_video import UgcVideoExtractor 11 | from .ugc_video_batch import UgcVideoBatchExtractor 12 | from .user_all_favourites import UserAllFavouritesExtractor 13 | from .user_all_ugc_videos import UserAllUgcVideosExtractor 14 | from .user_watch_later import UserWatchLaterExtractor 15 | 16 | __all__ = [ 17 | "UgcVideoExtractor", 18 | "UgcVideoBatchExtractor", 19 | "BangumiExtractor", 20 | "BangumiBatchExtractor", 21 | "CheeseExtractor", 22 | "CheeseBatchExtractor", 23 | "UserAllUgcVideosExtractor", 24 | "UserWatchLaterExtractor", 25 | "FavouritesExtractor", 26 | "UserAllFavouritesExtractor", 27 | "SeriesExtractor", 28 | "CollectionExtractor", 29 | ] 30 | -------------------------------------------------------------------------------- /src/yutto/extractor/_abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABCMeta, abstractmethod 4 | from typing import TYPE_CHECKING, TypeVar 5 | 6 | if TYPE_CHECKING: 7 | import httpx 8 | 9 | from yutto._typing import EpisodeData, ExtractorOptions 10 | from yutto.utils.asynclib import CoroutineWrapper 11 | from yutto.utils.fetcher import FetcherContext 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | class Extractor(metaclass=ABCMeta): 17 | def resolve_shortcut(self, id: str) -> tuple[bool, str]: 18 | matched = False 19 | url = id 20 | return (matched, url) 21 | 22 | @abstractmethod 23 | def match(self, url: str) -> bool: 24 | raise NotImplementedError 25 | 26 | @abstractmethod 27 | async def __call__( 28 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 29 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 30 | raise NotImplementedError 31 | 32 | 33 | class SingleExtractor(Extractor): 34 | async def __call__( 35 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 36 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 37 | return [await self.extract(ctx, client, options)] 38 | 39 | @abstractmethod 40 | async def extract( 41 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 42 | ) -> CoroutineWrapper[EpisodeData | None] | None: 43 | raise NotImplementedError 44 | 45 | 46 | class BatchExtractor(Extractor): 47 | async def __call__( 48 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 49 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 50 | return await self.extract(ctx, client, options) 51 | 52 | @abstractmethod 53 | async def extract( 54 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 55 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 56 | raise NotImplementedError 57 | -------------------------------------------------------------------------------- /src/yutto/extractor/bangumi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from typing import TYPE_CHECKING 6 | 7 | from yutto._typing import EpisodeData, EpisodeId 8 | from yutto.api.bangumi import get_bangumi_list, get_season_id_by_episode_id 9 | from yutto.exceptions import ( 10 | ErrorCode, 11 | HttpStatusError, 12 | NoAccessPermissionError, 13 | NotFoundError, 14 | UnSupportedTypeError, 15 | ) 16 | from yutto.extractor._abc import SingleExtractor 17 | from yutto.extractor.common import extract_bangumi_data 18 | from yutto.utils.asynclib import CoroutineWrapper 19 | from yutto.utils.console.logger import Badge, Logger 20 | 21 | if TYPE_CHECKING: 22 | import httpx 23 | 24 | from yutto._typing import ExtractorOptions 25 | from yutto.utils.fetcher import FetcherContext 26 | 27 | 28 | class BangumiExtractor(SingleExtractor): 29 | """番剧单话""" 30 | 31 | REGEX_EP = re.compile(r"https?://www\.bilibili\.com/bangumi/play/ep(?P\d+)") 32 | 33 | REGEX_EP_ID = re.compile(r"ep(?P\d+)") 34 | 35 | episode_id: EpisodeId 36 | 37 | def resolve_shortcut(self, id: str) -> tuple[bool, str]: 38 | matched = False 39 | url = id 40 | if match_obj := self.REGEX_EP_ID.match(id): 41 | url = f"https://www.bilibili.com/bangumi/play/ep{match_obj.group('episode_id')}" 42 | matched = True 43 | return matched, url 44 | 45 | def match(self, url: str) -> bool: 46 | if match_obj := self.REGEX_EP.match(url): 47 | self.episode_id = EpisodeId(match_obj.group("episode_id")) 48 | return True 49 | else: 50 | return False 51 | 52 | async def extract( 53 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 54 | ) -> CoroutineWrapper[EpisodeData | None] | None: 55 | season_id = await get_season_id_by_episode_id(ctx, client, self.episode_id) 56 | bangumi_list = await get_bangumi_list(ctx, client, season_id) 57 | Logger.custom(bangumi_list["title"], Badge("番剧", fore="black", back="cyan")) 58 | try: 59 | for bangumi_item in bangumi_list["pages"]: 60 | if bangumi_item["episode_id"] == self.episode_id: 61 | bangumi_list_item = bangumi_item 62 | break 63 | else: 64 | Logger.error("在列表中未找到该剧集") 65 | sys.exit(ErrorCode.EPISODE_NOT_FOUND_ERROR.value) 66 | 67 | return CoroutineWrapper( 68 | extract_bangumi_data( 69 | ctx, 70 | client, 71 | bangumi_list_item, 72 | options, 73 | { 74 | "title": bangumi_list["title"], 75 | }, 76 | "{name}", 77 | ) 78 | ) 79 | except (NoAccessPermissionError, HttpStatusError, UnSupportedTypeError, NotFoundError) as e: 80 | Logger.error(e.message) 81 | return None 82 | -------------------------------------------------------------------------------- /src/yutto/extractor/cheese.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from typing import TYPE_CHECKING 6 | 7 | from yutto._typing import EpisodeData, EpisodeId 8 | from yutto.api.cheese import get_cheese_list, get_season_id_by_episode_id 9 | from yutto.exceptions import ( 10 | ErrorCode, 11 | HttpStatusError, 12 | NoAccessPermissionError, 13 | NotFoundError, 14 | UnSupportedTypeError, 15 | ) 16 | from yutto.extractor._abc import SingleExtractor 17 | from yutto.extractor.common import extract_cheese_data 18 | from yutto.utils.asynclib import CoroutineWrapper 19 | from yutto.utils.console.logger import Badge, Logger 20 | 21 | if TYPE_CHECKING: 22 | import httpx 23 | 24 | from yutto._typing import ExtractorOptions 25 | from yutto.utils.fetcher import FetcherContext 26 | 27 | 28 | class CheeseExtractor(SingleExtractor): 29 | """单课时""" 30 | 31 | REGEX_EP = re.compile(r"https?://www\.bilibili\.com/cheese/play/ep(?P\d+)") 32 | 33 | REGEX_EP_ID = re.compile(r"ep(?P\d+)") 34 | 35 | episode_id: EpisodeId 36 | 37 | def resolve_shortcut(self, id: str) -> tuple[bool, str]: 38 | matched = False 39 | url = id 40 | # TODO 和番剧的快捷方式冲突,课程中暂时放弃快捷方式特性 41 | # if match_obj := self.REGEX_EP_ID.match(id): 42 | # url = f"https://www.bilibili.com/cheese/play/ep{match_obj.group('episode_id')}" 43 | # matched = True 44 | return matched, url 45 | 46 | def match(self, url: str) -> bool: 47 | if match_obj := self.REGEX_EP.match(url): 48 | self.episode_id = EpisodeId(match_obj.group("episode_id")) 49 | return True 50 | else: 51 | return False 52 | 53 | async def extract( 54 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 55 | ) -> CoroutineWrapper[EpisodeData | None] | None: 56 | season_id = await get_season_id_by_episode_id(ctx, client, self.episode_id) 57 | cheese_list = await get_cheese_list(ctx, client, season_id) 58 | Logger.custom(cheese_list["title"], Badge("课程", fore="black", back="cyan")) 59 | try: 60 | for cheese_item in cheese_list["pages"]: 61 | if cheese_item["episode_id"] == self.episode_id: 62 | cheese_list_item = cheese_item 63 | break 64 | else: 65 | Logger.error("在列表中未找到该剧集") 66 | sys.exit(ErrorCode.EPISODE_NOT_FOUND_ERROR.value) 67 | 68 | return CoroutineWrapper( 69 | extract_cheese_data( 70 | ctx, 71 | client, 72 | self.episode_id, 73 | cheese_list_item, 74 | options, 75 | { 76 | "title": cheese_list["title"], 77 | }, 78 | "{name}", 79 | ) 80 | ) 81 | except (NoAccessPermissionError, HttpStatusError, UnSupportedTypeError, NotFoundError) as e: 82 | Logger.error(e.message) 83 | return None 84 | -------------------------------------------------------------------------------- /src/yutto/extractor/cheese_batch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from yutto._typing import EpisodeData, EpisodeId, SeasonId 7 | from yutto.api.cheese import get_cheese_list, get_season_id_by_episode_id 8 | from yutto.extractor._abc import BatchExtractor 9 | from yutto.extractor.common import extract_cheese_data 10 | from yutto.parser import parse_episodes_selection 11 | from yutto.utils.asynclib import CoroutineWrapper 12 | from yutto.utils.console.logger import Badge, Logger 13 | 14 | if TYPE_CHECKING: 15 | import httpx 16 | 17 | from yutto._typing import ExtractorOptions 18 | from yutto.utils.fetcher import FetcherContext 19 | 20 | 21 | class CheeseBatchExtractor(BatchExtractor): 22 | """课程全集""" 23 | 24 | REGEX_EP = re.compile(r"https?://www\.bilibili\.com/cheese/play/ep(?P\d+)") 25 | REGEX_SS = re.compile(r"https?://www\.bilibili\.com/cheese/play/ss(?P\d+)") 26 | 27 | # REGEX_EP_ID = re.compile(r"ep(?P\d+)") 28 | # REGEX_SS_ID = re.compile(r"ss(?P\d+)") 29 | 30 | _match_result: re.Match[Any] 31 | season_id: SeasonId 32 | 33 | def resolve_shortcut(self, id: str) -> tuple[bool, str]: 34 | matched = False 35 | url = id 36 | # TODO 和番剧的快捷方式冲突,课程中暂时放弃快捷方式特性 37 | # if match_obj := self.REGEX_EP_ID.match(id): 38 | # url = f"https://www.bilibili.com/cheese/play/ep{match_obj.group('episode_id')}" 39 | # matched = True 40 | # elif match_obj := self.REGEX_SS_ID.match(id): 41 | # url = f"https://www.bilibili.com/cheese/play/ss{match_obj.group('season_id')}" 42 | # matched = True 43 | return matched, url 44 | 45 | def match(self, url: str) -> bool: 46 | if (match_obj := self.REGEX_SS.match(url)) or (match_obj := self.REGEX_EP.match(url)): 47 | self._match_result = match_obj 48 | return True 49 | else: 50 | return False 51 | 52 | async def _parse_ids(self, ctx: FetcherContext, client: httpx.AsyncClient): 53 | if "episode_id" in self._match_result.groupdict().keys(): 54 | episode_id = EpisodeId(self._match_result.group("episode_id")) 55 | self.season_id = await get_season_id_by_episode_id(ctx, client, episode_id) 56 | else: 57 | self.season_id = SeasonId(self._match_result.group("season_id")) 58 | 59 | async def extract( 60 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 61 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 62 | await self._parse_ids(ctx, client) 63 | 64 | cheese_list = await get_cheese_list(ctx, client, self.season_id) 65 | Logger.custom(cheese_list["title"], Badge("课程", fore="black", back="cyan")) 66 | # 选集过滤 67 | episodes = parse_episodes_selection(options["episodes"], len(cheese_list["pages"])) 68 | cheese_list["pages"] = list(filter(lambda item: item["id"] in episodes, cheese_list["pages"])) 69 | return [ 70 | CoroutineWrapper( 71 | extract_cheese_data( 72 | ctx, 73 | client, 74 | cheese_item["episode_id"], 75 | cheese_item, 76 | options, 77 | { 78 | "title": cheese_list["title"], 79 | }, 80 | "{title}/{name}", 81 | ) 82 | ) 83 | for cheese_item in cheese_list["pages"] 84 | ] 85 | -------------------------------------------------------------------------------- /src/yutto/extractor/favourites.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import re 5 | from typing import TYPE_CHECKING 6 | 7 | from yutto._typing import EpisodeData, FId, MId 8 | from yutto.api.space import get_favourite_avids, get_favourite_info, get_user_name 9 | from yutto.api.ugc_video import UgcVideoListItem, get_ugc_video_list 10 | from yutto.exceptions import NoAccessPermissionError, NotFoundError 11 | from yutto.extractor._abc import BatchExtractor 12 | from yutto.extractor.common import extract_ugc_video_data 13 | from yutto.utils.asynclib import CoroutineWrapper 14 | from yutto.utils.console.logger import Badge, Logger 15 | from yutto.utils.fetcher import Fetcher, FetcherContext 16 | from yutto.utils.filter import Filter 17 | 18 | if TYPE_CHECKING: 19 | import httpx 20 | 21 | from yutto._typing import ExtractorOptions 22 | 23 | 24 | class FavouritesExtractor(BatchExtractor): 25 | """用户单一收藏夹""" 26 | 27 | REGEX_FAV = re.compile(r"https?://space\.bilibili\.com/(?P\d+)/favlist\?fid=(?P\d+)((&ftype=create)|$)") 28 | 29 | mid: MId 30 | fid: FId 31 | 32 | def match(self, url: str) -> bool: 33 | if match_obj := self.REGEX_FAV.match(url): 34 | self.mid = MId(match_obj.group("mid")) 35 | self.fid = FId(match_obj.group("fid")) 36 | return True 37 | else: 38 | return False 39 | 40 | async def extract( 41 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 42 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 43 | username, favourite_info = await asyncio.gather( 44 | get_user_name(ctx, client, self.mid), 45 | get_favourite_info(ctx, client, self.fid), 46 | ) 47 | Logger.custom(favourite_info["title"], Badge("收藏夹", fore="black", back="cyan")) 48 | 49 | ugc_video_info_list: list[tuple[UgcVideoListItem, str, int]] = [] 50 | 51 | for avid in await get_favourite_avids(ctx, client, self.fid): 52 | try: 53 | ugc_video_list = await get_ugc_video_list(ctx, client, avid) 54 | # 在使用 SESSDATA 时,如果不去事先 touch 一下视频链接的话,是无法获取 episode_data 的 55 | # 至于为什么前面那俩(投稿视频页和番剧页)不需要额外 touch,因为在 get_redirected_url 阶段连接过了呀 56 | if not Filter.verify_timer(ugc_video_list["pubdate"]): 57 | Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") 58 | continue 59 | await Fetcher.touch_url(ctx, client, avid.to_url()) 60 | for ugc_video_item in ugc_video_list["pages"]: 61 | ugc_video_info_list.append( 62 | ( 63 | ugc_video_item, 64 | ugc_video_list["title"], 65 | ugc_video_list["pubdate"], 66 | ) 67 | ) 68 | except (NotFoundError, NoAccessPermissionError) as e: 69 | Logger.error(e.message) 70 | continue 71 | 72 | return [ 73 | CoroutineWrapper( 74 | extract_ugc_video_data( 75 | ctx, 76 | client, 77 | ugc_video_item["avid"], 78 | ugc_video_item, 79 | options, 80 | { 81 | "title": title, 82 | "username": username, 83 | "series_title": favourite_info["title"], 84 | "pubdate": pubdate, 85 | }, 86 | "{username}的收藏夹/{series_title}/{title}/{name}", 87 | ) 88 | ) 89 | for ugc_video_item, title, pubdate in ugc_video_info_list 90 | ] 91 | -------------------------------------------------------------------------------- /src/yutto/extractor/series.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import re 5 | from typing import TYPE_CHECKING 6 | 7 | from yutto._typing import EpisodeData, MId, SeriesId 8 | from yutto.api.space import get_medialist_avids, get_medialist_title, get_user_name 9 | from yutto.api.ugc_video import UgcVideoListItem, get_ugc_video_list 10 | from yutto.exceptions import NoAccessPermissionError, NotFoundError 11 | from yutto.extractor._abc import BatchExtractor 12 | from yutto.extractor.common import extract_ugc_video_data 13 | from yutto.utils.asynclib import CoroutineWrapper 14 | from yutto.utils.console.logger import Badge, Logger 15 | from yutto.utils.fetcher import Fetcher, FetcherContext 16 | from yutto.utils.filter import Filter 17 | 18 | if TYPE_CHECKING: 19 | import httpx 20 | 21 | from yutto._typing import ExtractorOptions 22 | 23 | 24 | class SeriesExtractor(BatchExtractor): 25 | """视频列表""" 26 | 27 | REGEX_SERIES_LISTS = re.compile(r"https?://space\.bilibili\.com/(?P\d+)/lists/(?P\d+)\?type=series") 28 | REGEX_SERIES_LEGACY: re.Pattern[str] = re.compile( 29 | r"https?://space\.bilibili\.com/(?P\d+)/channel/seriesdetail\?sid=(?P\d+)" 30 | ) 31 | REGEX_SERIES_PLAYLIST = re.compile(r"https?://www\.bilibili\.com/list/(?P\d+)\?sid=(?P\d+)") 32 | 33 | mid: MId 34 | series_id: SeriesId 35 | 36 | def match(self, url: str) -> bool: 37 | if ( 38 | (match_obj := self.REGEX_SERIES_LISTS.match(url)) 39 | or (match_obj := self.REGEX_SERIES_LEGACY.match(url)) 40 | or (match_obj := self.REGEX_SERIES_PLAYLIST.match(url)) 41 | ): 42 | self.mid = MId(match_obj.group("mid")) 43 | self.series_id = SeriesId(match_obj.group("series_id")) 44 | return True 45 | else: 46 | return False 47 | 48 | async def extract( 49 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 50 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 51 | username, series_title = await asyncio.gather( 52 | get_user_name(ctx, client, self.mid), get_medialist_title(ctx, client, self.series_id) 53 | ) 54 | Logger.custom(series_title, Badge("视频列表", fore="black", back="cyan")) 55 | 56 | ugc_video_info_list: list[tuple[UgcVideoListItem, str, int]] = [] 57 | for avid in await get_medialist_avids(ctx, client, self.series_id, self.mid): 58 | try: 59 | ugc_video_list = await get_ugc_video_list(ctx, client, avid) 60 | if not Filter.verify_timer(ugc_video_list["pubdate"]): 61 | Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") 62 | continue 63 | await Fetcher.touch_url(ctx, client, avid.to_url()) 64 | for ugc_video_item in ugc_video_list["pages"]: 65 | ugc_video_info_list.append( 66 | ( 67 | ugc_video_item, 68 | ugc_video_list["title"], 69 | ugc_video_list["pubdate"], 70 | ) 71 | ) 72 | except (NotFoundError, NoAccessPermissionError) as e: 73 | Logger.error(e.message) 74 | continue 75 | 76 | return [ 77 | CoroutineWrapper( 78 | extract_ugc_video_data( 79 | ctx, 80 | client, 81 | ugc_video_item["avid"], 82 | ugc_video_item, 83 | options, 84 | { 85 | "series_title": series_title, 86 | "username": username, # 虽然默认模板的用不上,但这里可以提供一下 87 | "title": title, 88 | "pubdate": pubdate, 89 | }, 90 | "{series_title}/{title}/{name}", 91 | ) 92 | ) 93 | for ugc_video_item, title, pubdate in ugc_video_info_list 94 | ] 95 | -------------------------------------------------------------------------------- /src/yutto/extractor/ugc_video.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING 5 | from urllib.parse import parse_qs, urlparse 6 | 7 | from yutto._typing import AId, AvId, BvId, EpisodeData 8 | from yutto.api.ugc_video import get_ugc_video_list 9 | from yutto.exceptions import ( 10 | HttpStatusError, 11 | NoAccessPermissionError, 12 | NotFoundError, 13 | UnSupportedTypeError, 14 | ) 15 | from yutto.extractor._abc import SingleExtractor 16 | from yutto.extractor.common import extract_ugc_video_data 17 | from yutto.utils.asynclib import CoroutineWrapper 18 | from yutto.utils.console.logger import Badge, Logger 19 | 20 | if TYPE_CHECKING: 21 | import httpx 22 | 23 | from yutto._typing import ExtractorOptions 24 | from yutto.utils.fetcher import FetcherContext 25 | 26 | 27 | class UgcVideoExtractor(SingleExtractor): 28 | """投稿视频单视频""" 29 | 30 | REGEX_AV = re.compile(r"https?://www\.bilibili\.com/video/av(?P\d+)/?") 31 | REGEX_BV = re.compile(r"https?://www\.bilibili\.com/video/(?P(bv|BV)\w+)/?") 32 | 33 | REGEX_AV_ID = re.compile(r"av(?P\d+)(\?p=(?P\d+))?") 34 | REGEX_BV_ID = re.compile(r"(?P(bv|BV)\w+)(\?p=(?P\d+))?") 35 | 36 | REGEX_BV_SPECIAL_PAGE = re.compile(r"https?://www\.bilibili\.com/festival/.+(?P(bv|BV)\w+)") 37 | 38 | page: int 39 | avid: AvId 40 | 41 | def resolve_shortcut(self, id: str) -> tuple[bool, str]: 42 | matched = False 43 | url = id 44 | if match_obj := self.REGEX_AV_ID.match(id): 45 | page: int = 1 46 | if match_obj.group("page") is not None: 47 | page = int(match_obj.group("page")) 48 | url = f"https://www.bilibili.com/video/av{match_obj.group('aid')}?p={page}" 49 | matched = True 50 | elif match_obj := self.REGEX_BV_ID.match(id): 51 | page: int = 1 52 | if match_obj.group("page") is not None: 53 | page = int(match_obj.group("page")) 54 | url = f"https://www.bilibili.com/video/{match_obj.group('bvid')}?p={page}" 55 | matched = True 56 | return matched, url 57 | 58 | def match(self, url: str) -> bool: 59 | if ( 60 | (match_obj := self.REGEX_AV.match(url)) 61 | or (match_obj := self.REGEX_BV.match(url)) 62 | or (match_obj := self.REGEX_BV_SPECIAL_PAGE.match(url)) 63 | ): 64 | self.page: int = 1 65 | if "aid" in match_obj.groupdict().keys(): 66 | self.avid = AId(match_obj.group("aid")) 67 | else: 68 | self.avid = BvId(match_obj.group("bvid")) 69 | query_params = parse_qs(urlparse(url).query) 70 | if p_queries := query_params.get("p"): 71 | try: 72 | assert len(p_queries) == 1, f"p should only have one value in url `{url}`, but got {len(p_queries)}" 73 | self.page = int(p_queries[0]) 74 | except (ValueError, AssertionError) as e: 75 | Logger.error(f"url 的 page 信息不正确, `{e}`, 请检查 `p=` 的值是否为整数且唯一~") 76 | return False 77 | return True 78 | else: 79 | return False 80 | 81 | async def extract( 82 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 83 | ) -> CoroutineWrapper[EpisodeData | None] | None: 84 | try: 85 | ugc_video_list = await get_ugc_video_list(ctx, client, self.avid) 86 | self.avid = ugc_video_list["avid"] # 当视频撞车时,使用新的 avid 替代原有 avid,见 #96 87 | Logger.custom(ugc_video_list["title"], Badge("投稿视频", fore="black", back="cyan")) 88 | return CoroutineWrapper( 89 | extract_ugc_video_data( 90 | ctx, 91 | client, 92 | self.avid, 93 | ugc_video_list["pages"][self.page - 1], 94 | options, 95 | { 96 | "title": ugc_video_list["title"], 97 | "pubdate": ugc_video_list["pubdate"], 98 | }, 99 | "{title}", 100 | ) 101 | ) 102 | except (NoAccessPermissionError, HttpStatusError, UnSupportedTypeError, NotFoundError) as e: 103 | Logger.error(e.message) 104 | return None 105 | -------------------------------------------------------------------------------- /src/yutto/extractor/ugc_video_batch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING 5 | 6 | from yutto._typing import AId, AvId, BvId, EpisodeData 7 | from yutto.api.ugc_video import get_ugc_video_list 8 | from yutto.exceptions import NoAccessPermissionError, NotFoundError 9 | from yutto.extractor._abc import BatchExtractor 10 | from yutto.extractor.common import extract_ugc_video_data 11 | from yutto.parser import parse_episodes_selection 12 | from yutto.utils.asynclib import CoroutineWrapper 13 | from yutto.utils.console.logger import Badge, Logger 14 | 15 | if TYPE_CHECKING: 16 | import httpx 17 | 18 | from yutto._typing import ExtractorOptions 19 | from yutto.utils.fetcher import FetcherContext 20 | 21 | 22 | class UgcVideoBatchExtractor(BatchExtractor): 23 | """投稿视频批下载""" 24 | 25 | REGEX_AV = re.compile(r"https?://www\.bilibili\.com/video/av(?P\d+)/?") 26 | REGEX_BV = re.compile(r"https?://www\.bilibili\.com/video/(?P(bv|BV)\w+)/?") 27 | 28 | REGEX_AV_ID = re.compile(r"av(?P\d+)(\?p=(?P\d+))?") 29 | REGEX_BV_ID = re.compile(r"(?P(bv|BV)\w+)(\?p=(?P\d+))?") 30 | 31 | REGEX_BV_SPECIAL_PAGE = re.compile(r"https?://www\.bilibili\.com/festival/.+(?P(bv|BV)\w+)") 32 | 33 | avid: AvId 34 | 35 | def resolve_shortcut(self, id: str) -> tuple[bool, str]: 36 | matched = False 37 | url = id 38 | if match_obj := self.REGEX_AV_ID.match(id): 39 | page: int = 1 40 | if match_obj.group("page") is not None: 41 | page = int(match_obj.group("page")) 42 | url = f"https://www.bilibili.com/video/av{match_obj.group('aid')}?p={page}" 43 | matched = True 44 | elif match_obj := self.REGEX_BV_ID.match(id): 45 | page: int = 1 46 | if match_obj.group("page") is not None: 47 | page = int(match_obj.group("page")) 48 | url = f"https://www.bilibili.com/video/{match_obj.group('bvid')}?p={page}" 49 | matched = True 50 | return matched, url 51 | 52 | def match(self, url: str) -> bool: 53 | if ( 54 | (match_obj := self.REGEX_AV.match(url)) 55 | or (match_obj := self.REGEX_BV.match(url)) 56 | or (match_obj := self.REGEX_BV_SPECIAL_PAGE.match(url)) 57 | ): 58 | if "aid" in match_obj.groupdict().keys(): 59 | self.avid = AId(match_obj.group("aid")) 60 | else: 61 | self.avid = BvId(match_obj.group("bvid")) 62 | return True 63 | else: 64 | return False 65 | 66 | async def extract( 67 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 68 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 69 | try: 70 | ugc_video_list = await get_ugc_video_list(ctx, client, self.avid) 71 | Logger.custom(ugc_video_list["title"], Badge("投稿视频", fore="black", back="cyan")) 72 | except (NotFoundError, NoAccessPermissionError) as e: 73 | # 由于获取 info 时候也会因为视频不存在而报错,因此这里需要捕捉下 74 | Logger.error(e.message) 75 | return [] 76 | 77 | # 选集过滤 78 | episodes = parse_episodes_selection(options["episodes"], len(ugc_video_list["pages"])) 79 | ugc_video_list["pages"] = list(filter(lambda item: item["id"] in episodes, ugc_video_list["pages"])) 80 | 81 | return [ 82 | CoroutineWrapper( 83 | extract_ugc_video_data( 84 | ctx, 85 | client, 86 | ugc_video_item["avid"], 87 | ugc_video_item, 88 | options, 89 | { 90 | "title": ugc_video_list["title"], 91 | "pubdate": ugc_video_list["pubdate"], 92 | }, 93 | "{title}/{name}", 94 | ) 95 | ) 96 | for ugc_video_item in ugc_video_list["pages"] 97 | ] 98 | -------------------------------------------------------------------------------- /src/yutto/extractor/user_all_favourites.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING 5 | 6 | from yutto._typing import EpisodeData, MId 7 | from yutto.api.space import get_all_favourites, get_favourite_avids, get_user_name 8 | from yutto.api.ugc_video import UgcVideoListItem, get_ugc_video_list 9 | from yutto.exceptions import NoAccessPermissionError, NotFoundError 10 | from yutto.extractor._abc import BatchExtractor 11 | from yutto.extractor.common import extract_ugc_video_data 12 | from yutto.utils.asynclib import CoroutineWrapper 13 | from yutto.utils.console.logger import Badge, Logger 14 | from yutto.utils.fetcher import Fetcher, FetcherContext 15 | from yutto.utils.filter import Filter 16 | 17 | if TYPE_CHECKING: 18 | import httpx 19 | 20 | from yutto._typing import ExtractorOptions 21 | 22 | 23 | class UserAllFavouritesExtractor(BatchExtractor): 24 | """用户所有收藏夹""" 25 | 26 | REGEX_FAV_ALL = re.compile(r"https?://space\.bilibili\.com/(?P\d+)/favlist$") 27 | 28 | mid: MId 29 | 30 | def match(self, url: str) -> bool: 31 | if match_obj := self.REGEX_FAV_ALL.match(url): 32 | self.mid = MId(match_obj.group("mid")) 33 | return True 34 | else: 35 | return False 36 | 37 | async def extract( 38 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 39 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 40 | username = await get_user_name(ctx, client, self.mid) 41 | Logger.custom(username, Badge("用户收藏夹", fore="black", back="cyan")) 42 | 43 | ugc_video_info_list: list[tuple[UgcVideoListItem, str, int, str]] = [] 44 | 45 | for fav in await get_all_favourites(ctx, client, self.mid): 46 | series_title = fav["title"] 47 | fid = fav["fid"] 48 | for avid in await get_favourite_avids(ctx, client, fid): 49 | try: 50 | ugc_video_list = await get_ugc_video_list(ctx, client, avid) 51 | if not Filter.verify_timer(ugc_video_list["pubdate"]): 52 | Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") 53 | continue 54 | await Fetcher.touch_url(ctx, client, avid.to_url()) 55 | for ugc_video_item in ugc_video_list["pages"]: 56 | ugc_video_info_list.append( 57 | ( 58 | ugc_video_item, 59 | ugc_video_list["title"], 60 | ugc_video_list["pubdate"], 61 | series_title, 62 | ) 63 | ) 64 | except (NotFoundError, NoAccessPermissionError) as e: 65 | Logger.error(e.message) 66 | continue 67 | 68 | return [ 69 | CoroutineWrapper( 70 | extract_ugc_video_data( 71 | ctx, 72 | client, 73 | ugc_video_item["avid"], 74 | ugc_video_item, 75 | options, 76 | { 77 | "title": title, 78 | "username": username, 79 | "series_title": series_title, 80 | "pubdate": pubdate, 81 | }, 82 | "{username}的收藏夹/{series_title}/{title}/{name}", 83 | ) 84 | ) 85 | for ugc_video_item, title, pubdate, series_title in ugc_video_info_list 86 | ] 87 | -------------------------------------------------------------------------------- /src/yutto/extractor/user_all_ugc_videos.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING 5 | 6 | from yutto._typing import EpisodeData, MId 7 | from yutto.api.space import get_user_name, get_user_space_all_videos_avids 8 | from yutto.api.ugc_video import UgcVideoListItem, get_ugc_video_list 9 | from yutto.exceptions import NoAccessPermissionError, NotFoundError 10 | from yutto.extractor._abc import BatchExtractor 11 | from yutto.extractor.common import extract_ugc_video_data 12 | from yutto.utils.asynclib import CoroutineWrapper 13 | from yutto.utils.console.logger import Badge, Logger 14 | from yutto.utils.fetcher import Fetcher, FetcherContext 15 | from yutto.utils.filter import Filter 16 | 17 | if TYPE_CHECKING: 18 | import httpx 19 | 20 | from yutto._typing import ExtractorOptions 21 | 22 | 23 | class UserAllUgcVideosExtractor(BatchExtractor): 24 | """UP 主个人空间全部投稿视频""" 25 | 26 | REGEX_SPACE = re.compile(r"https?://space\.bilibili\.com/(?P\d+)(/video)?") 27 | 28 | mid: MId 29 | 30 | def match(self, url: str) -> bool: 31 | if match_obj := self.REGEX_SPACE.match(url): 32 | self.mid = MId(match_obj.group("mid")) 33 | return True 34 | else: 35 | return False 36 | 37 | async def extract( 38 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 39 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 40 | username = await get_user_name(ctx, client, self.mid) 41 | Logger.custom(username, Badge("UP 主投稿视频", fore="black", back="cyan")) 42 | 43 | ugc_video_info_list: list[tuple[UgcVideoListItem, str, int]] = [] 44 | for avid in await get_user_space_all_videos_avids(ctx, client, self.mid): 45 | try: 46 | ugc_video_list = await get_ugc_video_list(ctx, client, avid) 47 | if not Filter.verify_timer(ugc_video_list["pubdate"]): 48 | Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") 49 | continue 50 | await Fetcher.touch_url(ctx, client, avid.to_url()) 51 | for ugc_video_item in ugc_video_list["pages"]: 52 | ugc_video_info_list.append( 53 | ( 54 | ugc_video_item, 55 | ugc_video_list["title"], 56 | ugc_video_list["pubdate"], 57 | ) 58 | ) 59 | except (NotFoundError, NoAccessPermissionError) as e: 60 | Logger.error(e.message) 61 | continue 62 | 63 | return [ 64 | CoroutineWrapper( 65 | extract_ugc_video_data( 66 | ctx, 67 | client, 68 | ugc_video_item["avid"], 69 | ugc_video_item, 70 | options, 71 | { 72 | "title": title, 73 | "username": username, 74 | "pubdate": pubdate, 75 | }, 76 | "{username}的全部投稿视频/{title}/{name}", 77 | ) 78 | ) 79 | for ugc_video_item, title, pubdate in ugc_video_info_list 80 | ] 81 | -------------------------------------------------------------------------------- /src/yutto/extractor/user_watch_later.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import TYPE_CHECKING 5 | 6 | from yutto.api.space import get_watch_later_avids 7 | from yutto.api.ugc_video import UgcVideoListItem, get_ugc_video_list 8 | from yutto.exceptions import NoAccessPermissionError, NotFoundError, NotLoginError 9 | from yutto.extractor._abc import BatchExtractor 10 | from yutto.extractor.common import extract_ugc_video_data 11 | from yutto.utils.asynclib import CoroutineWrapper 12 | from yutto.utils.console.logger import Badge, Logger 13 | from yutto.utils.fetcher import Fetcher, FetcherContext 14 | from yutto.utils.filter import Filter 15 | 16 | if TYPE_CHECKING: 17 | import httpx 18 | 19 | from yutto._typing import EpisodeData, ExtractorOptions 20 | 21 | 22 | class UserWatchLaterExtractor(BatchExtractor): 23 | """用户稍后再看""" 24 | 25 | REGEX_WATCH_LATER_INDEX = re.compile(r"https?://www\.bilibili\.com/watchlater/?.*?$") 26 | REGEX_WATCH_LATER_LIST = re.compile(r"https?://www\.bilibili\.com/list/watchlater/?.*?$") 27 | 28 | def match(self, url: str) -> bool: 29 | if self.REGEX_WATCH_LATER_INDEX.match(url) or self.REGEX_WATCH_LATER_LIST.match(url): 30 | return True 31 | else: 32 | return False 33 | 34 | async def extract( 35 | self, ctx: FetcherContext, client: httpx.AsyncClient, options: ExtractorOptions 36 | ) -> list[CoroutineWrapper[EpisodeData | None] | None]: 37 | Logger.custom("当前用户", Badge("稍后再看", fore="black", back="cyan")) 38 | 39 | ugc_video_info_list: list[tuple[UgcVideoListItem, str, int, str]] = [] 40 | 41 | try: 42 | avid_list = await get_watch_later_avids(ctx, client) 43 | except NotLoginError as e: 44 | Logger.error(e.message) 45 | return [] 46 | 47 | for avid in avid_list: 48 | try: 49 | ugc_video_list = await get_ugc_video_list(ctx, client, avid) 50 | if not Filter.verify_timer(ugc_video_list["pubdate"]): 51 | Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") 52 | continue 53 | await Fetcher.touch_url(ctx, client, avid.to_url()) 54 | for ugc_video_item in ugc_video_list["pages"]: 55 | ugc_video_info_list.append( 56 | ( 57 | ugc_video_item, 58 | ugc_video_list["title"], 59 | ugc_video_list["pubdate"], 60 | "稍后再看", 61 | ) 62 | ) 63 | except (NotFoundError, NoAccessPermissionError) as e: 64 | Logger.error(e.message) 65 | continue 66 | 67 | return [ 68 | CoroutineWrapper( 69 | extract_ugc_video_data( 70 | ctx, 71 | client, 72 | ugc_video_item["avid"], 73 | ugc_video_item, 74 | options, 75 | { 76 | "title": title, 77 | "username": "", 78 | "series_title": series_title, 79 | "pubdate": pubdate, 80 | }, 81 | "稍后再看/{title}/{name}", 82 | ) 83 | ) 84 | for ugc_video_item, title, pubdate, series_title in ugc_video_info_list 85 | ] 86 | -------------------------------------------------------------------------------- /src/yutto/mcp.py: -------------------------------------------------------------------------------- 1 | # noqa: I002 2 | 3 | from collections.abc import AsyncIterator 4 | from contextlib import asynccontextmanager 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING, cast 7 | 8 | from fastmcp import Context, FastMCP 9 | from pydantic import Field 10 | 11 | from yutto.download_manager import DownloadManager, DownloadTask 12 | from yutto.utils.fetcher import FetcherContext 13 | 14 | if TYPE_CHECKING: 15 | from mcp.server.session import ServerSession 16 | 17 | 18 | @dataclass 19 | class AppContext: 20 | download_manager: DownloadManager 21 | 22 | 23 | @asynccontextmanager 24 | async def app_lifespan(server: FastMCP[AppContext]) -> AsyncIterator[AppContext]: 25 | """Manage application lifecycle with type-safe context""" 26 | # Initialize on startup 27 | ctx = FetcherContext() 28 | download_manager = DownloadManager() 29 | download_manager.start(ctx) 30 | try: 31 | yield AppContext(download_manager=download_manager) 32 | finally: 33 | # Cleanup on shutdown 34 | await download_manager.stop() 35 | 36 | 37 | mcp = FastMCP("yutto", lifespan=app_lifespan) 38 | 39 | 40 | def parse_args(url: str, dir: str): 41 | from yutto.cli.cli import cli 42 | 43 | parser = cli() 44 | args = parser.parse_args(["download", url, "-d", dir]) 45 | return args 46 | 47 | 48 | @mcp.tool() 49 | async def add_task( 50 | ctx: Context, # pyright: ignore[reportMissingTypeArgument, reportUnknownParameterType] 51 | url: str = Field(description="The URL to download, you can also use a short link like 'BV1CrfKYLEeP'"), 52 | dir: str = Field(description="The directory to save the downloaded file"), 53 | ) -> str: 54 | """ 55 | Use this tool to download a video from Bilibili using the given URL or short link. 56 | """ 57 | ctx_typed = cast("Context[ServerSession, AppContext]", ctx) 58 | download_manager: DownloadManager = ctx_typed.request_context.lifespan_context.download_manager 59 | await download_manager.add_task(DownloadTask(args=parse_args(url, dir))) 60 | return "Task added" 61 | 62 | 63 | def run_mcp(): 64 | mcp.run() 65 | 66 | 67 | if __name__ == "__main__": 68 | run_mcp() 69 | -------------------------------------------------------------------------------- /src/yutto/path_resolver.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from html import unescape 5 | from pathlib import Path 6 | from typing import Literal 7 | 8 | from yutto.utils.console.logger import Logger 9 | from yutto.utils.time import get_time_str_by_stamp 10 | 11 | PathTemplateVariable = Literal[ 12 | "title", "id", "aid", "bvid", "name", "username", "series_title", "pubdate", "download_date", "owner_uid" 13 | ] 14 | PathTemplateVariableDict = dict[PathTemplateVariable, int | str] 15 | UNKNOWN: str = "unknown_variable" 16 | 17 | _count: int = 0 18 | 19 | 20 | def repair_filename(filename: str) -> str: 21 | """修复不合法的文件名""" 22 | 23 | global _count 24 | 25 | def to_full_width_chr(matchobj: re.Match[str]) -> str: 26 | char = matchobj.group(0) 27 | full_width_char = chr(ord(char) + ord("?") - ord("?")) 28 | return full_width_char 29 | 30 | # 路径非法字符,转全角 31 | regex_path = re.compile(r'[\\/:*?"<>|]') 32 | # 空格类字符,转空格 33 | regex_spaces = re.compile(r"\s+") 34 | # 不可打印字符,移除 35 | regex_non_printable = re.compile( 36 | r"[\001\002\003\004\005\006\007\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" 37 | r"\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a]" 38 | ) 39 | # 尾部多个 .,转为省略号 40 | regex_dots = re.compile(r"\.+$") 41 | 42 | # 由于部分内容可能是从 HTML 解析的,所以使用 html 反转义 43 | filename = unescape(filename) 44 | filename = regex_path.sub(to_full_width_chr, filename) 45 | filename = regex_spaces.sub(" ", filename) 46 | filename = regex_non_printable.sub("", filename) 47 | filename = filename.strip() 48 | filename = regex_dots.sub("……", filename) 49 | if not filename: 50 | filename = f"未命名文件_{_count:04}" 51 | _count += 1 52 | return filename 53 | 54 | 55 | def create_time_formatter(name: str, value: int): 56 | regex = re.compile(rf"{{{name}(@(?P.+?))?}}") 57 | DEFAULT_TIMEFMT = "%Y-%m-%d" 58 | 59 | def convert_pubdate(matchobj: re.Match[str]): 60 | timefmt = matchobj.group("timefmt") 61 | if timefmt is None: 62 | timefmt = DEFAULT_TIMEFMT 63 | formatted_time = repair_filename(get_time_str_by_stamp(value, timefmt)) 64 | return formatted_time 65 | 66 | def formatter(text: str): 67 | return regex.sub(convert_pubdate, text) 68 | 69 | return formatter 70 | 71 | 72 | def resolve_path_template( 73 | path_template: str, auto_path_template: str, subpath_variables: PathTemplateVariableDict 74 | ) -> str: 75 | # 保证所有传进来的值都满足路径要求 76 | for key, value in subpath_variables.items(): 77 | # 未知变量警告 78 | if f"{{{key}}}" in path_template and value == UNKNOWN: 79 | Logger.warning("使用了未知的变量,可能导致产生错误的下载路径") 80 | # 只对字符串值修改,int 型不修改以适配高级模板 81 | if isinstance(value, str): 82 | subpath_variables[key] = repair_filename(value) 83 | 84 | # 将时间变量转换为对应的时间格式 85 | time_vars: list[PathTemplateVariable] = ["pubdate", "download_date"] 86 | for var in time_vars: 87 | value = subpath_variables.pop(var) 88 | if value == UNKNOWN: 89 | continue 90 | assert isinstance(value, int), f"变量 {var} 的值必须为 int 型,但是传入了 {value}" 91 | time_formatter = create_time_formatter(var, value) 92 | path_template = time_formatter(path_template) 93 | return path_template.format(auto=auto_path_template.format(**subpath_variables), **subpath_variables) 94 | 95 | 96 | def create_unique_path_resolver(): 97 | """确保同一次下载不会存在相同的路径 98 | 如分 P 命名完全相同(BV1Ua4y1W7cq) 99 | """ 100 | seen_path_count: dict[str, int] = {} 101 | 102 | def unique_path(path_str: str) -> str: 103 | """确保路径唯一""" 104 | seen_path_count.setdefault(path_str, -1) 105 | seen_path_count[path_str] += 1 106 | if seen_path_count[path_str] == 0: 107 | return path_str 108 | path = Path(path_str) 109 | return str(path.parent / f"{path.stem} ({seen_path_count[path_str]}){path.suffix}") 110 | 111 | return unique_path 112 | -------------------------------------------------------------------------------- /src/yutto/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/py.typed -------------------------------------------------------------------------------- /src/yutto/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/utils/__init__.py -------------------------------------------------------------------------------- /src/yutto/utils/asynclib.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import inspect 5 | import platform 6 | import time 7 | from functools import wraps 8 | from typing import TYPE_CHECKING, Any, Generic, TypeVar 9 | 10 | from typing_extensions import ParamSpec 11 | 12 | from yutto.utils.console.logger import Logger 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Callable, Coroutine, Generator, Iterable 16 | 17 | RetT = TypeVar("RetT") 18 | P = ParamSpec("P") 19 | 20 | 21 | def initial_async_policy(): 22 | if platform.system() == "Windows": 23 | Logger.debug("Windows 平台,单独设置 EventLoopPolicy") 24 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # pyright: ignore 25 | 26 | 27 | class CoroutineWrapper(Generic[RetT]): 28 | coro: Coroutine[Any, Any, RetT] 29 | 30 | def __init__(self, coro: Coroutine[Any, Any, RetT]): 31 | self.coro = coro 32 | 33 | def __await__(self) -> Generator[Any, None, RetT]: 34 | return (yield from self.coro.__await__()) 35 | 36 | def __del__(self): 37 | self.coro.close() 38 | 39 | 40 | async def sleep_with_status_bar_refresh(seconds: float): 41 | current_time = start_time = time.time() 42 | while current_time - start_time < seconds: 43 | Logger.status.next_tick() 44 | await asyncio.sleep(min(1, seconds - (current_time - start_time))) 45 | current_time = time.time() 46 | 47 | 48 | def async_cache( 49 | args_to_cache_key: Callable[[inspect.BoundArguments], str], 50 | ) -> Callable[[Callable[P, Coroutine[Any, Any, RetT]]], Callable[P, Coroutine[Any, Any, RetT]]]: 51 | CACHE: dict[str, RetT] = {} 52 | 53 | def decorator(fn: Callable[P, Coroutine[Any, Any, RetT]]) -> Callable[P, Coroutine[Any, Any, RetT]]: 54 | @wraps(fn) 55 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> RetT: 56 | sig = inspect.signature(fn) 57 | bound_args = sig.bind(*args, **kwargs) 58 | bound_args.apply_defaults() 59 | cache_key = args_to_cache_key(bound_args) 60 | if cache_key in CACHE: 61 | Logger.debug(f"{fn.__name__} cache hit: {cache_key}") 62 | return CACHE[cache_key] 63 | Logger.debug(f"{fn.__name__} cache miss: {cache_key}, all cache keys: {list(CACHE.keys())}") 64 | return CACHE.setdefault(cache_key, await fn(*args, **kwargs)) 65 | 66 | return wrapper 67 | 68 | return decorator 69 | 70 | 71 | async def first_successful(coros: Iterable[Coroutine[Any, Any, RetT]]) -> list[RetT]: 72 | tasks = [asyncio.create_task(coro) for coro in coros] 73 | 74 | results: list[RetT] = [] 75 | while not results: 76 | done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 77 | results = [task.result() for task in done if task.exception() is None] 78 | for task in tasks: 79 | task.cancel() 80 | return results 81 | 82 | 83 | async def first_successful_with_check(coros: Iterable[Coroutine[Any, Any, RetT]]) -> RetT: 84 | results = await first_successful(coros) 85 | if not results: 86 | raise Exception("All coroutines failed") 87 | if len(set(results)) != 1: 88 | raise Exception("Multiple coroutines returned different results") 89 | return results[0] 90 | -------------------------------------------------------------------------------- /src/yutto/utils/console/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/src/yutto/utils/console/__init__.py -------------------------------------------------------------------------------- /src/yutto/utils/console/attributes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import shutil 6 | import sys 7 | 8 | 9 | def get_terminal_size() -> tuple[int, int]: 10 | """获取 Console 的宽高 11 | 12 | ### Refs 13 | 14 | - https://github.com/willmcgugan/rich/blob/e5246436cd75de32f3436cc88d6e4fdebe13bd8d/rich/console.py#L918-L951 15 | """ 16 | 17 | width: int | None = None 18 | height: int | None = None 19 | if platform.system() == "Windows": 20 | width, height = shutil.get_terminal_size() 21 | else: 22 | try: 23 | width, height = os.get_terminal_size(sys.stdin.fileno()) 24 | except (AttributeError, ValueError, OSError): 25 | try: 26 | width, height = os.get_terminal_size(sys.stdout.fileno()) 27 | except (AttributeError, ValueError, OSError): 28 | pass 29 | 30 | width = width or 80 31 | height = height or 25 32 | return (width, height) 33 | -------------------------------------------------------------------------------- /src/yutto/utils/console/colorful.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from typing import Final, Literal, NamedTuple, TypeAlias, TypedDict 6 | 7 | # thirt-party imports 8 | # if system is windows, initialize colorama, which translates UNIX console color sequences into windows color sequences 9 | if sys.platform == "win32": 10 | from colorama import init 11 | 12 | init() 13 | 14 | CSI: Final[str] = "\x1b[" 15 | 16 | 17 | class RGBColor(NamedTuple): 18 | r: int 19 | g: int 20 | b: int 21 | 22 | 23 | TextColor = Literal[ 24 | "black", 25 | "red", 26 | "green", 27 | "yellow", 28 | "blue", 29 | "magenta", 30 | "cyan", 31 | "white", 32 | "bright_black", 33 | "bright_red", 34 | "bright_green", 35 | "bright_yellow", 36 | "bright_blue", 37 | "bright_magenta", 38 | "bright_cyan", 39 | "bright_white", 40 | ] 41 | 42 | Color: TypeAlias = TextColor | RGBColor 43 | Style: TypeAlias = Literal["reset", "bold", "italic", "underline", "defaultfg", "defaultbg"] 44 | 45 | _no_color = False 46 | 47 | 48 | class CodeMap(TypedDict): 49 | fore: dict[TextColor, int] 50 | back: dict[TextColor, int] 51 | style: dict[Style, int] 52 | 53 | 54 | code_map: CodeMap = { 55 | "fore": { 56 | "black": 30, 57 | "red": 31, 58 | "green": 32, 59 | "yellow": 33, 60 | "blue": 34, 61 | "magenta": 35, 62 | "cyan": 36, 63 | "white": 37, 64 | "bright_black": 90, 65 | "bright_red": 91, 66 | "bright_green": 92, 67 | "bright_yellow": 93, 68 | "bright_blue": 94, 69 | "bright_magenta": 95, 70 | "bright_cyan": 96, 71 | "bright_white": 97, 72 | }, 73 | "back": { 74 | "black": 40, 75 | "red": 41, 76 | "green": 42, 77 | "yellow": 43, 78 | "blue": 44, 79 | "magenta": 45, 80 | "cyan": 46, 81 | "white": 47, 82 | "bright_black": 100, 83 | "bright_red": 101, 84 | "bright_green": 102, 85 | "bright_yellow": 103, 86 | "bright_blue": 104, 87 | "bright_magenta": 105, 88 | "bright_cyan": 106, 89 | "bright_white": 107, 90 | }, 91 | "style": { 92 | "reset": 0, 93 | "bold": 1, 94 | "italic": 3, 95 | "underline": 4, 96 | "defaultfg": 39, 97 | "defaultbg": 49, 98 | }, 99 | } 100 | 101 | 102 | def colored_string( 103 | string: str, fore: Color | None = None, back: Color | None = None, style: list[Style] | None = None 104 | ) -> str: 105 | if _no_color: 106 | return string 107 | code_list: list[int] = [] 108 | 109 | if fore is not None: 110 | if isinstance(fore, str): 111 | code_list += [code_map["fore"][fore]] 112 | else: 113 | code_list += [38, 2, *fore] 114 | if back is not None: 115 | if isinstance(back, str): 116 | code_list += [code_map["back"][back]] 117 | else: 118 | code_list += [48, 2, *back] 119 | if style is not None: 120 | for s in style: 121 | code_list += [code_map["style"][s]] 122 | 123 | return f"{CSI}{';'.join(map(str, code_list))}m{string}{CSI}0m" 124 | 125 | 126 | def no_colored_string(string: str) -> str: 127 | """去除字符串中的颜色码""" 128 | regex_color = re.compile(r"\x1b\[(\d+;)*\d+m") 129 | string = regex_color.sub("", string) 130 | return string 131 | 132 | 133 | def set_no_color(): 134 | global _no_color 135 | _no_color = True 136 | -------------------------------------------------------------------------------- /src/yutto/utils/console/formatter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | from yutto.utils.console.colorful import no_colored_string 6 | 7 | 8 | def size_format(size: float, ndigits: int = 2, base_unit_size: Literal[1024, 1000] = 1024) -> str: 9 | """输入数据字节数,与保留小数位数,返回数据量字符串""" 10 | sign = "-" if size < 0 else "" 11 | size = abs(size) 12 | unit_list = ( 13 | ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "BiB"] 14 | if base_unit_size == 1024 15 | else ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "BB"] 16 | ) 17 | 18 | index = 0 19 | while index < len(unit_list) - 1: 20 | if size >= base_unit_size ** (index + 1): 21 | index += 1 22 | else: 23 | break 24 | return "{}{:.{}f} {}".format(sign, size / base_unit_size**index, ndigits, unit_list[index]) 25 | 26 | 27 | def get_char_width(char: str) -> int: 28 | """计算单个字符的宽度""" 29 | widths = [ 30 | (126, 1), (159, 0), (687, 1), (710, 0), (711, 1), 31 | (727, 0), (733, 1), (879, 0), (1154, 1), (1161, 0), 32 | (4347, 1), (4447, 2), (7467, 1), (7521, 0), (8369, 1), 33 | (8426, 0), (9000, 1), (9002, 2), (11021, 1), (12350, 2), 34 | (12351, 1), (12438, 2), (12442, 0), (19893, 2), (19967, 1), 35 | (55203, 2), (63743, 1), (64106, 2), (65039, 1), (65059, 0), 36 | (65131, 2), (65279, 1), (65376, 2), (65500, 1), (65510, 2), 37 | (120831, 1), (262141, 2), (1114109, 1), 38 | ] # fmt: skip 39 | 40 | o = ord(char) 41 | if o == 0xE or o == 0xF: 42 | return 0 43 | for num, wid in widths: 44 | if o <= num: 45 | return wid 46 | return 1 47 | 48 | 49 | def get_string_width(string: str) -> int: 50 | """计算包含中文的字符串宽度""" 51 | # 去除颜色码 52 | string = no_colored_string(string) 53 | try: 54 | length = sum([get_char_width(c) for c in string]) 55 | except Exception: 56 | length = len(string) 57 | return length 58 | -------------------------------------------------------------------------------- /src/yutto/utils/console/status_bar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from yutto.utils.console.formatter import get_string_width 4 | 5 | 6 | class StatusBar: 7 | _enabled = False 8 | tip = "" 9 | _snippers = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 10 | _count = 0 11 | _last_line_width = 0 12 | 13 | @classmethod 14 | def enable(cls): 15 | cls._enabled = True 16 | 17 | @classmethod 18 | def disable(cls): 19 | cls._enabled = False 20 | 21 | @classmethod 22 | def set_snippers(cls, snippers: list[str]): 23 | cls._snippers = snippers 24 | 25 | @classmethod 26 | def clear(cls): 27 | if not cls._enabled: 28 | return 29 | print("\r" + cls._last_line_width * " " + "\r", end="") 30 | 31 | @classmethod 32 | def set(cls, text: str): 33 | if not cls._enabled: 34 | return 35 | cls.clear() 36 | print(text, end="\r") 37 | cls._last_line_width = get_string_width(text) 38 | 39 | @classmethod 40 | def set_tip(cls, tip: str): 41 | cls.tip = tip 42 | 43 | @classmethod 44 | def next_tick(cls): 45 | cls.set(cls._snippers[cls._count] + " " + cls.tip) 46 | cls._count += 1 47 | cls._count %= len(cls._snippers) 48 | -------------------------------------------------------------------------------- /src/yutto/utils/danmaku.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Literal, TypeAlias, TypedDict 5 | 6 | from biliass import BlockOptions, convert_to_ass 7 | 8 | DanmakuSourceType = Literal["xml", "protobuf"] 9 | DanmakuSaveType = Literal["xml", "ass", "protobuf"] 10 | 11 | DanmakuSourceDataXml = str 12 | DanmakuSourceDataProtobuf = bytes 13 | DanmakuSourceDataType: TypeAlias = DanmakuSourceDataXml | DanmakuSourceDataProtobuf 14 | 15 | 16 | class DanmakuOptions(TypedDict): 17 | font_size: int | None 18 | font: str 19 | opacity: float 20 | display_region_ratio: float 21 | speed: float 22 | block_options: BlockOptions 23 | 24 | 25 | class DanmakuData(TypedDict): 26 | source_type: DanmakuSourceType | None 27 | save_type: DanmakuSaveType | None 28 | data: list[DanmakuSourceDataType] 29 | 30 | 31 | EmptyDanmakuData: DanmakuData = {"source_type": None, "save_type": None, "data": []} 32 | 33 | 34 | def write_xml_danmaku(xml_danmaku: str, filepath: Path): 35 | with filepath.open("w", encoding="utf-8") as f: 36 | f.write(xml_danmaku) 37 | 38 | 39 | def write_protobuf_danmaku(protobuf_danmaku: bytes, filepath: Path): 40 | with filepath.open("wb") as f: 41 | f.write(protobuf_danmaku) 42 | 43 | 44 | def write_ass_danmaku( 45 | danmaku: list[str | bytes], 46 | input_format: Literal["xml", "protobuf"], 47 | filepath: Path, 48 | height: int, 49 | width: int, 50 | options: DanmakuOptions, 51 | ): 52 | with filepath.open( 53 | "w", 54 | encoding="utf-8-sig", 55 | errors="replace", 56 | ) as f: 57 | f.write( 58 | convert_to_ass( 59 | danmaku, 60 | width, 61 | height, 62 | input_format=input_format, 63 | display_region_ratio=options["display_region_ratio"], 64 | font_face=options["font"], 65 | font_size=options["font_size"] if options["font_size"] is not None else width / 40, 66 | text_opacity=options["opacity"], 67 | duration_marquee=8.0 / options["speed"], 68 | duration_still=5.0 / options["speed"], 69 | block_options=options["block_options"], 70 | reduce_comments=True, 71 | ) 72 | ) 73 | 74 | 75 | def write_danmaku( 76 | danmaku: DanmakuData, 77 | video_path: str | Path, 78 | height: int, 79 | width: int, 80 | options: DanmakuOptions, 81 | ) -> str | None: 82 | video_path = Path(video_path) 83 | video_name = video_path.stem 84 | if danmaku["source_type"] == "xml": 85 | xml_danmaku = danmaku["data"] 86 | assert isinstance(xml_danmaku[0], str) 87 | if danmaku["save_type"] == "xml": 88 | file_path = video_path.with_suffix(".xml") 89 | write_xml_danmaku(xml_danmaku[0], file_path) 90 | elif danmaku["save_type"] == "ass": 91 | file_path = video_path.with_suffix(".ass") 92 | write_ass_danmaku(xml_danmaku, "xml", file_path, height, width, options) 93 | else: 94 | return None 95 | elif danmaku["source_type"] == "protobuf": 96 | protobuf_danmaku = danmaku["data"] 97 | assert isinstance(protobuf_danmaku[0], bytes) 98 | if danmaku["save_type"] == "ass": 99 | file_path = video_path.with_suffix(".ass") 100 | write_ass_danmaku(protobuf_danmaku, "protobuf", file_path, height, width, options) 101 | elif danmaku["save_type"] == "protobuf": 102 | if len(protobuf_danmaku) == 1: 103 | file_path = video_path.with_suffix(".pb") 104 | write_protobuf_danmaku(protobuf_danmaku[0], file_path) 105 | else: 106 | for i in range(len(protobuf_danmaku)): 107 | file_path = video_path.with_name(f"{video_name}_{i:02}.pb") 108 | protobuf_danmaku_item = protobuf_danmaku[i] 109 | assert isinstance(protobuf_danmaku_item, bytes) 110 | write_protobuf_danmaku(protobuf_danmaku_item, file_path) 111 | else: 112 | return None 113 | else: 114 | return None 115 | -------------------------------------------------------------------------------- /src/yutto/utils/file_buffer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import heapq 4 | from dataclasses import dataclass, field 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | import aiofiles 9 | 10 | from yutto.utils.console.logger import Logger 11 | from yutto.utils.funcutils import aobject 12 | 13 | if TYPE_CHECKING: 14 | from types import TracebackType 15 | 16 | from typing_extensions import Self 17 | 18 | 19 | @dataclass(order=True) 20 | class BufferChunk: 21 | offset: int 22 | data: bytes = field(compare=False) 23 | 24 | 25 | class AsyncFileBuffer(aobject): 26 | """异步文件缓冲区 27 | 28 | ### Args 29 | 30 | - file_path (str): 所需存储文件位置 31 | - overwrite (bool): 是否直接覆盖原文件 32 | 33 | ### Examples: 34 | 35 | ``` python 36 | async def afunc(): 37 | buffer = await AsyncFileBuffer("/path/to/file", True) 38 | for i, chunk in enumerate([b'0', b'1', b'2', b'3', b'4']): 39 | await buffer.write(chunk, i) 40 | await buffer.close() 41 | 42 | # 或者使用 async with(注意后面要有 await,因为 AsyncFileBuffer 的初始化是异步的) 43 | 44 | async with await AsyncFileBuffer("/path/to/file", True) as buffer: 45 | for i, chunk in enumerate([b'0', b'1', b'2', b'3', b'4']): 46 | await buffer.write(chunk, i) 47 | ``` 48 | """ 49 | 50 | async def __ainit__(self, file_path: str | Path, overwrite: bool = False): 51 | self.file_path = Path(file_path) 52 | if overwrite: 53 | self.file_path.unlink(missing_ok=True) 54 | self.buffer = list[BufferChunk]() 55 | self.written_size = self.file_path.stat().st_size if not overwrite and self.file_path.exists() else 0 56 | self.file_obj: aiofiles.threadpool.binary.AsyncBufferedIOBase | None = await aiofiles.open(file_path, "ab") 57 | 58 | async def write(self, chunk: bytes, offset: int): 59 | buffer_chunk = BufferChunk(offset, chunk) 60 | # 使用堆结构,保证第一个元素始终最小 61 | heapq.heappush(self.buffer, buffer_chunk) 62 | while self.buffer and self.buffer[0].offset <= self.written_size: 63 | assert self.file_obj is not None 64 | ready_to_write_chunk = heapq.heappop(self.buffer) 65 | if ready_to_write_chunk.offset < self.written_size: 66 | Logger.error(f"交叠的块范围 {ready_to_write_chunk.offset} < {self.written_size},舍弃!") 67 | continue 68 | await self.file_obj.write(ready_to_write_chunk.data) 69 | self.written_size += len(ready_to_write_chunk.data) 70 | 71 | async def close(self): 72 | if self.buffer: 73 | Logger.error("buffer 尚未清空") 74 | if self.file_obj is not None: 75 | await self.file_obj.close() 76 | else: 77 | Logger.error("未预期的结果:未曾创建文件对象") 78 | 79 | def __enter__(self) -> None: 80 | raise TypeError("Use async with instead") 81 | 82 | def __exit__( 83 | self, 84 | exc_type: type[BaseException] | None, 85 | exc: BaseException | None, 86 | tb: TracebackType | None, 87 | ) -> None: 88 | # __exit__ should exist in pair with __enter__ but never executed 89 | ... 90 | 91 | async def __aenter__(self) -> Self: 92 | return self 93 | 94 | async def __aexit__( 95 | self, 96 | exc_type: type[BaseException] | None, 97 | exc: BaseException | None, 98 | tb: TracebackType | None, 99 | ) -> None: 100 | await self.close() 101 | -------------------------------------------------------------------------------- /src/yutto/utils/filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import re 5 | 6 | from yutto.utils.console.logger import Logger 7 | 8 | 9 | class Filter: 10 | # NOTE(FrankHB): A workaround to https://bugs.python.org/issue31212. 11 | batch_filter_start_time: datetime.datetime = datetime.datetime(1971, 1, 1) 12 | batch_filter_end_time: datetime.datetime = datetime.datetime.now() + datetime.timedelta(days=1) 13 | 14 | @staticmethod 15 | def set_timer(key: str, user_input: str): 16 | """设置过滤器的时间""" 17 | timer: datetime.datetime | None = None 18 | if re.match(r"^\d{4}-\d{2}-\d{2}$", user_input): 19 | timer = datetime.datetime.strptime(user_input, "%Y-%m-%d") 20 | elif re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", user_input): 21 | timer = datetime.datetime.strptime(user_input, "%Y-%m-%d %H:%M:%S") 22 | else: 23 | Logger.error(f"稿件过滤参数: {user_input} 看不懂呢┭┮﹏┭┮,不会生效哦") 24 | return 25 | setattr(Filter, key, timer) 26 | 27 | @staticmethod 28 | def verify_timer(timestamp: int) -> bool: 29 | return Filter.batch_filter_start_time.timestamp() <= timestamp < Filter.batch_filter_end_time.timestamp() 30 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .aobject import aobject 4 | from .as_sync import as_sync 5 | from .data_access import data_has_chained_keys 6 | from .filter_none_value import filter_none_value 7 | from .singleton import Singleton 8 | from .xmerge import xmerge 9 | 10 | __all__ = [ 11 | "aobject", 12 | "Singleton", 13 | "as_sync", 14 | "filter_none_value", 15 | "xmerge", 16 | "data_has_chained_keys", 17 | ] 18 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/aobject.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class aobject: 7 | """Inheriting this class allows you to define an async __ainit__. 8 | 9 | ### Refs 10 | 11 | - https://stackoverflow.com/questions/33128325/how-to-set-class-attribute-with-await-in-init 12 | 13 | ### Examples 14 | 15 | ``` python 16 | class MyClass(aobject): 17 | # pyright: reportIncompatibleMethodOverride=false 18 | async def __ainit__(self): 19 | ... 20 | 21 | await MyClass() 22 | ``` 23 | """ 24 | 25 | async def __new__(cls, *args: Any, **kwargs: Any): 26 | instance = super().__new__(cls) 27 | await instance.__ainit__(*args, **kwargs) 28 | return instance 29 | 30 | async def __ainit__(self, *args: Any, **kwargs: Any): 31 | pass 32 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/as_sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from functools import wraps 5 | from typing import TYPE_CHECKING, Any, TypeVar 6 | 7 | from typing_extensions import ParamSpec 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Callable, Coroutine 11 | 12 | 13 | R = TypeVar("R") 14 | P = ParamSpec("P") 15 | 16 | 17 | def as_sync(async_func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, R]: 18 | """将异步函数变成同步函数,避免在调用时需要显式使用 asyncio.run 19 | 20 | ### Examples 21 | 22 | ``` python 23 | 24 | # 不使用 sync 25 | async def itoa(a: int) -> str: 26 | return str(a) 27 | 28 | s: str = asyncio.run(itoa(1)) 29 | 30 | # 使用 sync 31 | @as_sync 32 | async def itoa(a: int) -> str: 33 | return str(a) 34 | s: str = itoa(1) 35 | ``` 36 | """ 37 | 38 | @wraps(async_func) 39 | def sync_func(*args: P.args, **kwargs: P.kwargs) -> R: 40 | return asyncio.run(async_func(*args, **kwargs)) 41 | 42 | return sync_func 43 | 44 | 45 | if __name__ == "__main__": 46 | 47 | @as_sync 48 | async def run(a: int) -> int: 49 | return a 50 | 51 | print(run(1)) 52 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/data_access.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class Undefined: ... 7 | 8 | 9 | def data_has_chained_keys(data: Any, keys: list[str]) -> bool: 10 | if isinstance(data, Undefined): 11 | return False 12 | if not keys: 13 | return True 14 | if not isinstance(data, dict): 15 | return False 16 | key, *remaining_keys = keys 17 | return data_has_chained_keys(data.get(key, Undefined()), remaining_keys) # pyright: ignore[reportUnknownMemberType] 18 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/filter_none_value.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypeVar 4 | 5 | if TYPE_CHECKING: 6 | from collections.abc import Iterable 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | def filter_none_value(list_contains_some_none: Iterable[T | None]) -> Iterable[T]: 12 | """移除列表(迭代器)中的 None 13 | 14 | ### Examples 15 | 16 | ``` python 17 | l1 = [1, 2, 3, None, 5, None, 7] 18 | l2 = filter_none_value(l1) 19 | assert l2 == [1, 2, 3, 5, 7] 20 | ``` 21 | """ 22 | result: Iterable[T] = [] 23 | for item in list_contains_some_none: 24 | if item is not None: 25 | result.append(item) 26 | return result 27 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/functional.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TypeVar 4 | 5 | from returns.maybe import Maybe 6 | 7 | if TYPE_CHECKING: 8 | from collections.abc import Callable 9 | 10 | 11 | T = TypeVar("T") 12 | U = TypeVar("U") 13 | 14 | 15 | def map_optional(fn: Callable[[T], U], value: T | None) -> U | None: 16 | return Maybe.from_optional(value).map(fn).value_or(None) 17 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/singleton.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class Singleton(type): 9 | """单例模式元类 10 | 11 | ### Refs 12 | 13 | - https://stackoverflow.com/questions/6760685/creating-a-singleton-in-python 14 | 15 | ### Examples 16 | 17 | ``` python 18 | class MyClass(BaseClass, metaclass=Singleton): 19 | pass 20 | 21 | obj1 = MyClass() 22 | obj2 = MyClass() 23 | assert obj1 is obj2 24 | ``` 25 | """ 26 | 27 | _instances: dict[Any, Any] = {} 28 | 29 | def __call__(cls, *args: Any, **kwargs: Any): 30 | if cls not in cls._instances: 31 | cls._instances[cls] = super().__call__(*args, **kwargs) 32 | return cls._instances[cls] 33 | -------------------------------------------------------------------------------- /src/yutto/utils/funcutils/xmerge.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from itertools import chain, zip_longest 4 | from typing import TYPE_CHECKING, TypeVar 5 | 6 | from .filter_none_value import filter_none_value 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Iterable 10 | 11 | T = TypeVar("T") 12 | 13 | 14 | def xmerge(*multi_list: Iterable[T]) -> Iterable[T]: 15 | """将多个 list 交错地合并到一个 list 16 | 17 | ### Examples 18 | 19 | ``` python 20 | multi_list = [ 21 | [1, 2, 3, 4, 5], 22 | [6, 7, 8], 23 | [9, 10, 11, 12] 24 | ] 25 | xmerge(*multi_list) 26 | # [1, 6, 9, 2, 7, 10, 3, 8, 11, 4, 12, 5] 27 | ``` 28 | """ 29 | return filter_none_value(chain(*zip_longest(*multi_list))) 30 | -------------------------------------------------------------------------------- /src/yutto/utils/metadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, TypedDict, cast 4 | 5 | from dict2xml import dict2xml # pyright: ignore[reportUnknownVariableType,reportMissingTypeStubs] 6 | 7 | from yutto.utils.time import get_time_str_by_stamp 8 | 9 | if TYPE_CHECKING: 10 | from pathlib import Path 11 | 12 | 13 | class Actor(TypedDict): 14 | name: str 15 | role: str 16 | thumb: str 17 | profile: str 18 | order: int 19 | 20 | 21 | class ChapterInfoData(TypedDict): 22 | start: int 23 | end: int 24 | content: str 25 | 26 | 27 | class MetaData(TypedDict): 28 | title: str 29 | show_title: str 30 | plot: str 31 | thumb: str 32 | premiered: int 33 | dateadded: int 34 | actor: list[Actor] 35 | genre: list[str] 36 | tag: list[str] 37 | source: str 38 | original_filename: str 39 | website: str 40 | chapter_info_data: list[ChapterInfoData] 41 | 42 | 43 | def metadata_value_format(metadata: MetaData, metadata_format: dict[str, str]) -> dict[str, Any]: 44 | formatted_metadata: dict[str, Any] = {} 45 | for key, value in metadata.items(): 46 | if key in metadata_format: 47 | assert isinstance(value, int) 48 | value = get_time_str_by_stamp(value, metadata_format[key]) 49 | formatted_metadata[key] = value 50 | return formatted_metadata 51 | 52 | 53 | def write_metadata(metadata: MetaData, video_path: Path, metadata_format: dict[str, str]): 54 | metadata_path = video_path.with_suffix(".nfo") 55 | custom_root = "episodedetails" # TODO: 不同视频类型使用不同的 root name 56 | # 增加字段格式化内容,后续如果需要调整可以继续调整 57 | user_formatted_metadata = metadata_value_format(metadata, metadata_format) if metadata_format else metadata 58 | xml_content = cast("str", dict2xml(user_formatted_metadata, wrap=custom_root, indent=" ")) # pyright: ignore[reportUnknownVariableType] 59 | with metadata_path.open("w", encoding="utf-8") as f: 60 | f.write(xml_content) 61 | 62 | 63 | def attach_chapter_info(metadata: MetaData, chapter_info_data: list[ChapterInfoData]): 64 | metadata["chapter_info_data"] = chapter_info_data 65 | 66 | 67 | # https://wklchris.github.io/blog/FFmpeg/FFmpeg.html#id26 68 | def write_chapter_info(title: str, chapter_info_data: list[ChapterInfoData], chapter_path: Path): 69 | with chapter_path.open("w", encoding="utf-8") as f: 70 | f.write(";FFMETADATA1\n") 71 | f.write(f"title={title}\n") 72 | for chapter in chapter_info_data: 73 | f.write("[CHAPTER]\n") 74 | f.write("TIMEBASE=1/1\n") 75 | f.write(f"START={chapter['start']}\n") 76 | f.write(f"END={chapter['end']}\n") 77 | f.write(f"title={chapter['content']}\n") 78 | -------------------------------------------------------------------------------- /src/yutto/utils/priority.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def gen_priority_sequence(choice: int, num_choices: int) -> list[int]: 5 | """根据默认先降后升的机制生成序列 6 | 7 | 值得注意的是,默认的优先级序列应当满足从左向右兼容性逐渐提高,以保证默认策略不会影响兼容性 8 | - 在清晰度中,应当从左向右清晰度降低 9 | - 在编码方式中,应当从左向右兼容性提高,压缩率降低 10 | 11 | ### Args: 12 | 13 | - choice (int): 是当前选择的目标索引 14 | - num_choices (int): 是可选择目标数量 15 | 16 | """ 17 | 18 | assert choice >= 0 and choice < num_choices 19 | default_policy = list(range(num_choices)) 20 | 21 | return default_policy[choice:] + list(reversed(default_policy[:choice])) 22 | -------------------------------------------------------------------------------- /src/yutto/utils/subtitle.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TypedDict 5 | 6 | SubtitleLineData = TypedDict( 7 | "SubtitleLineData", 8 | { 9 | "content": str, 10 | "from": int, # This attribute is a keyword in Python, so it can not convert to class syntax 11 | "to": int, 12 | }, 13 | ) 14 | 15 | SubtitleData = list[SubtitleLineData] 16 | 17 | 18 | class Subtitle: 19 | """播放列表类""" 20 | 21 | def __init__(self): 22 | self._text = "" 23 | self._count = 0 24 | 25 | def write_line(self, string: str): 26 | self._text += string + "\n" 27 | 28 | @staticmethod 29 | def time_format(seconds: int): 30 | ms = int(1000 * (seconds - int(seconds))) 31 | seconds = int(seconds) 32 | minutes, sec = seconds // 60, seconds % 60 33 | hour, min = minutes // 60, minutes % 60 34 | return f"{hour:02}:{min:02}:{sec:02},{ms:03}" 35 | 36 | def write_subtitle(self, subtitle_line_data: SubtitleLineData) -> None: 37 | self._count += 1 38 | self.write_line(str(self._count)) 39 | self.write_line( 40 | "{} --> {}".format(self.time_format(subtitle_line_data["from"]), self.time_format(subtitle_line_data["to"])) 41 | ) 42 | self.write_line(subtitle_line_data["content"] + "\n") 43 | 44 | def __str__(self) -> str: 45 | return self._text 46 | 47 | 48 | def write_subtitle(subtitle_data: SubtitleData, video_path: Path, lang: str): 49 | video_path = Path(video_path) 50 | video_name = video_path.stem 51 | sub = Subtitle() 52 | subtitle_path = video_path.with_name(f"{video_name}_{lang}.srt") 53 | for subline in subtitle_data: 54 | sub.write_subtitle(subline) 55 | with subtitle_path.open("w", encoding="utf-8") as f: 56 | f.write(str(sub)) 57 | -------------------------------------------------------------------------------- /src/yutto/utils/time.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | 5 | TIME_FULL_FMT = "%Y-%m-%d %H:%M:%S" 6 | TIME_DATE_FMT = "%Y-%m-%d" 7 | 8 | 9 | def get_time_stamp_by_now() -> int: 10 | return int(time.time()) 11 | 12 | 13 | def get_time_str_by_now(fmt: str = TIME_FULL_FMT): 14 | time_stamp_now = time.time() 15 | return get_time_str_by_stamp(time_stamp_now, fmt) 16 | 17 | 18 | def get_time_str_by_stamp(stamp: float, fmt: str = TIME_FULL_FMT): 19 | local_time = time.localtime(stamp) 20 | return time.strftime(fmt, local_time) 21 | 22 | 23 | def get_time_struct_by_stamp(stamp: float): 24 | return time.localtime(stamp) 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import shutil 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | import pytest 9 | 10 | TEST_DIR = Path("./__test_files__") 11 | 12 | 13 | def pytest_sessionstart(session: pytest.Session): 14 | TEST_DIR.mkdir(exist_ok=True) 15 | 16 | 17 | def pytest_sessionfinish(session: pytest.Session, exitstatus: int): 18 | if TEST_DIR.exists(): 19 | shutil.rmtree(TEST_DIR) 20 | -------------------------------------------------------------------------------- /tests/test_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/tests/test_api/__init__.py -------------------------------------------------------------------------------- /tests/test_api/test_bangumi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto._typing import BvId, CId, EpisodeId, MediaId, SeasonId 6 | from yutto.api.bangumi import ( 7 | get_bangumi_list, 8 | get_bangumi_playurl, 9 | get_bangumi_subtitles, # pyright: ignore[reportUnusedImport] 10 | get_season_id_by_episode_id, 11 | get_season_id_by_media_id, 12 | ) 13 | from yutto.utils.fetcher import FetcherContext, create_client 14 | from yutto.utils.funcutils import as_sync 15 | 16 | 17 | @pytest.mark.api 18 | @as_sync 19 | async def test_get_season_id_by_media_id(): 20 | media_id = MediaId("28223066") 21 | season_id_excepted = SeasonId("28770") 22 | ctx = FetcherContext() 23 | async with create_client() as client: 24 | season_id = await get_season_id_by_media_id(ctx, client, media_id) 25 | assert season_id == season_id_excepted 26 | 27 | 28 | @pytest.mark.api 29 | @as_sync 30 | @pytest.mark.parametrize("episode_id", [EpisodeId("314477"), EpisodeId("300998")]) 31 | async def test_get_season_id_by_episode_id(episode_id: EpisodeId): 32 | season_id_excepted = SeasonId("28770") 33 | ctx = FetcherContext() 34 | async with create_client() as client: 35 | season_id = await get_season_id_by_episode_id(ctx, client, episode_id) 36 | assert season_id == season_id_excepted 37 | 38 | 39 | @pytest.mark.api 40 | @as_sync 41 | async def test_get_bangumi_title(): 42 | season_id = SeasonId("28770") 43 | ctx = FetcherContext() 44 | async with create_client() as client: 45 | title = (await get_bangumi_list(ctx, client, season_id))["title"] 46 | assert title == "我的三体之章北海传" 47 | 48 | 49 | @pytest.mark.api 50 | @as_sync 51 | async def test_get_bangumi_list(): 52 | season_id = SeasonId("28770") 53 | ctx = FetcherContext() 54 | async with create_client() as client: 55 | bangumi_list = (await get_bangumi_list(ctx, client, season_id))["pages"] 56 | assert bangumi_list[0]["id"] == 1 57 | assert bangumi_list[0]["name"] == "第1话" 58 | assert bangumi_list[0]["cid"] == CId("144541892") 59 | assert bangumi_list[0]["metadata"] is not None 60 | assert bangumi_list[0]["metadata"]["title"] == "第1话" 61 | 62 | assert bangumi_list[8]["id"] == 9 63 | assert bangumi_list[8]["name"] == "第9话" 64 | assert bangumi_list[8]["cid"] == CId("162395026") 65 | assert bangumi_list[8]["metadata"] is not None 66 | assert bangumi_list[8]["metadata"]["title"] == "第9话" 67 | 68 | 69 | @pytest.mark.api 70 | @pytest.mark.ci_skip 71 | @as_sync 72 | async def test_get_bangumi_playurl(): 73 | avid = BvId("BV1q7411v7Vd") 74 | cid = CId("144541892") 75 | ctx = FetcherContext() 76 | async with create_client() as client: 77 | playlist = await get_bangumi_playurl(ctx, client, avid, cid) 78 | assert len(playlist[0]) > 0 79 | assert len(playlist[1]) > 0 80 | 81 | 82 | @pytest.mark.api 83 | @as_sync 84 | async def test_get_bangumi_subtitles(): 85 | # TODO: 暂未找到需要字幕的番剧(非港澳台) 86 | pass 87 | -------------------------------------------------------------------------------- /tests/test_api/test_cheese.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto._typing import AId, AudioUrlMeta, CId, EpisodeId, SeasonId, VideoUrlMeta 6 | from yutto.api.cheese import ( 7 | get_cheese_list, 8 | get_cheese_playurl, 9 | get_season_id_by_episode_id, 10 | ) 11 | from yutto.utils.fetcher import FetcherContext, create_client 12 | from yutto.utils.funcutils import as_sync 13 | 14 | 15 | @pytest.mark.api 16 | @as_sync 17 | @pytest.mark.parametrize("episode_id", [EpisodeId("6945"), EpisodeId("6902")]) 18 | async def test_get_season_id_by_episode_id(episode_id: EpisodeId): 19 | season_id_excepted = SeasonId("298") 20 | ctx = FetcherContext() 21 | async with create_client() as client: 22 | season_id = await get_season_id_by_episode_id(ctx, client, episode_id) 23 | assert season_id == season_id_excepted 24 | 25 | 26 | @pytest.mark.api 27 | @as_sync 28 | async def test_get_cheese_title(): 29 | season_id = SeasonId("298") 30 | ctx = FetcherContext() 31 | async with create_client() as client: 32 | cheese_list = await get_cheese_list(ctx, client, season_id) 33 | title = cheese_list["title"] 34 | assert title == "林超:给年轻人的跨学科通识课" 35 | 36 | 37 | @pytest.mark.api 38 | @as_sync 39 | async def test_get_cheese_list(): 40 | season_id = SeasonId("298") 41 | ctx = FetcherContext() 42 | async with create_client() as client: 43 | cheese_list = (await get_cheese_list(ctx, client, season_id))["pages"] 44 | assert cheese_list[0]["id"] == 1 45 | assert cheese_list[0]["name"] == "【先导片】给年轻人的跨学科通识课" 46 | assert cheese_list[0]["cid"] == CId("344779477") 47 | 48 | assert cheese_list[25]["id"] == 26 49 | assert cheese_list[25]["name"] == "回到真实世界(下)" 50 | assert cheese_list[25]["cid"] == CId("506369050") 51 | 52 | 53 | @pytest.mark.api 54 | @pytest.mark.ci_skip 55 | @as_sync 56 | async def test_get_cheese_playurl(): 57 | avid = AId("545852212") 58 | episode_id = EpisodeId("6902") 59 | cid = CId("344779477") 60 | ctx = FetcherContext() 61 | async with create_client() as client: 62 | playlist: tuple[list[VideoUrlMeta], list[AudioUrlMeta]] = await get_cheese_playurl( 63 | ctx, client, avid, episode_id, cid 64 | ) 65 | assert len(playlist[0]) > 0 66 | assert len(playlist[1]) > 0 67 | 68 | 69 | @pytest.mark.api 70 | @as_sync 71 | async def test_get_cheese_subtitles(): 72 | # TODO: 暂未找到需要字幕的课程 73 | pass 74 | -------------------------------------------------------------------------------- /tests/test_api/test_collection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto._typing import BvId, MId, SeriesId 6 | from yutto.api.collection import get_collection_details 7 | from yutto.utils.fetcher import FetcherContext, create_client 8 | from yutto.utils.funcutils import as_sync 9 | 10 | 11 | @pytest.mark.api 12 | @as_sync 13 | async def test_get_collection_details(): 14 | # 测试页面:https://space.bilibili.com/6762654/channel/collectiondetail?sid=39879&ctype=0 15 | series_id = SeriesId("39879") 16 | mid = MId("6762654") 17 | ctx = FetcherContext() 18 | async with create_client() as client: 19 | collection_details = await get_collection_details(ctx, client, series_id=series_id, mid=mid) 20 | title = collection_details["title"] 21 | avids = [page["avid"] for page in collection_details["pages"]] 22 | assert title == "傻开心整活" 23 | assert BvId("BV1er4y1H7tQ") in avids 24 | assert BvId("BV1Yi4y1C7u6") in avids 25 | -------------------------------------------------------------------------------- /tests/test_api/test_danmaku.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto._typing import AvId, CId 6 | from yutto.api.danmaku import get_danmaku, get_protobuf_danmaku_segment, get_xml_danmaku 7 | from yutto.utils.fetcher import FetcherContext, create_client 8 | from yutto.utils.funcutils import as_sync 9 | 10 | 11 | @pytest.mark.api 12 | @as_sync 13 | async def test_xml_danmaku(): 14 | cid = CId("144541892") 15 | ctx = FetcherContext() 16 | async with create_client() as client: 17 | danmaku = await get_xml_danmaku(ctx, client, cid=cid) 18 | assert len(danmaku) > 0 19 | 20 | 21 | @pytest.mark.api 22 | @as_sync 23 | async def test_protobuf_danmaku(): 24 | cid = CId("144541892") 25 | ctx = FetcherContext() 26 | async with create_client() as client: 27 | danmaku = await get_protobuf_danmaku_segment(ctx, client, cid=cid, segment_id=1) 28 | assert len(danmaku) > 0 29 | 30 | 31 | @pytest.mark.api 32 | @as_sync 33 | async def test_danmaku(): 34 | cid = CId("144541892") 35 | avid = AvId("BV1q7411v7Vd") 36 | ctx = FetcherContext() 37 | async with create_client() as client: 38 | danmaku = await get_danmaku(ctx, client, cid=cid, avid=avid, save_type="ass") 39 | assert len(danmaku["data"]) > 0 40 | assert danmaku["source_type"] == "xml" 41 | assert danmaku["save_type"] == "ass" 42 | -------------------------------------------------------------------------------- /tests/test_api/test_space.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto._typing import AId, BvId, FId, MId, SeriesId 6 | from yutto.api.space import ( 7 | get_all_favourites, 8 | get_favourite_avids, 9 | get_favourite_info, 10 | get_medialist_avids, 11 | get_medialist_title, 12 | get_user_name, 13 | get_user_space_all_videos_avids, 14 | ) 15 | from yutto.utils.fetcher import FetcherContext, create_client 16 | from yutto.utils.funcutils import as_sync 17 | 18 | 19 | @pytest.mark.api 20 | @pytest.mark.ignore 21 | @as_sync 22 | async def test_get_user_space_all_videos_avids(): 23 | mid = MId("100969474") 24 | ctx = FetcherContext() 25 | async with create_client() as client: 26 | all_avid = await get_user_space_all_videos_avids(ctx, client, mid=mid) 27 | assert len(all_avid) > 0 28 | assert AId("371660125") in all_avid or BvId("BV1vZ4y1M7mQ") in all_avid 29 | 30 | 31 | @pytest.mark.api 32 | @pytest.mark.ignore 33 | @as_sync 34 | async def test_get_user_name(): 35 | mid = MId("100969474") 36 | ctx = FetcherContext() 37 | async with create_client() as client: 38 | username = await get_user_name(ctx, client, mid=mid) 39 | assert username == "时雨千陌" 40 | 41 | 42 | @pytest.mark.api 43 | @as_sync 44 | async def test_get_favourite_info(): 45 | fid = FId("1306978874") 46 | ctx = FetcherContext() 47 | async with create_client() as client: 48 | fav_info = await get_favourite_info(ctx, client, fid=fid) 49 | assert fav_info["fid"] == fid 50 | assert fav_info["title"] == "Test" 51 | 52 | 53 | @pytest.mark.api 54 | @as_sync 55 | async def test_get_favourite_avids(): 56 | fid = FId("1306978874") 57 | ctx = FetcherContext() 58 | async with create_client() as client: 59 | avids = await get_favourite_avids(ctx, client, fid=fid) 60 | assert AId("456782499") in avids or BvId("BV1o541187Wh") in avids 61 | 62 | 63 | @pytest.mark.api 64 | @as_sync 65 | async def test_all_favourites(): 66 | mid = MId("100969474") 67 | ctx = FetcherContext() 68 | async with create_client() as client: 69 | fav_list = await get_all_favourites(ctx, client, mid=mid) 70 | assert {"fid": FId("1306978874"), "title": "Test"} in fav_list 71 | 72 | 73 | @pytest.mark.api 74 | @as_sync 75 | async def test_get_medialist_avids(): 76 | series_id = SeriesId("1947439") 77 | mid = MId("100969474") 78 | ctx = FetcherContext() 79 | async with create_client() as client: 80 | avids = await get_medialist_avids(ctx, client, series_id=series_id, mid=mid) 81 | assert avids == [BvId("BV1Y441167U2"), BvId("BV1vZ4y1M7mQ")] 82 | 83 | 84 | @pytest.mark.api 85 | @as_sync 86 | async def test_get_medialist_title(): 87 | series_id = SeriesId("1947439") 88 | ctx = FetcherContext() 89 | async with create_client() as client: 90 | title = await get_medialist_title(ctx, client, series_id=series_id) 91 | assert title == "一个小视频列表~" 92 | -------------------------------------------------------------------------------- /tests/test_api/test_ugc_video.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto._typing import AId, BvId, CId, EpisodeId 6 | from yutto.api.ugc_video import ( 7 | get_ugc_video_info, 8 | get_ugc_video_list, 9 | get_ugc_video_playurl, 10 | get_ugc_video_subtitles, 11 | ) 12 | from yutto.utils.fetcher import FetcherContext, create_client 13 | from yutto.utils.funcutils import as_sync 14 | 15 | 16 | @pytest.mark.api 17 | @pytest.mark.ci_skip 18 | @as_sync 19 | async def test_get_ugc_video_info(): 20 | bvid = BvId("BV1q7411v7Vd") 21 | aid = AId("84271171") 22 | avid = bvid 23 | episode_id = EpisodeId("300998") 24 | ctx = FetcherContext() 25 | async with create_client() as client: 26 | video_info = await get_ugc_video_info(ctx, client, avid=avid) 27 | assert video_info["avid"] == aid or video_info["avid"] == bvid 28 | assert video_info["aid"] == aid 29 | assert video_info["bvid"] == bvid 30 | assert video_info["episode_id"] == episode_id 31 | assert video_info["is_bangumi"] is True 32 | assert video_info["cid"] == CId("144541892") 33 | assert video_info["title"] == "【独播】我的三体之章北海传 第1集" 34 | 35 | 36 | @pytest.mark.api 37 | @as_sync 38 | async def test_get_ugc_video_title(): 39 | avid = BvId("BV1vZ4y1M7mQ") 40 | ctx = FetcherContext() 41 | async with create_client() as client: 42 | title = (await get_ugc_video_list(ctx, client, avid))["title"] 43 | assert title == "用 bilili 下载 B 站视频" 44 | 45 | 46 | @pytest.mark.api 47 | @as_sync 48 | async def test_get_ugc_video_list(): 49 | avid = BvId("BV1vZ4y1M7mQ") 50 | ctx = FetcherContext() 51 | async with create_client() as client: 52 | ugc_video_list = (await get_ugc_video_list(ctx, client, avid))["pages"] 53 | assert ugc_video_list[0]["id"] == 1 54 | assert ugc_video_list[0]["name"] == "bilili 特性以及使用方法简单介绍" 55 | assert ugc_video_list[0]["cid"] == CId("222190584") 56 | assert ugc_video_list[0]["metadata"] is not None 57 | assert ugc_video_list[0]["metadata"]["title"] == "bilili 特性以及使用方法简单介绍" 58 | assert ugc_video_list[0]["metadata"]["website"] == "https://www.bilibili.com/video/BV1vZ4y1M7mQ" 59 | 60 | assert ugc_video_list[1]["id"] == 2 61 | assert ugc_video_list[1]["name"] == "bilili 环境配置方法" 62 | assert ugc_video_list[1]["cid"] == CId("222200470") 63 | assert ugc_video_list[1]["metadata"] is not None 64 | assert ugc_video_list[1]["metadata"]["title"] == "bilili 环境配置方法" 65 | assert ugc_video_list[0]["metadata"]["website"] == "https://www.bilibili.com/video/BV1vZ4y1M7mQ" 66 | 67 | 68 | @pytest.mark.api 69 | @pytest.mark.ci_skip 70 | @as_sync 71 | async def test_get_ugc_video_playurl(): 72 | avid = BvId("BV1vZ4y1M7mQ") 73 | cid = CId("222190584") 74 | ctx = FetcherContext() 75 | async with create_client() as client: 76 | playlist = await get_ugc_video_playurl(ctx, client, avid, cid) 77 | assert len(playlist[0]) > 0 78 | assert len(playlist[1]) > 0 79 | 80 | 81 | # The latest subtitle API needs login, so this test is skipped. 82 | # We need to find a way to test theses APIs. 83 | @pytest.mark.skip 84 | @pytest.mark.api 85 | @as_sync 86 | async def test_get_ugc_video_subtitles(): 87 | avid = BvId("BV1Ra411A7kN") 88 | cid = CId("253246252") 89 | ctx = FetcherContext() 90 | async with create_client() as client: 91 | subtitles = await get_ugc_video_subtitles(ctx, client, avid=avid, cid=cid) 92 | assert len(subtitles) > 0 93 | assert len(subtitles[0]["lines"]) > 0 94 | -------------------------------------------------------------------------------- /tests/test_api/test_user_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto.api.user_info import get_user_info 6 | from yutto.utils.fetcher import FetcherContext, create_client 7 | from yutto.utils.funcutils import as_sync 8 | 9 | 10 | @pytest.mark.api 11 | @as_sync 12 | async def test_get_user_info(): 13 | ctx = FetcherContext() 14 | async with create_client() as client: 15 | user_info = await get_user_info(ctx, client) 16 | assert not user_info["vip_status"] 17 | assert not user_info["is_login"] 18 | -------------------------------------------------------------------------------- /tests/test_biliass/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/tests/test_biliass/__init__.py -------------------------------------------------------------------------------- /tests/test_e2e.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | import sys 5 | 6 | import pytest 7 | 8 | from yutto.__version__ import VERSION as yutto_version 9 | 10 | from .conftest import TEST_DIR 11 | 12 | PYTHON = sys.executable 13 | 14 | 15 | @pytest.mark.e2e 16 | def test_version_e2e(): 17 | p = subprocess.run([PYTHON, "-m", "yutto", "-v"], capture_output=True, check=True) 18 | res = p.stdout.decode() 19 | assert res.strip().endswith(yutto_version) 20 | 21 | 22 | @pytest.mark.e2e 23 | @pytest.mark.ci_skip 24 | def test_bangumi_e2e(): 25 | short_bangumi = "https://www.bilibili.com/bangumi/play/ep100367" 26 | subprocess.run( 27 | [PYTHON, "-m", "yutto", short_bangumi, f"-d={TEST_DIR}", "-q=16", "-w"], 28 | capture_output=True, 29 | check=True, 30 | ) 31 | 32 | 33 | @pytest.mark.e2e 34 | def test_ugc_video_e2e(): 35 | short_ugc_video = "https://www.bilibili.com/video/BV1AZ4y147Yg" 36 | subprocess.run( 37 | [PYTHON, "-m", "yutto", short_ugc_video, f"-d={TEST_DIR}", "-q=16", "-w"], 38 | capture_output=True, 39 | check=True, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/test_processor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/tests/test_processor/__init__.py -------------------------------------------------------------------------------- /tests/test_processor/test_downloader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | import httpx 6 | import pytest 7 | 8 | from yutto.downloader.downloader import slice_blocks 9 | from yutto.utils.asynclib import CoroutineWrapper 10 | from yutto.utils.fetcher import Fetcher, FetcherContext, create_client 11 | from yutto.utils.file_buffer import AsyncFileBuffer 12 | from yutto.utils.funcutils import as_sync 13 | 14 | from ..conftest import TEST_DIR 15 | 16 | 17 | @pytest.mark.processor 18 | @as_sync 19 | async def test_150_kB_downloader(): 20 | # test_dir = "./downloader_test/" 21 | # url = "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4" 22 | # 因为 file-examples-com 挂掉了(GitHub 账号都消失了,因此暂时使用一个别处的 mirror) 23 | url = "https://github.com/nhegde610/samples-files/raw/main/file_example_MP4_480_1_5MG.mp4" 24 | file_path = TEST_DIR / "test_150_kB.pdf" 25 | ctx = FetcherContext() 26 | async with await AsyncFileBuffer(file_path, overwrite=False) as buffer: 27 | async with create_client( 28 | timeout=httpx.Timeout(7, connect=3), 29 | ) as client: 30 | ctx.set_download_semaphore(4) 31 | size = await Fetcher.get_size(ctx, client, url) 32 | coroutines = [ 33 | CoroutineWrapper(Fetcher.download_file_with_offset(ctx, client, url, [], buffer, offset, block_size)) 34 | for offset, block_size in slice_blocks(buffer.written_size, size, 1 * 1024 * 1024) 35 | ] 36 | 37 | print("开始下载……") 38 | await asyncio.gather(*coroutines) 39 | print("下载完成!") 40 | assert size == file_path.stat().st_size, "文件大小与实际大小不符" 41 | 42 | 43 | @pytest.mark.processor 44 | @as_sync 45 | async def test_150_kB_no_slice_downloader(): 46 | # test_dir = "./downloader_test/" 47 | # url = "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4" 48 | url = "https://github.com/nhegde610/samples-files/raw/main/file_example_MP4_480_1_5MG.mp4" 49 | file_path = TEST_DIR / "test_150_kB_no_slice.pdf" 50 | ctx = FetcherContext() 51 | async with await AsyncFileBuffer(file_path, overwrite=False) as buffer: 52 | async with create_client( 53 | timeout=httpx.Timeout(7, connect=3), 54 | ) as client: 55 | ctx.set_download_semaphore(4) 56 | size = await Fetcher.get_size(ctx, client, url) 57 | coroutines = [CoroutineWrapper(Fetcher.download_file_with_offset(ctx, client, url, [], buffer, 0, size))] 58 | 59 | print("开始下载……") 60 | await asyncio.gather(*coroutines) 61 | print("下载完成!") 62 | assert size == file_path.stat().st_size, "文件大小与实际大小不符" 63 | -------------------------------------------------------------------------------- /tests/test_processor/test_path_resolver.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto.path_resolver import create_unique_path_resolver 6 | 7 | 8 | @pytest.mark.processor 9 | def test_unique_path(): 10 | unique_path = create_unique_path_resolver() 11 | assert unique_path("a") == "a" 12 | assert unique_path("a") == "a (1)" 13 | assert unique_path("a") == "a (2)" 14 | 15 | assert unique_path("/xxx/yyy/zzz.ext") == "/xxx/yyy/zzz.ext" 16 | assert unique_path("/xxx/yyy/zzz.ext") == "/xxx/yyy/zzz (1).ext" 17 | assert unique_path("/xxx/yyy/zzz.ext") == "/xxx/yyy/zzz (2).ext" 18 | -------------------------------------------------------------------------------- /tests/test_processor/test_selector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from yutto.parser import ( 6 | parse_episodes_selection, 7 | validate_episodes_selection, 8 | ) 9 | 10 | 11 | @pytest.mark.processor 12 | def test_regex(): 13 | # 单个 14 | assert validate_episodes_selection("1") 15 | assert validate_episodes_selection("99") 16 | assert validate_episodes_selection("-1") 17 | assert validate_episodes_selection("-99") 18 | assert validate_episodes_selection("$") 19 | assert not validate_episodes_selection("") 20 | assert not validate_episodes_selection(" ") 21 | assert not validate_episodes_selection("x") 22 | assert not validate_episodes_selection("- 1") 23 | assert not validate_episodes_selection("1$") 24 | 25 | # 组合 26 | assert validate_episodes_selection("1,2") 27 | assert validate_episodes_selection("1,-2,3,-4") 28 | assert not validate_episodes_selection("1, 2") 29 | assert not validate_episodes_selection("1,") 30 | 31 | # 范围 32 | assert validate_episodes_selection("1~3") 33 | assert validate_episodes_selection("1~-1") 34 | assert validate_episodes_selection("-2~-1") 35 | assert not validate_episodes_selection("1~2~3") 36 | 37 | # 范围 + 组合 38 | assert validate_episodes_selection("1~2,9~$") 39 | assert validate_episodes_selection("0~10,12~14,-2~$") 40 | 41 | # 起止省略语法糖 42 | assert validate_episodes_selection("~2,9~") 43 | assert validate_episodes_selection("9~,~2") 44 | assert validate_episodes_selection("~") 45 | 46 | 47 | @pytest.mark.processor 48 | def test_single(): 49 | assert parse_episodes_selection("1", 24) == [1] 50 | assert parse_episodes_selection("11", 24) == [11] 51 | assert parse_episodes_selection("-1", 24) == [24] 52 | assert parse_episodes_selection("-10", 24) == [15] 53 | assert parse_episodes_selection("$", 24) == [24] 54 | assert parse_episodes_selection("25", 24) == [] 55 | 56 | 57 | @pytest.mark.processor 58 | def test_compose(): 59 | assert parse_episodes_selection("1,2,4", 24) == [1, 2, 4] 60 | assert parse_episodes_selection("11,14,15", 24) == [11, 14, 15] 61 | assert parse_episodes_selection("11,14,25", 24) == [11, 14] 62 | assert parse_episodes_selection("11,-1,$", 24) == [11, 24] 63 | assert parse_episodes_selection("$,-10", 24) == [15, 24] 64 | 65 | 66 | @pytest.mark.processor 67 | def test_range(): 68 | assert parse_episodes_selection("1~4", 24) == [1, 2, 3, 4] 69 | assert parse_episodes_selection("1~100", 6) == [1, 2, 3, 4, 5, 6] 70 | assert parse_episodes_selection("4~10", 6) == [4, 5, 6] 71 | assert parse_episodes_selection("2~-2", 6) == [2, 3, 4, 5] 72 | assert parse_episodes_selection("2~$", 6) == [2, 3, 4, 5, 6] 73 | 74 | 75 | @pytest.mark.processor 76 | def test_range_and_compose(): 77 | assert parse_episodes_selection("1~4,6~8", 24) == [1, 2, 3, 4, 6, 7, 8] 78 | assert parse_episodes_selection("1~4,2~6", 24) == [1, 2, 3, 4, 5, 6] 79 | assert parse_episodes_selection("1~4,5~6", 24) == [1, 2, 3, 4, 5, 6] 80 | assert parse_episodes_selection("1~4,5~6,8", 24) == [1, 2, 3, 4, 5, 6, 8] 81 | assert parse_episodes_selection("3,5~7,12,17", 24) == [3, 5, 6, 7, 12, 17] 82 | assert parse_episodes_selection("1~3,10,12~14,16,-4~$", 24) == [1, 2, 3, 10, 12, 13, 14, 16, 21, 22, 23, 24] 83 | 84 | 85 | @pytest.mark.processor 86 | def test_sugar(): 87 | assert parse_episodes_selection("~4,20~", 24) == parse_episodes_selection("1~4,20~24", 24) 88 | assert parse_episodes_selection("~4,20~$", 24) == parse_episodes_selection("1~4,20~24", 24) 89 | assert parse_episodes_selection("~", 24) == parse_episodes_selection("1~24", 24) 90 | -------------------------------------------------------------------------------- /tests/test_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/yutto/64a190571863e29ee2f0b57b34264f291dfa28d0/tests/test_utils/__init__.py -------------------------------------------------------------------------------- /tests/test_utils/test_data_access.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | from yutto.utils.funcutils.data_access import Undefined, data_has_chained_keys 8 | 9 | TEST_DATA: list[tuple[Any, list[str], bool]] = [ 10 | # basic 11 | ({"a": None}, ["a"], True), 12 | ({"a": Undefined()}, ["a"], False), 13 | ({"a": Undefined()}, ["a", "b"], False), 14 | ({"a": 1}, ["a"], True), 15 | ({"a": 1}, ["a", "b"], False), 16 | ({"a": 1}, ["b"], False), 17 | # nested 18 | ({"a": {"b": 1}}, ["a"], True), 19 | ({"a": {"b": 1}}, ["a", "b"], True), 20 | ({"a": {"b": 1}}, ["a", "b", "c"], False), 21 | ({"a": {"b": 1}}, ["a", "c", "b"], False), 22 | ({"a": {"b": 1}}, ["0", "1", "2"], False), 23 | ({"a": {"b": None}}, ["a", "b"], True), 24 | # not a dict 25 | (None, [], True), 26 | (1, [], True), 27 | ] 28 | 29 | 30 | @pytest.mark.parametrize("data, keys, expected", TEST_DATA) 31 | def test_data_has_chained_keys(data: Any, keys: list[str], expected: bool): 32 | assert data_has_chained_keys(data, keys) == expected 33 | --------------------------------------------------------------------------------