├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── renovate.json5 └── workflows │ ├── api-test.yml │ ├── e2e-test.yml │ ├── lint-and-fmt.yml │ ├── release.yml │ └── vuepress-deploy.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── .prettierrc ├── .vuepress │ ├── components │ │ ├── Contributors.vue │ │ ├── DeprecationNotice.vue │ │ └── GithubUser.vue │ ├── config.ts │ ├── public │ │ ├── cursor │ │ │ ├── default.cur │ │ │ └── link.cur │ │ ├── logo.png │ │ └── logo.svg │ └── styles │ │ ├── index.styl │ │ └── palette.styl ├── LICENSE ├── README.md ├── cli │ └── README.md ├── guide │ ├── README.md │ ├── faq.md │ ├── feedback.md │ ├── getting-started.md │ ├── knack.md │ ├── notice.md │ ├── thanks.md │ └── work-process.md ├── package.json ├── pnpm-lock.yaml ├── scripts │ └── get_latest_version.mjs └── sponsor.md ├── justfile ├── pyproject.toml ├── src └── bilili │ ├── __init__.py │ ├── __main__.py │ ├── __version__.py │ ├── api │ ├── LICENSE │ ├── __init__.py │ ├── acg_video.py │ ├── bangumi.py │ ├── danmaku.py │ ├── exceptions.py │ ├── utils.py │ └── vip.py │ ├── handlers │ ├── __init__.py │ ├── base.py │ ├── downloader.py │ ├── merger.py │ └── status.py │ ├── parser │ ├── __init__.py │ ├── acg_video.py │ └── bangumi.py │ ├── quality.py │ ├── tools.py │ ├── utils │ ├── __init__.py │ ├── base.py │ ├── console │ │ ├── __init__.py │ │ ├── colorful.py │ │ ├── logger.py │ │ └── ui.py │ ├── crawler.py │ ├── danmaku.py │ ├── ffmpeg.py │ ├── functiontools │ │ ├── __init__.py │ │ ├── attrdict.py │ │ └── singleton.py │ ├── playlist.py │ ├── subtitle.py │ └── thread.py │ └── video.py ├── tests ├── __init__.py ├── test_acg_video.py ├── test_bangumi.py ├── test_danmaku.py └── test_e2e.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] 15 | indent_size = 4 16 | 17 | [*.sh] 18 | indent_size = 4 19 | 20 | [*.md] 21 | indent_size = 3 22 | -------------------------------------------------------------------------------- /.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 bilili 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: "请在这里提供你所使用/调用 bilili 的方式。如果与特定 url 有关,请直接在命令中提供该 url。" 24 | placeholder: "注意在粘贴的命令中隐去所有隐私信息哦(*/ω\*)" 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: environment-info 29 | attributes: 30 | label: 环境信息 31 | description: 请尽可能详细地供以下信息~ 32 | placeholder: 你的环境信息~ 33 | value: | 34 | - OS: 操作系统类型及其版本号 35 | - Python: Python 版本号 (`python --version`) 36 | - bilili: bilili 版本号 (`bilili -v`) 37 | - FFmpeg: FFmpeg 版本号 (`ffmpeg -version`) 38 | - 如果是显示相关问题 39 | - Shell: Shell 类型 (`echo $SHELL`) 40 | - Terminal: 终端类型 41 | - Others: 其它信息 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: additional-context 46 | attributes: 47 | label: 额外信息 48 | description: 49 | placeholder: 如有额外的信息,请填写在这里~ 50 | validations: 51 | required: false 52 | - type: checkboxes 53 | id: checkboxes 54 | attributes: 55 | label: 一点点的自我检查 56 | description: 在你提交 issue 之前,麻烦确认自己是否已经完成了以下检查: 57 | options: 58 | - label: 充分阅读 [README.md](https://github.com/yutto-dev/bilili) 与[文档](https://bilili.nyakku.moe/),特别是与本 issue 相关的部分 59 | required: true 60 | - label: 如果是网络问题,已经检查网络连接、设置是否正常,并经过充分测试认为这是 bilili 本身的问题 61 | required: true 62 | - label: 本 issue 在 [issues](https://github.com/yutto-dev/bilili/issues) 和 [discussion](https://github.com/yutto-dev/bilili/discussions) 中并没有重复问题 63 | required: true 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Questions & Discussions 4 | url: https://github.com/yutto-dev/bilili/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 bilili 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/bilili) 与[文档](https://bilili.nyakku.moe/),特别是与本 issue 相关的部分 37 | required: true 38 | - label: 本 issue 在 [issues](https://github.com/yutto-dev/bilili/issues) 和 [discussion](https://github.com/yutto-dev/bilili/discussions) 中并没有重复问题 39 | required: true 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ## 概述 10 | 11 | 12 | 13 | 本 PR 的类型(至少选择一个) 14 | 15 | - [ ] :sparkles: feat: 添加新功能 16 | - [ ] :bug: fix: 修复 bug 17 | - [ ] :pencil: docs: 对文档进行修改 18 | - [ ] :art: style: 对代码语义无影响的格式修改(如去除无用空格、格式化等等修改) 19 | - [ ] :recycle: refactor: 代码重构(既不是新增功能,也不是修改 bug 的代码变动) 20 | - [ ] :zap: perf: 提高性能的代码修改 21 | - [ ] :white_check_mark: test: 测试用例添加及修改 22 | - [ ] :hammer: build: 影响构建系统或外部依赖关系的更改 23 | - [ ] :construction_worker: ci: 更改 CI 配置文件和脚本 24 | - [ ] :question: chore: 其它不涉及源码以及测试的修改 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | commit-message: 13 | prefix: "⬆️ deps: " 14 | - package-ecosystem: "npm" 15 | directory: "/docs/" 16 | schedule: 17 | interval: "daily" 18 | commit-message: 19 | prefix: "⬆️ deps: " 20 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | // https://docs.renovatebot.com/configuration-options/ 2 | { 3 | extends: ["github>SigureMo/renovate-config"], 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/api-test.yml: -------------------------------------------------------------------------------- 1 | name: API Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | merge_group: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test-crwaler: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 16 | name: unittest (api) - Python ${{ matrix.python-version }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | 24 | - name: Install python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | architecture: x64 29 | 30 | - name: Install just 31 | uses: extractions/setup-just@v2 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Install dependencies 36 | run: | 37 | just ci-install 38 | 39 | - name: Test bilili API 40 | run: | 41 | just ci-api-test 42 | -------------------------------------------------------------------------------- /.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-crwaler: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 16 | name: e2e test - Python ${{ matrix.python-version }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | 24 | - name: Install python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | architecture: x64 29 | 30 | - name: Install just 31 | uses: extractions/setup-just@v2 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Install tools 36 | run: | 37 | sudo apt update 38 | sudo apt install ffmpeg 39 | 40 | - name: Install dependencies 41 | run: | 42 | just ci-install 43 | 44 | - name: e2e without subprocess 45 | run: | 46 | just run -v 47 | just run -h 48 | just run https://www.bilibili.com/video/BV1AZ4y147Yg -w -y 49 | just clean 50 | 51 | - name: e2e test 52 | run: | 53 | just ci-e2e-test 54 | -------------------------------------------------------------------------------- /.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: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.9"] 16 | name: lint and format - Python ${{ matrix.python-version }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | 24 | - name: Install python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | architecture: x64 29 | 30 | - name: Install just 31 | uses: extractions/setup-just@v2 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Install dependencies 36 | run: | 37 | just ci-install 38 | 39 | - name: lint 40 | run: | 41 | just lint 42 | 43 | - name: format check 44 | run: | 45 | just fmt 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release-build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v3 18 | 19 | - name: Install python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.x" 23 | 24 | - name: Install just 25 | uses: extractions/setup-just@v2 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: build release distributions 30 | run: | 31 | just ci-install 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: Publish release distributions to PyPI 57 | uses: pypa/gh-action-pypi-publish@release/v1 58 | 59 | publish-release: 60 | runs-on: ubuntu-latest 61 | name: Publish to GitHub 62 | if: "startsWith(github.ref, 'refs/tags/')" 63 | needs: 64 | - release-build 65 | permissions: 66 | contents: write 67 | steps: 68 | - uses: actions/download-artifact@v4 69 | with: 70 | name: release-dists 71 | path: dist/ 72 | - name: Get tag name 73 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 74 | - name: Publish to GitHub 75 | uses: softprops/action-gh-release@v2 76 | with: 77 | draft: true 78 | files: dist/* 79 | tag_name: ${{ env.RELEASE_VERSION }} 80 | -------------------------------------------------------------------------------- /.github/workflows/vuepress-deploy.yml: -------------------------------------------------------------------------------- 1 | name: VuePress 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 | name: deploy-docs 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | submodules: true 20 | lfs: true 21 | 22 | - name: Install pnpm 23 | uses: pnpm/action-setup@v2 24 | with: 25 | package_json_file: "docs/package.json" 26 | version: "latest" 27 | 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: "22" 32 | cache: "pnpm" 33 | cache-dependency-path: "docs/pnpm-lock.yaml" 34 | 35 | - name: Install dependencies 36 | working-directory: ./docs 37 | run: pnpm i --frozen-lockfile 38 | 39 | - name: Build VuePress site 40 | working-directory: ./docs 41 | run: pnpm build 42 | 43 | - name: Deploy 44 | uses: peaceiris/actions-gh-pages@v4 45 | if: github.ref == 'refs/heads/main' 46 | with: 47 | personal_token: ${{ secrets.PERSONAL_TOKEN }} 48 | publish_dir: docs/.vuepress/dist 49 | external_repository: SigureMo/docs # 编译后直接转发到个人账户的 docs 50 | publish_branch: bilili 51 | force_orphan: true 52 | commit_message: ":rocket: deploy: " 53 | user_name: "github-actions[bot]" 54 | user_email: "github-actions[bot]@users.noreply.github.com" 55 | -------------------------------------------------------------------------------- /.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 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | .dmypy.json 109 | dmypy.json 110 | 111 | # Pyre type checker 112 | .pyre/ 113 | 114 | # Node.js 115 | node_modules/ 116 | 117 | # draft 118 | draft/ 119 | 120 | # IDEs/editors 121 | .vscode/ 122 | .idea/ 123 | 124 | # Others 125 | .ipynb_checkpoints 126 | .idea 127 | .DS_Store 128 | 129 | # videos base dir 130 | * - bilibili/ 131 | 132 | # ffmpeg 133 | ffmpeg/ 134 | tmp/ 135 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # bilili 贡献快速指南 2 | 3 | 很高兴你对参与 bilili 的贡献感兴趣,在提交你的贡献之前,请花一点点时间阅读本指南 4 | 5 | ## 本地调试 6 | 7 | 如果你想要本地调试,最佳的方案是从 github 上下载最新的源码来运行 8 | 9 | ```bash 10 | git clone git@github.com:yutto-dev/bilili.git 11 | cd bilili/ 12 | pip install -e ".[dev]" 13 | bilili 14 | ``` 15 | 16 | ## 测试 17 | 18 | bilili 有一些已经编写好的测试,虽然 GitHub Action 会帮忙自动测试,但最好你在本地预先测试一遍 19 | 20 | ```bash 21 | pytest # 使用 pytest 运行全量单测 22 | ``` 23 | 24 | 如果测试不通过,请查找相关错误原因,如果是测试代码过时,也欢迎对该代码进行修改 25 | 26 | ## 提交 PR 27 | 28 | 提交 PR 的最佳实践是 fork 一个新的 repo 到你的账户下,并创建一个新的分支,在该分支下进行改动后提交到 GitHub 上,并发起 PR(请注意在发起 PR 时不要取消勾选 `Allow edits from maintainers`) 29 | 30 | ```bash 31 | # 首先 fork 32 | git clone git@github.com:/bilili.git # 将你的 repo clone 到本地 33 | cd bilili/ # cd 到该文件夹 34 | git remote add upstream git@github.com:yutto-dev/bilili.git # 将原分支绑定在 upstream 35 | git checkout -b # 新建一个分支,名称随意,最好含有你本次改动的语义 36 | git push origin # 将该分支推送到 origin (也就是你 fork 后的 repo) 37 | # 对源码进行修改、并通过测试 38 | # 此时可以在 GitHub 发起 PR 39 | ``` 40 | 41 | 如果你的贡献需要继续修改,直接继续向该分支提交新的 commit 即可,并推送到 GitHub,PR 也会随之更新 42 | 43 | 如果你的 PR 已经被合并,就可以放心地删除这个分支了 44 | 45 | ```bash 46 | git checkout main # 切换到 main 47 | git fetch upstream # 将原作者分支下载到本地 48 | git merge upstream/main # 将原作者 main 分支最新内容合并到本地 main 49 | git branch -d # 删除本地分支 50 | git push origin --delete # 同时删除远程分支 51 | ``` 52 | 53 | ## PR 规范 54 | 55 | ### 标题 56 | 57 | 表明你所作的更改即可,没有太过苛刻的格式 58 | 59 | 如果可能,可以按照 ` : ` 来进行命名 60 | 61 | ### 内容 62 | 63 | 尽可能按照模板书写 64 | 65 | **因为有你,bilili 才会更加完善,感谢你的贡献** 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | bilili, bilibili video (including bangumi) and danmaku downloader 635 | Copyright (C) 2024 Nyakku Shigure 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | bilili Copyright (C) 2024 Nyakku Shigure 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 𝓫𝓲𝓵𝓲𝓵𝓲 2 | 3 |

4 | 5 |

6 | 7 |

8 | Bilibili 9 | PyPI - Python Version 10 | pypi 11 | PyPI - Downloads 12 | Build Status 13 | LICENSE 14 | Gitmoji 15 |

16 | 17 |

🍻 𝓫𝓲𝓵𝓲𝓵𝓲,一个可以帮你快速下载 B 站投稿视频以及番剧视频的 CLI~~~

18 | 19 | **文档戳这里啦 → [bilili の可愛い自己紹介](https://bilili.nyakku.moe)** 20 | 21 | > [!WARNING] 22 | > 23 | > 由于 B 站近期 API 的变动较大,导致 bilili 部分功能无法正常使用,相关问题已在 v2 版本([yutto](https://github.com/yutto-dev/yutto))中修复,限于个人精力,无法同时维护两个版本,所以建议大家尽快迁移至 v2 版本,感谢大家一直以来对 bilili 的支持~ 24 | 25 | ## 特性 26 | 27 | - 支持投稿视频(也即原 AV 号视频)和番剧下载 28 | - 多线程 + 分块下载,总之就是很快啦 29 | - 断点续传,即便一次没下完也可以接着下载 30 | - 弹幕支持,自动下载弹幕并可转换为 ASS 弹幕 31 | 32 | ## 快速开始 33 | 34 | `bilili` 可以从以下两种视频主页获取视频 35 | 36 | - 投稿视频主页: 37 | - `https://www.bilibili.com/video/avxxxxxx` 38 | - `https://b23.tv/avxxxxxx` 39 | - `https://www.bilibili.com/video/BVxxxxxx` 40 | - `https://b23.tv/BVxxxxxx` 41 | - 番剧视频主页: 42 | - `https://www.bilibili.com/bangumi/media/mdxxxxxx` 43 | - `https://www.bilibili.com/bangumi/play/ssxxxxxx` 44 | - `https://b23.tv/ssxxxxxx` 45 | - `https://www.bilibili.com/bangumi/play/epxxxxxx` 46 | - `https://b23.tv/epxxxxxx` 47 | 48 | ### 安装 FFmpeg 49 | 50 | 由于大多数格式需要合并,所以 bilili 依赖于 FFmpeg,你需要事先安装好它 51 | 52 | Windows 请[手动下载](https://ffmpeg.org/download.html)并解压后,存放到任意文件夹下,之后将 `ffmpeg.exe` 所在文件夹**添加到环境变量** 53 | 54 | 而如果是 macOS 或者 Linux 发行版,这一步可以很方便地通过包管理器一键完成啦~ 55 | 56 | 最后你可以通过直接在终端运行 `ffmpeg -version` 测试是否安装成功 57 | 58 | ### 安装 bilili 59 | 60 | #### pip 安装 61 | 62 | 现在 bilili 支持通过 pip 一键安装 63 | 64 | ```bash 65 | pip install bilili 66 | ``` 67 | 68 | #### 源码安装 69 | 70 | 此外你还可以从 GitHub 上下载最新的源码进行安装 71 | 72 | ```bash 73 | git clone git@github.com:yutto-dev/bilili.git 74 | cd bilili/ 75 | pip install . 76 | ``` 77 | 78 | ### 运行 79 | 80 | 你只需要这样就可以运行 bilili 啦~ 81 | 82 | ```bash 83 | bilili 84 | ``` 85 | 86 | 当然,你需要将 `` 替换为前面的视频主页 url 87 | 88 | ## 参数 89 | 90 | bilili 还支持很多参数,但参数使用方法等内容此处不作赘述,详情请访问[文档](https://bilili.nyakku.moe/cli/) 91 | 92 | - `-t`/`--type` 选择下载类型(`flv` or `dash` or `mp4`),默认为 dash 类型,注意该参数仅代表下载源格式,所有格式最后均会转为 mp4 93 | - `-d`/`--dir` 指定存储目录,默认为项目根目录 94 | - `-q`/`--quality` 指定清晰度,默认为 `127`(8K 超高清) 95 | - `-n`/`--num-threads` 指定最大下载线程数,默认为 16 96 | - `-p`/`--episodes` 选集,默认为 `^~$`(全选) 97 | - `-s`/`--with-section` 同时下载附加剧集( PV、预告以及特别篇等专区内容) 98 | - `-w`/`--overwrite` 强制覆盖已下载视频 99 | - `-c`/`--sess-data` 传入 `cookies` 中的 `SESSDATA` 100 | - `-y`/`--yes` 跳过下载询问 101 | - `--audio-quality` 指定音频质量等级,默认为 `30280`(320kbps) 102 | - `--playlist-type` 指定播放列表类型,支持 `dpl` 和 `m3u` ,默认为 `dpl`,设置为 `no` 即不生成播放列表 103 | - `--danmaku` 指定弹幕类型,支持 `xml` 和 `ass`,如果设置为 `no` 则不下载弹幕,默认为 `xml` 弹幕 104 | - `--block-size` 分块下载器的块大小,单位为 MB,默认为 128MB,设置为 0 时禁用分块下载 105 | - `--abs-path` 修改播放列表路径类型为绝对路径 106 | - `--use-mirrors` 启用从多个镜像下载功能 107 | - `--disable-proxy` 禁用系统代理 108 | - `--no-color` 不使用任何颜色 109 | - `--debug` 开启 `debug` 模式 110 | 111 | ## 参与贡献 112 | 113 | 请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 114 | -------------------------------------------------------------------------------- /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/.vuepress/components/Contributors.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /docs/.vuepress/components/DeprecationNotice.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /docs/.vuepress/components/GithubUser.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 31 | 32 | 81 | -------------------------------------------------------------------------------- /docs/.vuepress/config.ts: -------------------------------------------------------------------------------- 1 | import { ThemeConfig } from 'vuepress-theme-vt' 2 | import { defineConfig4CustomTheme } from 'vuepress/config' 3 | import path from 'path' 4 | 5 | const bilili_versions: { 6 | bilili: string 7 | yutto: string 8 | } = JSON.parse(process.env.BILILI_VERSIONS) 9 | 10 | export default defineConfig4CustomTheme({ 11 | title: 'bilili', 12 | description: '🍻 bilibili video and danmaku downloader', 13 | locales: { 14 | '/': { 15 | lang: 'zh-CN', 16 | title: 'bilili', 17 | description: '🍻 B站视频、弹幕下载器', 18 | }, 19 | }, 20 | 21 | head: [ 22 | ['meta', { property: 'og:url', content: 'https://bilili.nyakku.moe' }], 23 | ['meta', { property: 'og:site_name', content: 'bilili' }], 24 | ['meta', { property: 'og:image', content: '/logo.png' }], 25 | [ 26 | 'meta', 27 | { 28 | property: 'og:description', 29 | content: '🍻 bilibili video and danmaku downloader | B站视频、弹幕下载器', 30 | }, 31 | ], 32 | ['meta', { property: 'og:title', content: 'bilili' }], 33 | ], 34 | 35 | // @ts-ignore 36 | plugins: [ 37 | // 返回顶部 38 | ['@vuepress/back-to-top'], 39 | // 鼠标特效插件 40 | [ 41 | 'cursor-effects', 42 | { 43 | size: 1.75, 44 | shape: 'star', 45 | }, 46 | ], 47 | // 离开页面标题变化 48 | [ 49 | 'dynamic-title', 50 | { 51 | showText: '(๑‾᷅^‾᷅๑)哼,还知道回来!', 52 | hideText: '(〟-_・)ン?这就走了?', 53 | recoverTime: 2000, 54 | }, 55 | ], 56 | ], 57 | 58 | theme: 'vt', 59 | themeConfig: { 60 | enableDarkMode: true, 61 | nav: [ 62 | { text: '首页', link: '/' }, 63 | { text: '指南', link: '/guide/' }, 64 | { text: '参数', link: '/cli/' }, 65 | { 66 | text: `v${bilili_versions.bilili}`, 67 | items: [ 68 | { 69 | text: `v${bilili_versions.yutto}`, 70 | link: 'https://github.com/yutto-dev/yutto', 71 | }, 72 | ], 73 | }, 74 | { 75 | text: '支持我', 76 | items: [ 77 | { text: '赞助', link: '/sponsor' }, 78 | { 79 | text: '参与贡献', 80 | link: 'https://github.com/yutto-dev/bilili/blob/main/CONTRIBUTING.md', 81 | }, 82 | ], 83 | }, 84 | ], 85 | // @ts-ignore 86 | sidebar: { 87 | '/guide/': [ 88 | { 89 | title: '指南', 90 | collapsable: false, 91 | children: ['', 'getting-started', 'knack'], 92 | }, 93 | { 94 | title: '深入', 95 | collapsable: false, 96 | children: ['cli', 'work-process'], 97 | }, 98 | 'faq', 99 | 'feedback', 100 | 'notice', 101 | 'thanks', 102 | ], 103 | }, 104 | status: '', 105 | repo: 'yutto-dev/bilili', 106 | docsDir: 'docs', 107 | docsBranch: 'main', 108 | editLinks: true, 109 | editLinkText: '啊,我说错了?你可以帮我纠正哦~', 110 | }, 111 | 112 | // 插件 API 提供的额外路由 113 | additionalPages: [ 114 | { 115 | path: '/guide/cli.html', 116 | filePath: path.resolve(__dirname, '../cli/README.md'), 117 | }, 118 | ], 119 | }) 120 | -------------------------------------------------------------------------------- /docs/.vuepress/public/cursor/default.cur: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/docs/.vuepress/public/cursor/default.cur -------------------------------------------------------------------------------- /docs/.vuepress/public/cursor/link.cur: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/docs/.vuepress/public/cursor/link.cur -------------------------------------------------------------------------------- /docs/.vuepress/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/docs/.vuepress/public/logo.png -------------------------------------------------------------------------------- /docs/.vuepress/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | image/svg+xml 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/.vuepress/styles/index.styl: -------------------------------------------------------------------------------- 1 | // 鼠标指针 2 | body 3 | cursor url('/cursor/default.cur'), auto 4 | 5 | a, 6 | button, 7 | a.vp-link.prev, 8 | a.vp-link.next, 9 | .custom-block.details summary, 10 | .dropdown-wrapper button, 11 | #app > div.global-ui > svg, 12 | #app > div.theme-container > header > div > div.links > div > svg, 13 | #app > div.theme-container > header > div > div.links > nav button.dropdown-title 14 | cursor url('/cursor/link.cur'), auto 15 | 16 | // 主页 Banner 17 | #hero > div.heroText.content__heroText 18 | display: block 19 | -------------------------------------------------------------------------------- /docs/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | :root { 2 | --vp-c-brand: #00a1d6; 3 | --vp-c-brand-light: #00a1d6; 4 | --vp-c-brand-dark: #4f76c9; 5 | } 6 | -------------------------------------------------------------------------------- /docs/LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal 2 | 3 | Statement of Purpose 4 | 5 | The laws of most jurisdictions throughout the world automatically confer 6 | exclusive Copyright and Related Rights (defined below) upon the creator and 7 | subsequent owner(s) (each and all, an "owner") of an original work of 8 | authorship and/or a database (each, a "Work"). 9 | 10 | Certain owners wish to permanently relinquish those rights to a Work for the 11 | purpose of contributing to a commons of creative, cultural and scientific 12 | works ("Commons") that the public can reliably and without fear of later 13 | claims of infringement build upon, modify, incorporate in other works, reuse 14 | and redistribute as freely as possible in any form whatsoever and for any 15 | purposes, including without limitation commercial purposes. These owners may 16 | contribute to the Commons to promote the ideal of a free culture and the 17 | further production of creative, cultural and scientific works, or to gain 18 | reputation or greater distribution for their Work in part through the use and 19 | efforts of others. 20 | 21 | For these and/or other purposes and motivations, and without any expectation 22 | of additional consideration or compensation, the person associating CC0 with a 23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 | and publicly distribute the Work under its terms, with knowledge of his or her 26 | Copyright and Related Rights in the Work and the meaning and intended legal 27 | effect of CC0 on those rights. 28 | 29 | 1. Copyright and Related Rights. A Work made available under CC0 may be 30 | protected by copyright and related or neighboring rights ("Copyright and 31 | Related Rights"). Copyright and Related Rights include, but are not limited 32 | to, the following: 33 | 34 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 | and translate a Work; 36 | 37 | ii. moral rights retained by the original author(s) and/or performer(s); 38 | 39 | iii. publicity and privacy rights pertaining to a person's image or likeness 40 | depicted in a Work; 41 | 42 | iv. rights protecting against unfair competition in regards to a Work, 43 | subject to the limitations in paragraph 4(a), below; 44 | 45 | v. rights protecting the extraction, dissemination, use and reuse of data in 46 | a Work; 47 | 48 | vi. database rights (such as those arising under Directive 96/9/EC of the 49 | European Parliament and of the Council of 11 March 1996 on the legal 50 | protection of databases, and under any national implementation thereof, 51 | including any amended or successor version of such directive); and 52 | 53 | vii. other similar, equivalent or corresponding rights throughout the world 54 | based on applicable law or treaty, and any national implementations thereof. 55 | 56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 | and Related Rights and associated claims and causes of action, whether now 60 | known or unknown (including existing as well as future claims and causes of 61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 | duration provided by applicable law or treaty (including future time 63 | extensions), (iii) in any current or future medium and for any number of 64 | copies, and (iv) for any purpose whatsoever, including without limitation 65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 | the Waiver for the benefit of each member of the public at large and to the 67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 | shall not be subject to revocation, rescission, cancellation, termination, or 69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 | by the public as contemplated by Affirmer's express Statement of Purpose. 71 | 72 | 3. Public License Fallback. Should any part of the Waiver for any reason be 73 | judged legally invalid or ineffective under applicable law, then the Waiver 74 | shall be preserved to the maximum extent permitted taking into account 75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 | is so judged Affirmer hereby grants to each affected person a royalty-free, 77 | non transferable, non sublicensable, non exclusive, irrevocable and 78 | unconditional license to exercise Affirmer's Copyright and Related Rights in 79 | the Work (i) in all territories worldwide, (ii) for the maximum duration 80 | provided by applicable law or treaty (including future time extensions), (iii) 81 | in any current or future medium and for any number of copies, and (iv) for any 82 | purpose whatsoever, including without limitation commercial, advertising or 83 | promotional purposes (the "License"). The License shall be deemed effective as 84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 | License for any reason be judged legally invalid or ineffective under 86 | applicable law, such partial invalidity or ineffectiveness shall not 87 | invalidate the remainder of the License, and in such case Affirmer hereby 88 | affirms that he or she will not (i) exercise any of his or her remaining 89 | Copyright and Related Rights in the Work or (ii) assert any associated claims 90 | and causes of action with respect to the Work, in either case contrary to 91 | Affirmer's express Statement of Purpose. 92 | 93 | 4. Limitations and Disclaimers. 94 | 95 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 | surrendered, licensed or otherwise affected by this document. 97 | 98 | b. Affirmer offers the Work as-is and makes no representations or warranties 99 | of any kind concerning the Work, express, implied, statutory or otherwise, 100 | including without limitation warranties of title, merchantability, fitness 101 | for a particular purpose, non infringement, or the absence of latent or 102 | other defects, accuracy, or the present or absence of errors, whether or not 103 | discoverable, all to the greatest extent permissible under applicable law. 104 | 105 | c. Affirmer disclaims responsibility for clearing rights of other persons 106 | that may apply to the Work or any use thereof, including without limitation 107 | any person's Copyright and Related Rights in the Work. Further, Affirmer 108 | disclaims responsibility for obtaining any necessary consents, permissions 109 | or other rights required for any use of the Work. 110 | 111 | d. Affirmer understands and acknowledges that Creative Commons is not a 112 | party to this document and has no duty or obligation with respect to this 113 | CC0 or use of the Work. 114 | 115 | For more information, please see 116 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | actionText: 可以开始了哟 4 | actionLink: /guide/ 5 | features: 6 | - title: 极速下载 7 | details: 多线程 + 分块下载,总之就是很快啦~ 8 | - title: 弹幕支持 9 | details: 自动下载弹幕并可转换为 ass 弹幕~ 10 | - title: 断点续传 11 | details: 即便一次没下完也可以接着下载~ 12 | --- 13 | 14 | ::: slot heroText 15 | 𝓫𝓲𝓵𝓲𝓵𝓲 16 | ::: 17 | 18 | ::: slot tagline 19 | 🍻 bilibili video and danmaku downloader | B 站视频、弹幕下载器 20 | ::: 21 | 22 | ::: slot footer 23 | Released under the GPT-3.0 License.
24 | Copyright © 2020-present Nyakku Shigure 25 | ::: 26 | -------------------------------------------------------------------------------- /docs/cli/README.md: -------------------------------------------------------------------------------- 1 | # 命令行参数 2 | 3 | 我能做到的远不止你看到的这些哦~ 4 | 5 | ::: details 参数的使用方法 6 | 7 | 如果你经常使用命令行工具的话,这部分直接跳过就好啦~ 8 | 9 | - 指定参数值 10 | 11 | 比如你需要修改下载格式为 flv,只需要 12 | 13 | ```bash 14 | bilili --type=flv 15 | # 或者 16 | bilili -t flv 17 | # 当然你也可以不拘泥于这样的用法,下面这两种当然也是可以的 18 | bilili -t=flv 19 | bilili --type flv 20 | ``` 21 | 22 | - 切换 `True` or `False` 23 | 24 | 对于那些不需要指定具体值,只切换 `True` or `False` 的参数,你也不需要在命令中指定值,比如开启强制覆盖已下载视频选项 25 | 26 | ```bash 27 | bilili --overwrite 28 | # 或者 29 | bilili -w 30 | ``` 31 | 32 | - 多参数同时使用 33 | 34 | 直接向后加即可,而且 `` 和其它参数都不强制要求顺序,比如下面这些命令都是合法的 35 | 36 | ```bash 37 | bilili --overwrite --type=flv 38 | bilili --overwrite -t flv 39 | bilili -w --type=flv 40 | ``` 41 | 42 | ::: 43 | 44 | ## 源格式修改 45 | 46 | - 参数 `-t` 或 `--type` 47 | - 可选值 `flv | dash | mp4` 48 | - 默认值 `dash` 49 | 50 | 视频格式是指 bilibili 直接提供的资源格式,不过我最终都会转换成通用的 `mp4` 格式方便观看的啦,不同格式在通用性、下载速度等方面的比较如下 51 | 52 | 53 | ||dash|flv|mp4| 54 | |:-:|:-:|:-:|:-:| 55 | |支持程度|中(少数视频不支持)|高|低(仅支持投稿视频)| 56 | |下载速度|高|低|中| 57 | |需要 FFmpeg 合并|是|是|否| 58 | |清晰度支持|全面|中(部分较新的 4K 等清晰度无法获取)|极少(仅支持 1080P 及更低的清晰度,且无法选择)| 59 | |备用链接数量|2|2|0| 60 | |我该怎么选|B 站当前使用的格式,拥有齐全的清晰度和最佳的下载速度|当 dash 无法下载时的备用选项|除了不需要合并,一无是处| 61 | 62 | ::: tip 63 | 64 | 为了提高项目的可维护性,flv 格式与 mp4 格式支持将会在未来删除。 65 | 66 | ::: 67 | 68 | ## 指定存储目录 69 | 70 | - 参数 `-d` 或 `--dir` 71 | - 默认值 运行目录 72 | 73 | 也就是指定我要把视频放到哪里啦,不告诉我的话,就只能放在你的运行目录了。 74 | 75 | ## 指定视频清晰度 76 | 77 | - 参数 `-q` 或 `--quality` 78 | - 可选值 `127 | 125 | 120 | 116 | 116 | 112 | 80 | 74 | 64 | 32 | 16` 79 | - 默认值 `127` 80 | 81 | 清晰度对应关系如下 82 | 83 | 84 | |code|清晰度| 85 | |:-:|:-:| 86 | |127|8K 超高清| 87 | |125|HDR 真彩| 88 | |120|4K 超清| 89 | |116|1080P 60帧| 90 | |112|1080P 高码率| 91 | |80|1080P 高清| 92 | |74|720P 60帧| 93 | |64|720P 高清| 94 | |32|480P 清晰| 95 | |16|360P 流畅| 96 | 97 | 并不是说指定某个清晰度就一定会下载该清晰度的视频,我只会尽可能满足你的要求,如果不存在指定的清晰度,我就会按照默认的清晰度搜索机制进行调节,比如指定清晰度为 `80`,**我首先会依次降清晰度搜索** `74`、`64`、`32`、`16`,如果依然找不到合适的则**继续升清晰度搜索** `112`、`116`、`120`、`125`、`127`。 98 | 99 | ## 修改下载线程最大数量 100 | 101 | - 参数 `-n` 或 `--num-threads` 102 | - 默认值 `16` 103 | 104 | 也就是我的下载分身数量咯,越多的话,同时下载的视频块就越多,但并不是说分身越多就越好哟,如果你的带宽不够,分身再多也木有用滴。 105 | 106 | ## 指定需要下载的剧集 107 | 108 | - 参数 `-p` 或 `--episodes` 109 | - 默认值 `^~$`(也即全选) 110 | 111 | 也就是选集咯,其语法是这样的 112 | 113 | - `` 单独下某一剧集 114 | - 支持负数来选择倒数第几话 115 | - 此外还可以使用 `^` 与 `$` 来分别代表 `1` 与 `-1` 116 | - `~` 使用 `~` 可以连续选取 117 | - `,,,...,` 使用 `,` 可以不连续选取 118 | 119 | emmm,直接看的话大概并不能知道我在说什么,所以我们通过几个小例子来了解其语法 120 | 121 | ```bash 122 | # 假设要下载一个具有 24 话的番剧 123 | # 如果我们只想下载第 3 话,只需要这样 124 | bilili -p 3 125 | # 那如果我想下载第 5 话到第 7 话呢,使用 `~` 可以连续选中 126 | bilili -p 5~7 127 | # 那我想下载第 12 话和第 17 话又要怎么办?此时只需要 `,` 就可以将多个不连续的选集一起选中 128 | bilili -p 12,17 129 | # 那我突然又想将刚才那些都选中了呢?还是使用 `,` 呀,将它们连在一起即可 130 | bilili -p 3,5~7,12,17 131 | # 嗯,你已经把基本用法都了解过了,很简单吧~ 132 | # 下面是一些语法糖,不了解也完全不会影响任何功能哒~ 133 | # 那如果我只知道我想下载倒数第 3 话,而不想算倒数第三话是第几话应该怎么办? 134 | # 此时可以用负数哒~不过要注意的是,开头如果是 `-` 的话前面应该使用 `=` 135 | bilili -p=-3 136 | # 那么如果想下载最后一话你可能会想到 `-p=-1` 对吧?不过我内置了两个符号分别代表第一话(^)和最后一话($) 137 | # 像下面这样就可以直接下载最后一话啦~ 138 | bilili -p $ 139 | # 所有语法都了解完啦,我们看一个稍微复杂的例子 140 | bilili -p ^~3,10,12~14,16,-4~$ 141 | # 很明显,上面的例子就是下载前 3 话、第 10 话、第 12 到 14 话、第 16 话以及后 4 话 142 | ``` 143 | 144 | ::: tip 一些要注意的问题 145 | 146 | 1. 这里使用的序号是视频的顺序序号,而不是番剧所标注的`第 n 话`,因为有可能会出现 `第 x.5 话` 等等的特殊情况,此时一定要按照顺序自行计数。 147 | 2. 参数值里一定不要加空格 148 | 3. 参数值开头为 `-` 时前面应该使用 `=` 而非空格 149 | 150 | ::: 151 | 152 | ## 同时下载附加剧集 153 | 154 | - 参数 `-s` 或 `--with-section` 155 | - 默认值 `False` 156 | 157 | 默认是不会下载 PV、预告以及特别篇等专区内容部分的,指定该参数才可以下载哦~ 158 | 159 | ::: tip 与选集功能的组合 160 | 161 | 值得注意的是,该部分与番剧剧集部分统一编号,且位于番剧剧集后面 162 | 163 | 比如说一个 11 话的番剧,此外有额外 2 话附加剧集,如果只需要下载附加剧集的话只需要使用下面的命令即可: 164 | 165 | ```bash 166 | bilili -p -2~$ 167 | ``` 168 | 169 | ::: 170 | 171 | ## 强制覆盖已下载视频 172 | 173 | - 参数 `-w` 或 `--overwrite` 174 | - 默认值 `False` 175 | 176 | 也就是强制将已经下载过的部分覆盖掉啦。 177 | 178 | ## 个人信息认证 179 | 180 | - 参数 `-c` 或 `--sess-data` 181 | - 默认值 `""` 182 | 183 | 使用个人认证可以让你下载更高清晰度以及更多的剧集,当你传入你的大会员 `SESSDATA` 时(当然前提是你是大会员),你就可以下载大会员可访问的资源咯。 184 | 185 | ::: details SESSDATA 获取方式 186 | 187 | 这里用 Chrome 作为示例,其它浏览器请尝试类似方法。 188 | 189 | 首先,用你的帐号登录 B 站,然后随便打开一个 B 站网页,比如[首页](https://www.bilibili.com/)。 190 | 191 | 按 F12 打开开发者工具,切换到 Network 栏,刷新页面,此时第一个加载的资源应该就是当前页面的 html,选中该资源,在右侧 「Request Headers」 中找到 「cookie」,在其中找到类似于 `SESSDATA=d8bc7493%2C2843925707%2C08c3e*81;` 的一串字符串,复制这里的 `d8bc7493%2C2843925707%2C08c3e*81`,这就是你需要的 `SESSDATA`。 192 | 193 | ::: 194 | 195 | ::: tip 196 | 197 | SESSDATA 中可能有特殊符号,所以传入时你可能需要使用双引号来包裹 198 | 199 | ```bash 200 | bilili -c "d8bc7493%2C2843925707%2C08c3e*81" 201 | ``` 202 | 203 | ::: 204 | 205 | ## 跳过下载询问 206 | 207 | - 参数 `-y` 或 `--yes` 208 | - 默认值 `False` 209 | 210 | 跳过下载前的询问。 211 | 212 | ## 指定音频码率等级 213 | 214 | - 参数 `--audio-quality` 215 | - 可选值 `30280 | 30232 | 30216` 216 | - 默认值 `30280` 217 | 218 | 码率对应关系如下 219 | 220 | 221 | |code|码率| 222 | |:-:|:-:| 223 | |30280|320kbps| 224 | |30232|128kbps| 225 | |30216|64kbps| 226 | 227 | 码率自动调节机制与视频清晰度一致,也采用先降后升的匹配机制。 228 | 229 | ## 指定播放列表类型 230 | 231 | - 参数 `--playlist-type` 232 | - 可选值 `dpl | m3u | no` 233 | - 默认值 `dpl` 234 | 235 | `dpl` 是 PotPlayer 的专属播放列表格式,PotPlayer 可以在其中保存进度等。 236 | 237 | 而 `m3u` 有着更好的通用性,大多数播放器都支持。 238 | 239 | 当然指定 `no` 就是不生成播放列表。 240 | 241 | ## 指定下载弹幕类型 242 | 243 | - 参数 `--danmaku` 244 | - 可选值 `xml | ass | no` 245 | - 默认值 `xml` 246 | 247 | B 站只提供 `xml` 格式的弹幕,因此我默认会下载 `xml` 格式的弹幕,但本地播放器一般不支持 B 站提供的默认弹幕,因此需要你手动转换,比如使用 [us-danmaku](https://tiansh.github.io/us-danmaku/bilibili/) 在线转换。 248 | 249 | 其实我也可以帮你自动转换哒,只需要指定值为 `ass` 就好啦。 250 | 251 | 当然指定为 `no` 就是不需要弹幕咯。 252 | 253 | ## 指定分块下载时块的大小 254 | 255 | - 参数 `--block-size` 256 | - 默认值 `128` 257 | 258 | 因为内置分块下载机制,该参数就是指定分块下载的块大小咯,单位为 `MB`,当设置为 `0` 时可以禁用分块下载功能。 259 | 260 | ## 修改播放列表路径类型为绝对路径 261 | 262 | - 参数 `--abs-path` 263 | - 默认值 `False` 264 | 265 | 播放列表默认使用的是相对路径,这样即便移动下载后的文件夹也可以正常播放。 266 | 267 | 但偶尔有些播放器不支持相对路径的播放列表,所以提供了该选项来指定为绝对路径。当然,为了播放列表的灵活性,你应当只在发生本情况的前提下修改本参数。 268 | 269 | ## 启用从多个镜像源下载功能 270 | 271 | - 参数 `--use-mirrors` 272 | - 默认值 `False` 273 | 274 | 从多个镜像下载,当然,由于我的每个分身(子线程)只处理一个块,所以被分为多个块的资源才有效哦。 275 | 276 | ## 绕过系统代理 277 | 278 | - 参数 `--disable-proxy` 279 | - 默认值 `False` 280 | 281 | 因为我的依赖 requests 库会自动使用系统代理,使用本参数就可以绕过它啦。 282 | 283 | ## 不显示任何颜色 284 | 285 | - 参数 `--no-color` 286 | - 默认值 `False` 287 | 288 | 不显示任何颜色,除了使用参数 `--no-color` 外,设定环境变量 `NO_COLOR` 为非空值也可以达到同样的效果,可参照 。 289 | 290 | ## 开启 Debug 模式 291 | 292 | - 参数 `--debug` 293 | - 默认值 `False` 294 | 295 | 对 debug 更友好的模式,仅开发时使用。 296 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # 𝓫𝓲𝓵𝓲𝓵𝓲 2 | 3 | 𝓫𝓲𝓵𝓲𝓵𝓲 只是 𝓫𝓲𝓵𝓲𝓵𝓲,当然你可以叫我 bilili,但绝对不是什么别的东西。 4 | 5 | 如果你哄我开心的话,我可以方便快捷地帮你下载 B 站视频~ 6 | 7 | 那么我们就快些开始吧,其实也很简单的,你只需要对着这个页面大喊:「𝓫𝓲𝓵𝓲𝓵𝓲 小可爱帮我下载视频 XXX」,然后~ 8 | 9 | 然后周围的人大概就会用异样的目光看着你吧…… 10 | -------------------------------------------------------------------------------- /docs/guide/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## 解析时出现视频无法下载的问题 4 | 5 | 当在解析时出现某个视频无法下载的情况时,如果原因是 「啥都木有」,那么你只需要重新启动程序一般就可以解决。 6 | 7 | 而原因是其他情况时,请针对该情况进行检查,如果该视频你确实没有获取权限,请利用选集参数跳过该视频,这样就可以下载其余视频了。 8 | 9 | ## 总是解析一段时间后就崩溃了 10 | 11 | 可能是网络不佳,你可以通过增加 `-p` 参数每次下载一话,多运行几次就好了。 12 | 13 | ## 出现 `requests.exceptions.ProxyError` 14 | 15 | 由于你开启了系统代理会导致一些问题,所以请使用[参数 `--disable-proxy`](../cli/#绕过系统代理) 绕过系统代理。 16 | 17 | ## 可以下载互动视频吗? 18 | 19 | 暂时不可以,也不在现阶段计划中,因为本地的体验必然不如直接在 B 站上看体验好,建议直接在 B 站看哦~ 20 | 21 | ## 可以不生成 `- bilibili` 目录吗? 22 | 23 | 当前也是不可以的,不过 [bilili v2(yutto)](https://github.com/yutto-dev/yutto) 提供了更加灵活的路径设置方式,如果有兴趣可以尝试 v2 ~ 24 | -------------------------------------------------------------------------------- /docs/guide/feedback.md: -------------------------------------------------------------------------------- 1 | # 交流和反馈 2 | 3 | 在提出问题之前,请确定你已经大致地将文档浏览过一遍,这样会节省彼此的一些时间。当然,如果你对文档的组织形式、内容等有任何问题的话,欢迎提出与贡献。 4 | 5 | 如果你有一些新的想法,可以在 GitHub 上[发起 discussion](https://github.com/yutto-dev/bilili/discussions),如果你确定你发现了一个 Bug,或者想请求添加新的功能,也可以直接[发起 Issue](https://github.com/yutto-dev/bilili/issues/)。 6 | 7 | 此外你也可以通过[邮件](mailto:sigure.qaq@gmail.com)或者 [Telegram](https://t.me/SigureMo) 来与 Nyakku 取得联系,建议问题尽可能直奔主题,尽可能少询问不必要的信息。 8 | -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # 快速上手 2 | 3 | ## 我所支持的 url 4 | 5 | 嘛~我也是比较挑剔的,目前我只支持以下几种视频 url 6 | 7 | - 投稿视频主页: 8 | - `https://www.bilibili.com/video/avxxxxxx` 嘛,这种 av 号的我会支持 9 | - `https://b23.tv/avxxxxxx` 短链接也可以考虑 10 | - `https://www.bilibili.com/video/BVxxxxxx` 最新的 bv 号也不错 11 | - `https://b23.tv/BVxxxxxx` 当然,它的短链接也可以 12 | - 番剧视频主页: 13 | - `https://www.bilibili.com/bangumi/media/mdxxxxxx` 番剧的主页当然可以 14 | - `https://www.bilibili.com/bangumi/play/ssxxxxxx` 番剧的播放页(ss 号的)也可以啦 15 | - `https://b23.tv/ssxxxxxx` 还有它的短链接 16 | - `https://www.bilibili.com/bangumi/play/epxxxxxx` 番剧的播放页(ep 号的)也是可以哒 17 | - `https://b23.tv/epxxxxxx` 当然也包括它的短链接啦 18 | 19 | ## 我的解释器:Python 20 | 21 | 为了能够正常与你交流,你需要先安装 Python 前辈,当然一定要是 3.9 以上的版本,不然她可能也不知道我在说什么。 22 | 23 | 如果你是 Windows,请自行去 [Python 官网](https://www.python.org/)下载并安装,安装时记得要勾选「Add to PATH」选项,不然可能需要你手动添加到环境变量。 24 | 25 | macOS 及 Linux 发行版一般都自带 python 环境,但要注意版本。 26 | 27 | ## 我的依赖:FFmpeg 28 | 29 | 由于 B 站给我的视频大多是需要合并的,所以我需要 FFmpeg 小可爱的帮助,你需要事先把她安装到你的电脑上~ 30 | 31 | 如果你所使用的操作系统是 Windows,操作有些些麻烦,你需要[手动下载](https://ffmpeg.org/download.html)她,并将她放到你的环境变量中~ 32 | 33 | ::: details 详细操作 34 | 35 | 打开下载链接后,在 「Get packages & executable files」 部分选择 Windows 徽标,在 「Windows EXE Files」 下找到 「Windows builds by BtbN」 并点击,会跳转到一个 GitHub Releases 页面,在 「Latest release」 里就能看到最新的构建版本了~ 36 | 37 | 下载后解压,并随便放到一个安全的地方,然后在文件夹中找到 `ffmpeg.exe`,复制其所在文件夹路径。 38 | 39 | 右击「此电脑」,选择属性,在其中找到「高级系统设置」 → 「环境变量」,双击 PATH,在其中添加刚刚复制的路径(非 Win10 系统操作略有差异,请自行查阅「环境变量设置」的方法)。 40 | 41 | 保存保存,完事啦~~~ 42 | 43 | ::: 44 | 45 | 当然,如果你使用的是 macOS 或者 Linux 发行版的话,直接使用自己的包管理器就能一键完成该过程。 46 | 47 | ::: details 示例 48 | 49 | 比如 macOS 可以使用 50 | 51 | ```bash 52 | brew install ffmpeg 53 | ``` 54 | 55 | Ubuntu 可以使用 56 | 57 | ```bash 58 | apt install ffmpeg 59 | ``` 60 | 61 | Manjaro 等 Arch 系可以使用 62 | 63 | ```bash 64 | pacman -S ffmpeg 65 | ``` 66 | 67 | 大多都很简单的,其他就不一一列举啦~ 68 | 69 | ::: 70 | 71 | 此时,你可以在终端上使用 `ffmpeg -version` 命令来测试安装是否正确,只要显示的不是 `Command not found` 之类的提示就说明……成功啦~~~ 72 | 73 | ## 召唤 𝓫𝓲𝓵𝓲𝓵𝓲 74 | 75 | 是时候闪亮登场啦,你可以通过以下两种方式中任意一种方式来召唤我~ 76 | 77 | ### 通过 pip 复制我的镜像 78 | 79 | 由于我已经在 PyPI 上放置了自己的一份镜像,因此你可以通过 pip 来把那份镜像 copy 到自己电脑上 80 | 81 | ```bash 82 | pip install bilili 83 | ``` 84 | 85 | ### 通过 git 复制我的本体 86 | 87 | 如果你想见到我的最新版本体,那么你需要从 github 上将我 clone 下来 88 | 89 | ```bash 90 | git clone git@github.com:yutto-dev/bilili.git 91 | cd bilili/ 92 | pip install . 93 | ``` 94 | 95 | 无论通过哪种方式安装,此时直接使用 `bilili -v` 命令都应该不再是 `Command not found` 之类的提示啦。 96 | 97 | ## 开始工作 98 | 99 | 一切准备就绪,请为我分配任务吧~ 100 | 101 | 当然你只可以指派我可以完成的任务,也就是[我所支持的 url 格式](#我所支持的-url)。 102 | 103 | 我的工作指派方式非常简单 104 | 105 | ```bash 106 | bilili 107 | ``` 108 | 109 | 当然这里的 `` 需要用前面所说的 `url` 来替换。 110 | 111 | ::: details 示例 112 | 113 | 比如下载我的 [演示视频](https://www.bilibili.com/video/BV1vZ4y1M7mQ/)只需要 114 | 115 | ```bash 116 | bilili https://www.bilibili.com/video/BV1vZ4y1M7mQ 117 | ``` 118 | 119 | 下载番剧[《关于我转生变成史莱姆这档事》](https://www.bilibili.com/bangumi/media/md139252/)只需要 120 | 121 | ```bash 122 | bilili https://www.bilibili.com/bangumi/media/md139252/ 123 | ``` 124 | 125 | ::: 126 | 127 | 如果一切配置正确,此时我应该会正常工作咯。 128 | 129 | 当然,如果你想了解我的更多功能,请查阅[参数使用](../cli/)部分。 130 | -------------------------------------------------------------------------------- /docs/guide/knack.md: -------------------------------------------------------------------------------- 1 | # 使用技巧 2 | 3 | ## 使用 PotPlayer 4 | 5 | PotPlayer 是一款 Windows 下十分强大的播放器,我默认生成的播放列表格式就是 PotPlayer 专用的播放列表格式 `dpl`,你可以使用 PotPlayer 直接打开它。 6 | 7 | 当然,我并不会强制你使用 PotPlayer(话说其它系统也没有 PotPlayer 的说),因此其它系统请使用[参数 `--playlist-type`](../cli#指定播放列表类型) 进行修改。 8 | 9 | ## 终端的选择 10 | 11 | 请尽量使用支持 emoji 的终端,不然在我向你传达信息时可能出现失真问题(「乱码」现象),但这并不会影响下载过程。 12 | 13 | Windows 比较推荐使用 「Windows Terminal」,或者如果你有 VS Code 这样的自带终端的编辑器也是可以直接使用其终端的。 14 | 15 | ## 断点续传功能的使用 16 | 17 | 由于我具备断点续传的功能,因此你不必担心下载过程的中断,你可以在任何时刻 `Ctrl + C` 中断下载,下一次重新启动只需要重新运行一下上次的命令即可。 18 | 19 | 当然你也可以在重新开始时修改一部分参数,但由于断点续传功能会依赖于本地已下载部分的大小直接接着下载,因此如果你在一次下载中途停止后,修改了 `type`、`block-size`、`quality` 参数再次让我下载的话,两次下载的内容将截然不同,但断点续传机制仍然会强制拼接在一起,为了避免该问题,请在修改相关参数时删除已下载部分,或者直接添加参数 `overwrite` 来自动完成该过程。 20 | 21 | ## 升级方式 22 | 23 | 如果你是通过 pip 安装我的话,那么只需要使用 24 | 25 | ```bash 26 | pip install --upgrade bilili 27 | ``` 28 | 29 | 而如果你是使用 git 直接安装,直接重新运行安装时所使用的命令即可。 30 | 31 | ## 定义命令别名 32 | 33 | 可能你不想每次运行 bilili 都输入各种各样参数,所以这里我建议你将常用的参数都记录成在一条 alias 里,比如 Nyakku 就是这样做的 34 | 35 | ```bash 36 | alias bll='bilili -d ~/Movies/bilili/ -c `cat ~/.sessdata` --disable-proxy --danmaku=ass --playlist-type=m3u -y --use-mirrors' 37 | ``` 38 | 39 | 由于 Nyakku 使用的是 zsh,将其存到 `~/.zshrc` 就好了,如果你使用的是 bash 的话,存到 `~/.bashrc` 就好。 40 | 41 | 当然,Nyakku 是将自己 Cookie 里的 SESSDATA 存到了 `~/.sessdata`,这样每次只需运行 bll 就可以省去定义存储目录、Cookie 等等的参数啦。 42 | -------------------------------------------------------------------------------- /docs/guide/notice.md: -------------------------------------------------------------------------------- 1 | # 注意事项 2 | 3 | 我的工作只是将 B 站的视频搬运到你的电脑上,**仅此而已**啦,但有些事情你可能需要知道。 4 | 5 | 我不会帮你下载你没有权限访问的东西,因此我不是什么破解程序,该开大会员还是去乖乖开大会员,这是对 B 站的一种支持,所以请不要对我提让我为难的要求哦,无论现在还是以后。 6 | 7 | 我所下载的东西只代表你有权限获取,请不要将我获取的东西随意分享破坏平台和创作者的权益,如果你这么做了的话,那就不要怪我绝情咯,只能一切后果自负哦。 8 | 9 | 另外我本身的开源协议是 GPL-3.0,一方面我需要 FFmpeg 和 [danmaku2ass](https://github.com/m13253/danmaku2ass) 两位前辈的帮忙,另一方面也尽可能维护 B 站本身的权益。不过我的文档采用的是 CC0-1.0 协议,api 子模块采用的是 MIT 协议。 10 | -------------------------------------------------------------------------------- /docs/guide/thanks.md: -------------------------------------------------------------------------------- 1 | # 特别感谢 2 | 3 | ## 平台以及创作者 4 | 5 | 感谢 [bilibili](https://www.bilibili.com/) 平台以及平台上无数优质内容的创作者。 6 | 7 | ## 依赖项目 8 | 9 | 我的正常运作离不开以下项目的支持 10 | 11 | - [FFmpeg](https://github.com/FFmpeg/FFmpeg) 用于视频的合并 12 | - [danmaku2ass](https://github.com/m13253/danmaku2ass) 用于 xml 弹幕转换为 ass 弹幕 13 | - [VuePress](https://github.com/vuejs/vuepress) 本文档的生成器 14 | - [vuepress-theme-vt](https://vuepress-theme-vt.vercel.app/) 本文档主题 15 | 16 | ## 参考项目 17 | 18 | 我在探索过程中得到了以下项目的帮助 19 | 20 | - [Bilibili - 1033020837](https://github.com/1033020837/Bilibili) 早期探索时的参考项目 21 | - [BilibiliVideoDownload - blogwy](https://github.com/blogwy/BilibiliVideoDownload) 了解到更多清晰度等级(120、112 等) 22 | - [BiliUtil](https://github.com/wolfbolin/BiliUtil) 参考了 4k 清晰度获取时需要的额外参数以及打包 PyPI 的方式 23 | 24 | ## 云服务 25 | 26 | - [Vercel](https://vercel.com/) 提供[文档托管](https://vercel.com/siguremo/bilili) 27 | 28 | ## 贡献者 29 | 30 | 感谢每一位贡献者的辛勤付出 31 | 32 | 33 | 34 | ## 赞助者 35 | 36 | 感谢各位赞助者的资金援助 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ## 以及你的支持~ 46 | -------------------------------------------------------------------------------- /docs/guide/work-process.md: -------------------------------------------------------------------------------- 1 | # 工作流程 2 | 3 | 是时候让你了解一下我的工作方式了。 4 | 5 | ## url 解析 6 | 7 | 最开始,我会解析你的 url 类型,依此送入 [bangumi API 解析器](https://github.com/yutto-dev/bilili/blob/main/src/bilili/parser/bangumi.py)或者 [acg_video API 解析器](https://github.com/yutto-dev/bilili/blob/main/bilili/src/parser/acg_video.py) 8 | 9 | ## 列表获取 10 | 11 | 两个解析器会从当前 url 中获取关键信息,并通过 B 站的相关 API 中获取整个播放列表,番剧自然就是该番该季的全部剧集,而投稿视频则是各 P 的信息。 12 | 13 | ## 视频链接获取 14 | 15 | 进一步地,通过 B 站相关 API 来获取各个视频的链接。 16 | 17 | 这里视频链接的获取当前是有多种格式可选的,B 站早期使用的是 Flash 播放器,自然使用的是 `flv` 格式的视频,B 站的 `flv` 视频大多是分段的,因此下载之后需要合并。 18 | 19 | 后来 B 站采用 HTML5 播放器的时候貌似[也在使用 flv 格式](https://github.com/Bilibili/flv.js/),当然用的 API 应当也是 flv 的 API。 20 | 21 | 现在的 HTML5 播放器返回的是通过 [dash 方式组织的 `m4s` 格式的文件](https://www.bilibili.com/read/cv855111),一个是音频文件,另一个自然就是视频文件咯。 22 | 23 | 除此之外,还可以请求出投稿视频的 `mp4` 格式文件,但一般清晰度并不会太高,而且清晰度也不能自己指定,限制还是蛮多的。 24 | 25 | ## 弹幕、字幕获取 26 | 27 | 当然,看 B 站视频的话弹幕是不可或缺的,因此我会帮你自动下载 xml 格式的弹幕。 28 | 29 | 有些视频存在字幕,因此也会一并下载。 30 | 31 | ## 视频下载 32 | 33 | 此时,由于每个视频的真实 url 我们都已经得到了,因此就可以直接下载咯~ 34 | 35 | 为了提高下载速度,我会同时幻化出多个分身(子线程),另外我还会将每个视频切成小块,将每个小块分发给一个分身来下载。 36 | 37 | 当然,当一个视频块下载完成需要合并,这个过程会由下载最后那个块的分身来完成。 38 | 39 | 另外我还安排了三个分身用于视频片段的合并,如果一个视频所有片段都下载完成,就会通知她们进行合并。 40 | 41 | 什么?你问我我在干嘛?我会在旁边监督她们的啦,同时会告诉你她们的进度,嘻嘻~ 42 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilili-docs", 3 | "description": "bilili documentation", 4 | "license": "CC0-1.0", 5 | "packageManager": "pnpm@9.12.2", 6 | "scripts": { 7 | "dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider BILILI_VERSIONS=`node scripts/get_latest_version.mjs bilili yutto` vuepress dev", 8 | "build": "cross-env NODE_OPTIONS=--openssl-legacy-provider BILILI_VERSIONS=`node scripts/get_latest_version.mjs bilili yutto` vuepress build", 9 | "serve": "pnpm build && npx serve .vuepress/dist" 10 | }, 11 | "devDependencies": { 12 | "@vuepress/plugin-back-to-top": "^1.9.10", 13 | "cross-env": "^7.0.3", 14 | "markdown-it": "^14.1.0", 15 | "vuepress": "^1.9.10", 16 | "vuepress-plugin-cursor-effects": "^1.1.6", 17 | "vuepress-plugin-dynamic-title": "^1.0.0", 18 | "vuepress-theme-vt": "0.15.1" 19 | }, 20 | "pnpm": { 21 | "peerDependencyRules": { 22 | "allowedVersions": { 23 | "markdown-it": "*" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/scripts/get_latest_version.mjs: -------------------------------------------------------------------------------- 1 | const defaultVersion = '0.0.0' 2 | const packageNames = process.argv.slice(2, process.argv.length) 3 | const api = (packageName) => `https://pypi.org/pypi/${packageName}/json` 4 | 5 | Promise.all( 6 | packageNames.map((packageName) => 7 | fetch(api(packageName)) 8 | .then((response) => response.json()) 9 | .then((response) => response.info.version) 10 | .catch((err) => defaultVersion) 11 | .then((version) => ({ 12 | name: packageName, 13 | version, 14 | })) 15 | ) 16 | ) 17 | .then((versions) => { 18 | const versionsObj = Object.create({}) 19 | versions.forEach((version) => (versionsObj[version.name] = version.version)) 20 | return versionsObj 21 | }) 22 | .then((versions) => JSON.stringify(versions)) 23 | .then((versions) => process.stdout.write(versions)) 24 | -------------------------------------------------------------------------------- /docs/sponsor.md: -------------------------------------------------------------------------------- 1 | # 赞助 2 | 3 | 首先说明,由于我只是将 B 站的视频搬运到你的电脑上,是一件很简单的事情,请把最大的感激给予平台以及创作者。 4 | 5 | 如果你想支持我的话,在 [GitHub 项目主页](https://github.com/yutto-dev/bilili)给予我一个 「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 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set positional-arguments 2 | 3 | VERSION := `uv run python -c "import sys; from bilili.__version__ import VERSION as bilili_version; sys.stdout.write(bilili_version)"` 4 | 5 | install: 6 | uv sync 7 | 8 | clean-venv: 9 | rm -rf .venv 10 | 11 | run *ARGS: 12 | uv run bilili {{ARGS}} 13 | 14 | test: 15 | uv run pytest -m '(api or e2e) and not ci_only' 16 | just clean 17 | 18 | build: 19 | uv build 20 | 21 | release: 22 | @echo 'Tagging {{VERSION}}...' 23 | git tag {{VERSION}} 24 | @echo 'Push to GitHub to trigger publish process...' 25 | git push --tags 26 | 27 | clean: 28 | find . -name "*- bilibili" -print0 | xargs -0 rm -rf 29 | rm -rf tmp/ 30 | rm -rf .pytest_cache/ 31 | 32 | clean-builds: 33 | rm -rf build/ 34 | rm -rf dist/ 35 | rm -rf *.egg-info/ 36 | 37 | docs: 38 | cd docs/ && pnpm dev 39 | 40 | lint: 41 | uv run ruff check . 42 | 43 | fmt: 44 | uv run ruff format . 45 | 46 | ci-install: 47 | just install 48 | 49 | ci-api-test: 50 | uv run pytest -m "api and not ci_skip" --reruns 3 --reruns-delay 1 51 | just clean 52 | 53 | ci-e2e-test: 54 | uv run pytest -m "e2e and not ci_skip" 55 | just clean 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "bilili" 7 | description = "🍻 bilibili video and danmaku downloader | B站视频、弹幕下载器" 8 | readme = "README.md" 9 | requires-python = ">=3.9" 10 | authors = [{ name = "Nyakku Shigure", email = "sigure.qaq@gmail.com" }] 11 | keywords = ["python", "bilibili", "video", "download", "spider", "danmaku"] 12 | license = { text = "GPLv3" } 13 | classifiers = [ 14 | "Environment :: Console", 15 | "Operating System :: OS Independent", 16 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | ] 26 | dependencies = ["requests>=2.27.0", "biliass==1.3.13"] 27 | dynamic = ["version"] 28 | 29 | [project.urls] 30 | Homepage = "https://github.com/yutto-dev/bilili" 31 | Documentation = "https://bilili.nyakku.moe/" 32 | 33 | [project.scripts] 34 | bilili = "bilili.__main__:main" 35 | 36 | [dependency-groups] 37 | dev = ["ruff>=0.7.1", "pytest>=8.3.3", "pytest-rerunfailures>=14.0"] 38 | 39 | [tool.setuptools.packages.find] 40 | where = ["src"] 41 | 42 | [tool.setuptools.dynamic] 43 | version = { attr = "bilili.__version__.VERSION" } 44 | 45 | [tool.ruff] 46 | line-length = 120 47 | target-version = "py39" 48 | 49 | [tool.ruff.lint] 50 | select = [ 51 | # Pyflakes 52 | "F", 53 | # Pycodestyle 54 | "E", 55 | "W", 56 | # Isort 57 | "I", 58 | # Pyupgrade 59 | "UP", 60 | # Flake8-pyi 61 | "PYI", 62 | # Yesqa 63 | "RUF100", 64 | ] 65 | ignore = [ 66 | "E501", # line too long, duplicate with ruff fmt 67 | "E731", # lambda sometimes is more readable 68 | "F401", # imported but unused, duplicate with pyright 69 | "F841", # local variable is assigned to but never used, duplicate with pyright 70 | ] 71 | 72 | [tool.ruff.lint.isort] 73 | known-first-party = ["bilili"] 74 | 75 | [tool.pytest.ini_options] 76 | markers = ["api", "e2e", "ci_skip", "ci_only"] 77 | -------------------------------------------------------------------------------- /src/bilili/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/__init__.py -------------------------------------------------------------------------------- /src/bilili/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | import sys 5 | import time 6 | from urllib.parse import quote, unquote 7 | 8 | from .__version__ import VERSION as bilili_version 9 | from .api.danmaku import get_danmaku 10 | from .api.exceptions import CannotDownloadError, IsPreviewError 11 | from .api.vip import is_vip 12 | from .handlers.downloader import RemoteFile 13 | from .handlers.merger import MergingFile 14 | from .tools import global_status, regex, spider 15 | from .utils.base import repair_filename, size_format, touch_dir 16 | from .utils.console.colorful import set_no_color 17 | from .utils.console.logger import Badge, Logger, set_logger_debug 18 | from .utils.console.ui import ColorString, Line, LineList, ProgressBar, String, View 19 | from .utils.danmaku import convert_xml_danmaku_to_ass 20 | from .utils.functiontools.attrdict import AttrDict 21 | from .utils.playlist import Dpl, M3u 22 | from .utils.subtitle import Subtitle 23 | from .utils.thread import Flag, ThreadPool 24 | from .video import BililiContainer 25 | 26 | 27 | def parse_episodes(episodes_str: str, total: int) -> list[int]: 28 | """将选集字符串转为列表""" 29 | 30 | if total == 0: 31 | Logger.warning("该剧集列表无任何剧集,猜测正片尚未上线,如果想要下载 PV 等特殊剧集,请添加参数 -s") 32 | return [] 33 | 34 | def reslove_negetive(value: int) -> int: 35 | return value if value > 0 else value + total + 1 36 | 37 | # 解析字符串为列表 38 | Logger.print(f"全 {total} 话") 39 | if re.match(r"([\-\d\^\$]+(~[\-\d\^\$]+)?)(,[\-\d\^\$]+(~[\-\d\^\$]+)?)*", episodes_str): 40 | episodes_str = episodes_str.replace("^", "1") 41 | episodes_str = episodes_str.replace("$", "-1") 42 | episode_list = [] 43 | for episode_item in episodes_str.split(","): 44 | if "~" in episode_item: 45 | start, end = episode_item.split("~") 46 | start, end = int(start), int(end) 47 | start, end = reslove_negetive(start), reslove_negetive(end) 48 | assert end >= start, f"终点值({end})应不小于起点值({start})" 49 | episode_list.extend(list(range(start, end + 1))) 50 | else: 51 | episode_item = int(episode_item) 52 | episode_item = reslove_negetive(episode_item) 53 | episode_list.append(episode_item) 54 | else: 55 | episode_list = [] 56 | 57 | episode_list = sorted(list(set(episode_list))) 58 | 59 | # 筛选满足条件的剧集 60 | out_of_range = [] 61 | episodes = [] 62 | for episode in episode_list: 63 | if episode in range(1, total + 1): 64 | if episode not in episodes: 65 | episodes.append(episode) 66 | else: 67 | out_of_range.append(episode) 68 | if out_of_range: 69 | Logger.warning("剧集 {} 不存在哟!".format(",".join(list(map(str, out_of_range))))) 70 | 71 | Logger.print("已选择第 {} 话".format(",".join(list(map(str, episodes))))) 72 | assert episodes, "没有选中任何剧集" 73 | return episodes 74 | 75 | 76 | def check_arguments_and_set_global(args: argparse.Namespace): 77 | # 先解码后编码是防止获取到的 SESSDATA 是已经解码后的(包含「,」) 78 | # 而番剧无法使用解码后的 SESSDATA 79 | cookies = {"SESSDATA": quote(unquote(args.sess_data))} 80 | spider.set_cookies(cookies) 81 | 82 | if args.debug: 83 | set_logger_debug() 84 | 85 | # 使用 --no-color 或者 NO_COLOR 环境变量非空均不显示颜色 86 | if args.no_color or os.environ.get("NO_COLOR") is not None: 87 | set_no_color() 88 | 89 | if args.disable_proxy: 90 | spider.trust_env = False 91 | 92 | # 大会员身份校验 93 | if not args.sess_data: 94 | Logger.info("未提供 SESSDATA,无法下载会员专享剧集的喔~") 95 | else: 96 | if is_vip(): 97 | Logger.custom("成功以大会员身份登录~", badge=Badge("大会员", fore="white", back="magenta", style="bold")) 98 | else: 99 | Logger.warning("以非大会员身份登录,无法下载会员专享剧集的喔~") 100 | 101 | 102 | def main(): 103 | """解析命令行参数并调用相关模块进行下载""" 104 | 105 | parser = argparse.ArgumentParser(description="bilili B 站视频、弹幕下载器", prog="bilili") 106 | parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {bilili_version}") 107 | parser.add_argument("url", help="视频主页地址") 108 | parser.add_argument( 109 | "-t", 110 | "--type", 111 | default="dash", 112 | choices=["flv", "dash", "mp4"], 113 | help="选择下载源类型(dash 或 flv 或 mp4)", 114 | ) 115 | parser.add_argument("-d", "--dir", default=r"", help="下载目录") 116 | parser.add_argument( 117 | "-q", 118 | "--quality", 119 | default=127, 120 | choices=[127, 125, 120, 116, 112, 80, 74, 64, 32, 16], 121 | type=int, 122 | help="视频清晰度 127:8K, 125:HDR, 120:4K, 116:1080P60, 112:1080P+, 80:1080P, 74:720P60, 64:720P, 32:480P, 16:360P", 123 | ) 124 | parser.add_argument("-n", "--num-threads", default=16, type=int, help="最大下载线程数") 125 | parser.add_argument("-p", "--episodes", default="^~$", help="选集") 126 | parser.add_argument( 127 | "-s", "--with-section", action="store_true", help="同时下载附加剧集(PV、预告以及特别篇等专区内容)" 128 | ) 129 | parser.add_argument("-w", "--overwrite", action="store_true", help="强制覆盖已下载视频") 130 | parser.add_argument("-c", "--sess-data", default="", help="输入 cookies") 131 | parser.add_argument("-y", "--yes", action="store_true", help="跳过下载询问") 132 | parser.add_argument( 133 | "--audio-quality", 134 | default=30280, 135 | choices=[30280, 30232, 30216], 136 | type=int, 137 | help="音频码率等级 30280:320kbps, 30232:128kbps, 30216:64kbps", 138 | ) 139 | parser.add_argument( 140 | "--playlist-type", 141 | default="dpl", 142 | choices=["dpl", "m3u", "no"], 143 | help="播放列表类型,支持 dpl 和 m3u,输入 no 不生成播放列表", 144 | ) 145 | parser.add_argument( 146 | "--danmaku", 147 | default="xml", 148 | choices=["xml", "ass", "no"], 149 | help="弹幕类型,支持 xml 和 ass,如果设置为 no 则不下载弹幕", 150 | ) 151 | parser.add_argument( 152 | "--block-size", 153 | default=128, 154 | type=int, 155 | help="分块下载器的块大小,单位为 MiB,默认为 128MiB,设置为 0 时禁用分块下载", 156 | ) 157 | parser.add_argument("--abs-path", action="store_true", help="修改播放列表路径类型为绝对路径") 158 | parser.add_argument("--use-mirrors", action="store_true", help="启用从多个镜像下载功能") 159 | parser.add_argument("--disable-proxy", action="store_true", help="禁用系统代理") 160 | parser.add_argument("--no-color", action="store_true", help="不使用颜色") 161 | parser.add_argument("--debug", action="store_true", help="debug 模式") 162 | 163 | args = parser.parse_args() 164 | check_arguments_and_set_global(args) 165 | 166 | config = { 167 | "url": args.url, 168 | "dir": args.dir, 169 | "quality": args.quality, 170 | "audio_quality": args.audio_quality, 171 | "with_section": args.with_section, 172 | "episodes": args.episodes, 173 | "playlist_type": args.playlist_type, 174 | "playlist_path_type": "AP" if args.abs_path else "RP", 175 | "overwrite": args.overwrite, 176 | "type": args.type.lower(), 177 | "block_size": int(args.block_size * 1024 * 1024), 178 | } >> AttrDict() 179 | resource_id = { 180 | "avid": "", 181 | "bvid": "", 182 | "episode_id": "", 183 | "season_id": "", 184 | } >> AttrDict() 185 | 186 | # fmt: off 187 | if (avid_match := regex.acg_video.av.origin.match(args.url)) or \ 188 | (avid_match := regex.acg_video.av.short.match(args.url)): 189 | from .api.acg_video import get_video_info 190 | avid = avid_match.group("avid") 191 | if episode_id := get_video_info(avid=avid)["episode_id"]: 192 | resource_id.episode_id = episode_id 193 | else: 194 | resource_id.avid = avid 195 | elif (bvid_match := regex.acg_video.bv.origin.match(args.url)) or \ 196 | (bvid_match := regex.acg_video.bv.short.match(args.url)): 197 | from .api.acg_video import get_video_info 198 | bvid = bvid_match.group("bvid") 199 | if episode_id := get_video_info(bvid=bvid)["episode_id"]: 200 | resource_id.episode_id = episode_id 201 | else: 202 | resource_id.bvid = bvid 203 | elif media_id_match := regex.bangumi.md.origin.match(args.url): 204 | from .api.bangumi import get_season_id 205 | media_id = media_id_match.group("media_id") 206 | resource_id.season_id = get_season_id(media_id=media_id) 207 | elif (episode_id_match := regex.bangumi.ep.origin.match(args.url)) or \ 208 | (episode_id_match := regex.bangumi.ep.short.match(args.url)): 209 | episode_id = episode_id_match.group("episode_id") 210 | resource_id.episode_id = episode_id 211 | elif (season_id_match := regex.bangumi.ss.origin.match(args.url)) or \ 212 | (season_id_match := regex.bangumi.ss.short.match(args.url)): 213 | season_id = season_id_match.group("season_id") 214 | resource_id.season_id = season_id 215 | else: 216 | Logger.error("视频地址有误呀,请仔细检查一下下~") 217 | sys.exit(1) 218 | # fmt: on 219 | 220 | if resource_id.avid or resource_id.bvid: 221 | from .parser.acg_video import get_list, get_playurl, get_subtitle, get_title 222 | elif resource_id.season_id or resource_id.episode_id: 223 | from .parser.bangumi import get_list, get_playurl, get_subtitle, get_title 224 | else: 225 | Logger.error("未知的视频类型!") 226 | sys.exit(1) 227 | 228 | # 获取标题 229 | title = get_title(resource_id) 230 | Logger.print(title) 231 | 232 | # 创建所需目录结构 233 | base_dir = touch_dir(os.path.join(config["dir"], repair_filename(title + " - bilibili"))) 234 | video_dir = touch_dir(os.path.join(base_dir, "Videos")) 235 | 236 | # 获取需要的信息 237 | containers = [ 238 | BililiContainer(video_dir=video_dir, type=args.type, **video) 239 | for video in get_list(resource_id, config["with_section"]) 240 | ] 241 | 242 | # 解析并过滤不需要的选集 243 | episodes = parse_episodes(config["episodes"], len(containers)) 244 | containers, containers_need_filter = [], containers 245 | for container in containers_need_filter: 246 | if container.id not in episodes: 247 | container._.downloaded = True 248 | container._.merged = True 249 | else: 250 | containers.append(container) 251 | 252 | # 初始化播放列表 253 | if config["playlist_type"] == "dpl": 254 | playlist = Dpl(os.path.join(base_dir, "Playlist.dpl"), path_type=config["playlist_path_type"]) 255 | elif config["playlist_type"] == "m3u": 256 | playlist = M3u(os.path.join(base_dir, "Playlist.m3u"), path_type=config["playlist_path_type"]) 257 | else: 258 | playlist = None 259 | 260 | # 解析片段信息及视频 url 261 | for i, container in enumerate(containers): 262 | Logger.print( 263 | f"{i + 1:02}/{len(containers):02} 正在努力解析视频信息~", 264 | end="\r", 265 | ) 266 | 267 | # 解析视频 url 268 | try: 269 | for playinfo in get_playurl(container, config["quality"], config["audio_quality"]): 270 | container.append_media(block_size=config["block_size"], **playinfo) 271 | except CannotDownloadError as e: 272 | Logger.warning(f"{container.name} 无法下载,原因:{e.message}") 273 | del containers[i] 274 | continue 275 | except IsPreviewError: 276 | # TODO: 现在还有部分预览的视频吗? 277 | Logger.warning(f"{container.name} 是预览视频呢~") 278 | 279 | # 写入播放列表 280 | if playlist is not None: 281 | playlist.write_path(container.path) 282 | 283 | # 下载字幕 284 | for sub_info in get_subtitle(container): 285 | sub_path = "{}_{}.srt".format(os.path.splitext(container.path)[0], sub_info["lang"]) 286 | subtitle = Subtitle(sub_path) 287 | for sub_line in sub_info["lines"]: 288 | subtitle.write_line(sub_line["content"], sub_line["from"], sub_line["to"]) 289 | 290 | # 生成弹幕 291 | if args.danmaku != "no": 292 | xml_danmaku = get_danmaku(container.meta["cid"]) 293 | if args.danmaku == "ass": 294 | with open( 295 | os.path.splitext(container.path)[0] + ".ass", 296 | "w", 297 | encoding="utf-8-sig", 298 | errors="replace", 299 | ) as f: 300 | f.write( 301 | convert_xml_danmaku_to_ass( 302 | xml_danmaku, 303 | container.height, 304 | container.width, 305 | ) 306 | ) 307 | else: 308 | with open(os.path.splitext(container.path)[0] + ".xml", "w", encoding="utf-8") as f: 309 | f.write(xml_danmaku) 310 | 311 | if playlist is not None: 312 | playlist.flush() 313 | 314 | # 准备下载 315 | if containers: 316 | # 状态检查与校正 317 | for i, container in enumerate(containers, 1): 318 | container_downloaded = not container.check_needs_download(args.overwrite) 319 | symbol = " " if container_downloaded else "*" 320 | if container_downloaded: 321 | container._.merged = True 322 | Logger.print("{}{} {:>2} {}".format(" " * 0, symbol, i, str(container))) 323 | for media in container.medias: 324 | media_downloaded = not media.check_needs_download(args.overwrite) or container_downloaded 325 | symbol = " " if media_downloaded else "*" 326 | if not container_downloaded and args.debug: 327 | Logger.print("{}{} {}".format(" " * 1, symbol, media.name)) 328 | for block in media.blocks: 329 | block_downloaded = not block.check_needs_download(args.overwrite) or media_downloaded 330 | symbol = " " if block_downloaded else "*" 331 | block._.downloaded = block_downloaded 332 | if not media_downloaded and args.debug: 333 | Logger.print("{}{} {}".format(" " * 2, symbol, block.name)) 334 | 335 | # 询问是否下载,通过参数 -y 可以跳过 336 | if not args.yes: 337 | answer = None 338 | while answer is None: 339 | result = input("以上标 * 为需要进行下载的视频,是否立刻进行下载?[Y/n]") 340 | if result == "" or result[0].lower() == "y": 341 | answer = True 342 | elif result[0].lower() == "n": 343 | answer = False 344 | else: 345 | answer = None 346 | if not answer: 347 | sys.exit(0) 348 | 349 | # 部署下载与合并任务 350 | merge_wait_flag = Flag(False) # 合并线程池不能因为没有任务就结束 351 | # 因此要设定一个 flag,待最后合并结束后改变其值 352 | merge_pool = ThreadPool(3, wait=merge_wait_flag, daemon=True) 353 | download_pool = ThreadPool( 354 | args.num_threads, 355 | daemon=True, 356 | thread_globals_creator={ 357 | # 为每个线程创建一个全新的 Session,因为 requests.Session 不是线程安全的 358 | # https://github.com/psf/requests/issues/1871 359 | "thread_spider": spider.clone 360 | }, 361 | ) 362 | for container in containers: 363 | merging_file = MergingFile( 364 | container.type, 365 | [media.path for media in container.medias], 366 | container.path, 367 | ) 368 | for media in container.medias: 369 | block_merging_file = MergingFile(None, [block.path for block in media.blocks], media.path) 370 | for block in media.blocks: 371 | mirrors = block.mirrors if args.use_mirrors else [] 372 | remote_file = RemoteFile(block.url, block.path, mirrors=mirrors, range=block.range) 373 | 374 | # 为下载挂载各种钩子,以修改状态,注意外部变量应当作为默认参数传入 375 | @remote_file.on("before_download") 376 | def before_download(file, status=block._): 377 | status.downloading = True 378 | 379 | @remote_file.on("updated") 380 | def updated(file, status=block._): 381 | status.size = file.size 382 | 383 | @remote_file.on("downloaded") 384 | def downloaded( 385 | file, status=block._, merging_file=merging_file, block_merging_file=block_merging_file 386 | ): 387 | status.downloaded = True 388 | 389 | if status.parent.downloaded: 390 | # 当前 media 的最后一个 block 所在线程进行合并(直接执行,不放线程池) 391 | status.downloaded = False 392 | block_merging_file.merge() 393 | status.downloaded = True 394 | 395 | # 如果该线程同时也是当前 container 的最后一个 block,就部署合并任务(放到线程池) 396 | if status.parent.parent.downloaded and not status.parent.parent.merged: 397 | # 为合并挂载各种钩子 398 | @merging_file.on("before_merge") 399 | def before_merge(file, status=status.parent.parent): 400 | status.merging = True 401 | 402 | @merging_file.on("merged") 403 | def merged(file, status=status.parent.parent): 404 | status.merging = False 405 | status.merged = True 406 | 407 | merge_pool.add_task(merging_file.merge, args=()) 408 | 409 | status.downloading = False 410 | 411 | # 下载过的不应继续部署任务 412 | if block._.downloaded: 413 | continue 414 | download_pool.add_task(remote_file.download, args=()) 415 | 416 | # 启动线程池 417 | merge_pool.run() 418 | download_pool.run() 419 | 420 | # 初始化界面 421 | console = View(debug=args.debug) 422 | console.add_component(Line(center=String(), fillchar=" ")) 423 | console.add_component(Line(left=ColorString(fore="cyan"), fillchar=" ")) 424 | console.add_component(LineList(Line(left=String(), right=String(), fillchar="-"))) 425 | console.add_component( 426 | Line( 427 | left=ColorString( 428 | fore="green", 429 | back="white", 430 | subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=65), 431 | ), 432 | right=String(), 433 | fillchar=" ", 434 | ) 435 | ) 436 | console.add_component(Line(left=ColorString(fore="blue"), fillchar=" ")) 437 | console.add_component(LineList(Line(left=String(), fillchar=" "))) 438 | console.add_component( 439 | Line( 440 | left=ColorString( 441 | fore="yellow", 442 | back="white", 443 | subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=65), 444 | ), 445 | right=String(), 446 | fillchar=" ", 447 | ) 448 | ) 449 | 450 | # 准备监控 451 | size, t = global_status.size, time.time() 452 | while True: 453 | now_size, now_t = global_status.size, time.time() 454 | delta_size, delta_t = ( 455 | max(now_size - size, 0), 456 | (now_t - t) if now_t - t > 1e-6 else 1e-6, 457 | ) 458 | speed = delta_size / delta_t 459 | size, t = now_size, now_t 460 | 461 | # 数据传入,界面渲染 462 | console.refresh( 463 | [ 464 | { 465 | "center": " bilili ", 466 | }, 467 | {"left": "Downloading videos: "} if global_status.downloading else None, 468 | [ 469 | { 470 | "left": f"{str(container)} ", 471 | "right": f" {size_format(container._.size)}/{size_format(container._.total_size)}", 472 | } 473 | if container._.downloading 474 | else None 475 | for container in containers 476 | ] 477 | if global_status.downloading 478 | else None, 479 | { 480 | "left": global_status.size / global_status.total_size, 481 | "right": f" {size_format(global_status.size)}/{size_format(global_status.total_size)} {size_format(speed)}/s", 482 | } 483 | if global_status.downloading 484 | else None, 485 | {"left": "Merging videos: "} if global_status.merging else None, 486 | [ 487 | {"left": f"{str(container)} ", "right": True} if container._.merging else None 488 | for container in containers 489 | ] 490 | if global_status.merging 491 | else None, 492 | { 493 | "left": sum([container._.merged for container in containers]) / len(containers), 494 | "right": f" {sum([container._.merged for container in containers])}/{len(containers)}", 495 | } 496 | if global_status.merging 497 | else None, 498 | ] # fmt: skip 499 | ) 500 | 501 | # 检查是否已经全部完成 502 | if global_status.downloaded and global_status.merged: 503 | merge_wait_flag.value = True 504 | download_pool.join() 505 | merge_pool.join() 506 | break 507 | try: 508 | # 将刷新率稳定在 2fps 509 | refresh_rate = 2 510 | time.sleep(max(1 / refresh_rate - (time.time() - now_t), 0.01)) 511 | except (SystemExit, KeyboardInterrupt): 512 | Logger.info("已终止下载,再次运行即可继续下载~") 513 | sys.exit(1) 514 | Logger.info("已全部下载完成啦!") 515 | else: 516 | Logger.info("没有需要下载的视频!") 517 | 518 | 519 | if __name__ == "__main__": 520 | main() 521 | -------------------------------------------------------------------------------- /src/bilili/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.4.15" 2 | -------------------------------------------------------------------------------- /src/bilili/api/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nyakku Shigure 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/bilili/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/api/__init__.py -------------------------------------------------------------------------------- /src/bilili/api/acg_video.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from ..api.exceptions import ArgumentsError, CannotDownloadError, UnknownTypeError, UnsupportTypeError 5 | from ..quality import Media, gen_quality_sequence, video_quality_map 6 | from ..tools import regex_bangumi_ep, spider 7 | from ..utils.base import touch_url 8 | from .utils import MaxRetry 9 | 10 | _TOUCH_SET = set() 11 | 12 | 13 | @MaxRetry(2) 14 | def touch_homepage(avid: str = "", bvid: str = ""): 15 | # cache touched homepage 16 | cache_key = f"{avid}-{bvid}" 17 | if cache_key in _TOUCH_SET: 18 | return 19 | _TOUCH_SET.add(cache_key) 20 | 21 | if not (avid or bvid): 22 | raise ArgumentsError("avid", "bvid") 23 | if bvid: 24 | homepage_api = f"https://www.bilibili.com/video/{bvid}/" 25 | else: 26 | homepage_api = f"https://www.bilibili.com/video/av{avid}/" 27 | spider.get(homepage_api, timeout=3) 28 | 29 | 30 | @MaxRetry(2) 31 | def get_video_info(avid: str = "", bvid: str = ""): 32 | if not (avid or bvid): 33 | raise ArgumentsError("avid", "bvid") 34 | info_api = "http://api.bilibili.com/x/web-interface/view?aid={avid}&bvid={bvid}" 35 | res = spider.get(info_api.format(avid=avid, bvid=bvid), timeout=3) 36 | res_json_data = res.json()["data"] 37 | episode_id = "" 38 | if res_json_data.get("redirect_url") and (match_obj := regex_bangumi_ep.match(res_json_data["redirect_url"])): 39 | episode_id = match_obj.group("episode_id") 40 | return { 41 | "avid": str(res_json_data["aid"]), 42 | "bvid": res_json_data["bvid"], 43 | "title": res_json_data["title"], 44 | "picture": res_json_data["pic"], 45 | "episode_id": episode_id, 46 | } 47 | 48 | 49 | def get_acg_video_title(avid: str = "", bvid: str = "") -> str: 50 | if not (avid or bvid): 51 | raise ArgumentsError("avid", "bvid") 52 | try: 53 | title = get_video_info(avid=avid, bvid=bvid)["title"] 54 | except Exception: 55 | title = "呐,我也不知道是什么标题呢~" 56 | finally: 57 | return title 58 | 59 | 60 | @MaxRetry(2) 61 | def get_acg_video_list(avid: str = "", bvid: str = ""): 62 | if not (avid or bvid): 63 | raise ArgumentsError("avid", "bvid") 64 | list_api = "https://api.bilibili.com/x/player/pagelist?aid={avid}&bvid={bvid}&jsonp=jsonp" 65 | res = spider.get(list_api.format(avid=avid, bvid=bvid), timeout=3) 66 | return [ 67 | { 68 | 'id': i + 1, 69 | 'name': item['part'], 70 | 'cid': str(item['cid']) 71 | } 72 | for i, item in enumerate(res.json()['data']) 73 | ] # fmt: skip 74 | 75 | 76 | @MaxRetry(2) 77 | def get_acg_video_playurl( 78 | avid: str = "", 79 | bvid: str = "", 80 | cid: str = "", 81 | quality: int = 127, 82 | audio_quality: int = 30280, 83 | type: str = "dash", 84 | ): 85 | if not (avid or bvid): 86 | raise ArgumentsError("avid", "bvid") 87 | touch_homepage(avid=avid, bvid=bvid) 88 | video_quality_sequence = gen_quality_sequence(quality, type=Media.VIDEO) 89 | audio_quality_sequence = gen_quality_sequence(audio_quality, type=Media.AUDIO) 90 | play_api = ( 91 | "https://api.bilibili.com/x/player/playurl?avid={avid}&bvid={bvid}&cid={cid}&qn={quality}&type=&otype=json" 92 | ) 93 | if type == "flv": 94 | touch_message = spider.get(play_api.format(avid=avid, bvid=bvid, cid=cid, quality=80), timeout=3).json() 95 | if touch_message["code"] != 0: 96 | raise CannotDownloadError(touch_message["code"], touch_message["message"]) 97 | 98 | accept_quality = touch_message["data"]["accept_quality"] 99 | for quality in video_quality_sequence: 100 | if quality in accept_quality: 101 | break 102 | 103 | play_url = play_api.format(avid=avid, bvid=bvid, cid=cid, quality=quality) 104 | res = spider.get(play_url, timeout=3) 105 | 106 | return [ 107 | { 108 | "id": i + 1, 109 | "url": segment["url"], 110 | "mirrors": segment["backup_url"], 111 | "quality": quality, 112 | "height": video_quality_map[quality]["height"], 113 | "width": video_quality_map[quality]["width"], 114 | "size": segment["size"], 115 | "type": "flv_segment", 116 | } 117 | for i, segment in enumerate(res.json()["data"]["durl"]) 118 | ] 119 | elif type == "dash": 120 | result = [] 121 | play_api_dash = play_api + "&fnver=0&fnval=4048&fourk=1" 122 | touch_message = spider.get( 123 | play_api_dash.format(avid=avid, bvid=bvid, cid=cid, quality=video_quality_sequence[0]), timeout=3 124 | ).json() 125 | 126 | if touch_message["code"] != 0: 127 | raise CannotDownloadError(touch_message["code"], touch_message["message"]) 128 | if touch_message["data"].get("dash") is None: 129 | raise UnsupportTypeError("dash") 130 | 131 | video_accept_quality = set([video["id"] for video in touch_message["data"]["dash"]["video"]]) 132 | for video_quality in video_quality_sequence: 133 | if video_quality in video_accept_quality: 134 | break 135 | else: 136 | video_quality = 127 137 | 138 | audio_accept_quality = set([audio["id"] for audio in touch_message["data"]["dash"]["audio"]]) 139 | for audio_quality in audio_quality_sequence: 140 | if audio_quality in audio_accept_quality: 141 | break 142 | else: 143 | audio_quality = 30280 144 | 145 | res = spider.get(play_api_dash.format(avid=avid, bvid=bvid, cid=cid, quality=quality), timeout=3) 146 | 147 | if res.json()["data"]["dash"]["video"]: 148 | videos = res.json()["data"]["dash"]["video"] 149 | for video in videos: 150 | if video["id"] == video_quality: 151 | result.append( 152 | { 153 | "id": 1, 154 | "url": video["base_url"], 155 | "mirrors": video["backup_url"], 156 | "quality": video_quality, 157 | "height": video["height"], 158 | "width": video["width"], 159 | "size": touch_url(video["base_url"], spider)[0], 160 | "type": "dash_video", 161 | } 162 | ) 163 | break 164 | if res.json()["data"]["dash"]["audio"]: 165 | audios = res.json()["data"]["dash"]["audio"] 166 | for audio in audios: 167 | if audio["id"] == audio_quality: 168 | result.append( 169 | { 170 | "id": 2, 171 | "url": audio["base_url"], 172 | "mirrors": audio["backup_url"], 173 | "quality": audio_quality, 174 | "height": None, 175 | "width": None, 176 | "size": touch_url(audio["base_url"], spider)[0], 177 | "type": "dash_audio", 178 | } 179 | ) 180 | break 181 | return result 182 | elif type == "mp4": 183 | play_api_mp4 = play_api + "&platform=html5&high_quality=1" 184 | play_info = spider.get(play_api_mp4.format(avid=avid, bvid=bvid, cid=cid, quality=127), timeout=3).json() 185 | if play_info["code"] != 0: 186 | raise CannotDownloadError(play_info["code"], play_info["message"]) 187 | return [ 188 | { 189 | "id": 1, 190 | "url": play_info["data"]["durl"][0]["url"], 191 | "mirrors": [], 192 | "quality": play_info["data"]["quality"], 193 | "height": video_quality_map[play_info["data"]["quality"]]["height"], 194 | "width": video_quality_map[play_info["data"]["quality"]]["width"], 195 | "size": play_info["data"]["durl"][0]["size"], 196 | "type": "mp4_container", 197 | } 198 | ] 199 | else: 200 | raise UnknownTypeError(type) 201 | 202 | 203 | @MaxRetry(2) 204 | def get_acg_video_subtitle(avid: str = "", bvid: str = "", cid: str = ""): 205 | if not (avid or bvid): 206 | raise ArgumentsError("avid", "bvid") 207 | subtitle_api = "https://api.bilibili.com/x/player.so?id=cid:{cid}&aid={avid}&bvid={bvid}" 208 | subtitle_url = subtitle_api.format(avid=avid, cid=cid, bvid=bvid) 209 | res = spider.get(subtitle_url, timeout=3) 210 | subtitles_info = json.loads(re.search(r"(.+)", res.text).group(1)) 211 | return [ 212 | { 213 | "lang": sub_info["lan_doc"], 214 | "lines": spider.get("https:" + sub_info["subtitle_url"], timeout=(3, 10)).json()["body"] 215 | } 216 | for sub_info in subtitles_info["subtitles"] 217 | ] # fmt: skip 218 | -------------------------------------------------------------------------------- /src/bilili/api/bangumi.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ..api.exceptions import ArgumentsError, CannotDownloadError, IsPreviewError, UnknownTypeError, UnsupportTypeError 4 | from ..quality import Media, gen_quality_sequence, video_quality_map 5 | from ..tools import spider 6 | from ..utils.base import touch_url 7 | from .utils import MaxRetry 8 | 9 | 10 | @MaxRetry(2) 11 | def get_season_id(media_id: str) -> str: 12 | home_url = f"https://api.bilibili.com/pgc/review/user?media_id={media_id}" 13 | res_json = spider.get(home_url).json() 14 | return str(res_json["result"]["media"]["season_id"]) 15 | 16 | 17 | @MaxRetry(2) 18 | def get_bangumi_title(media_id: str = "", season_id: str = "", episode_id: str = "") -> str: 19 | if not (media_id or season_id or episode_id): 20 | raise ArgumentsError("media_id", "season_id", "episode_id") 21 | title = "呐,我也不知道是什么标题呢~" 22 | if media_id: 23 | home_url = f"https://www.bilibili.com/bangumi/media/md{media_id}" 24 | res = spider.get(home_url, timeout=3) 25 | regex_title = re.compile(r'(.*?)') 26 | if match_obj := regex_title.search(res.text): 27 | title = match_obj.group(1) 28 | elif season_id or episode_id: 29 | if season_id: 30 | play_url = f"https://www.bilibili.com/bangumi/play/ss{season_id}" 31 | else: 32 | play_url = f"https://www.bilibili.com/bangumi/play/ep{episode_id}" 33 | res = spider.get(play_url, timeout=3) 34 | regex_title = re.compile( 35 | r'(?P.*?)</a>' 36 | ) 37 | if match_obj := regex_title.search(res.text): 38 | title = match_obj.group("title") 39 | return title 40 | 41 | 42 | @MaxRetry(2) 43 | def get_bangumi_list(episode_id: str = "", season_id: str = "", with_section: bool = False): 44 | if not (season_id or episode_id): 45 | raise ArgumentsError("season_id", "episode_id") 46 | list_api = "http://api.bilibili.com/pgc/view/web/season?season_id={season_id}&ep_id={episode_id}" 47 | res = spider.get(list_api.format(episode_id=episode_id, season_id=season_id), timeout=3) 48 | result = res.json()["result"] 49 | section_episodes = [] 50 | 51 | if with_section and result.get("section", []): 52 | for section in result["section"]: 53 | section_episodes += section["episodes"] 54 | 55 | return [ 56 | { 57 | "id": i + 1, 58 | "name": " ".join( 59 | [ 60 | "第{}话".format(item["title"]) if re.match(r"^\d*\.?\d*$", item["title"]) else item["title"], 61 | item["long_title"], 62 | ] 63 | ), 64 | "cid": str(item["cid"]), 65 | "episode_id": str(item["id"]), 66 | "avid": str(item["aid"]), 67 | "bvid": item["bvid"], 68 | } 69 | for i, item in enumerate(result["episodes"] + section_episodes) 70 | ] 71 | 72 | 73 | @MaxRetry(2) 74 | def get_bangumi_playurl( 75 | avid: str = "", 76 | bvid: str = "", 77 | episode_id: str = "", 78 | cid: str = "", 79 | quality: int = 127, 80 | audio_quality: int = 30280, 81 | type: str = "dash", 82 | ): 83 | video_quality_sequence = gen_quality_sequence(quality, type=Media.VIDEO) 84 | audio_quality_sequence = gen_quality_sequence(audio_quality, type=Media.AUDIO) 85 | play_api = "https://api.bilibili.com/pgc/player/web/playurl?avid={avid}&bvid={bvid}&ep_id={episode_id}&cid={cid}&qn={quality}" 86 | if type == "flv": 87 | touch_message = spider.get( 88 | play_api.format(avid=avid, bvid=bvid, episode_id=episode_id, cid=cid, quality=80), timeout=3 89 | ).json() 90 | if touch_message["code"] != 0: 91 | raise CannotDownloadError(touch_message["code"], touch_message["message"]) 92 | if touch_message["result"]["is_preview"] == 1: 93 | raise IsPreviewError() 94 | 95 | accept_quality = touch_message["result"]["accept_quality"] 96 | for quality in video_quality_sequence: 97 | if quality in accept_quality: 98 | break 99 | 100 | play_url = play_api.format(avid=avid, bvid=bvid, episode_id=episode_id, cid=cid, quality=quality) 101 | res = spider.get(play_url, timeout=3) 102 | 103 | return [ 104 | { 105 | "id": i + 1, 106 | "url": segment["url"], 107 | "mirrors": segment["backup_url"], 108 | "quality": quality, 109 | "height": video_quality_map[quality]["height"], 110 | "width": video_quality_map[quality]["width"], 111 | "size": segment["size"], 112 | "type": "flv_segment", 113 | } 114 | for i, segment in enumerate(res.json()["result"]["durl"]) 115 | ] 116 | elif type == "dash": 117 | result = [] 118 | play_api_dash = play_api + "&fnver=0&fnval=4048&fourk=1" 119 | play_info = spider.get( 120 | play_api_dash.format( 121 | avid=avid, 122 | bvid=bvid, 123 | episode_id=episode_id, 124 | cid=cid, 125 | quality=video_quality_sequence[0], 126 | ), 127 | timeout=3, 128 | ).json() 129 | 130 | if play_info["code"] != 0: 131 | raise CannotDownloadError(play_info["code"], play_info["message"]) 132 | if play_info["result"].get("dash") is None: 133 | raise UnsupportTypeError("dash") 134 | if play_info["result"]["is_preview"] == 1: 135 | raise IsPreviewError() 136 | 137 | accept_video_quality = set([video["id"] for video in play_info["result"]["dash"]["video"]]) 138 | for video_quality in video_quality_sequence: 139 | if video_quality in accept_video_quality: 140 | break 141 | else: 142 | video_quality = 127 143 | 144 | accept_audio_quality = set([audio["id"] for audio in play_info["result"]["dash"]["audio"]]) 145 | for audio_quality in audio_quality_sequence: 146 | if audio_quality in accept_audio_quality: 147 | break 148 | else: 149 | audio_quality = 30280 150 | 151 | if play_info["result"]["dash"]["video"]: 152 | videos = play_info["result"]["dash"]["video"] 153 | for video in videos: 154 | if video["id"] == video_quality: 155 | result.append( 156 | { 157 | "id": 1, 158 | "url": video["base_url"], 159 | "mirrors": video["backup_url"], 160 | "quality": video_quality, 161 | "height": video["height"], 162 | "width": video["width"], 163 | "size": touch_url(video["base_url"], spider)[0], 164 | "type": "dash_video", 165 | } 166 | ) 167 | break 168 | if play_info["result"]["dash"]["audio"]: 169 | audios = play_info["result"]["dash"]["audio"] 170 | for audio in audios: 171 | if audio["id"] == audio_quality: 172 | result.append( 173 | { 174 | "id": 2, 175 | "url": audio["base_url"], 176 | "mirrors": audio["backup_url"], 177 | "quality": audio_quality, 178 | "height": None, 179 | "width": None, 180 | "size": touch_url(audio["base_url"], spider)[0], 181 | "type": "dash_audio", 182 | } 183 | ) 184 | break 185 | return result 186 | elif type == "mp4": 187 | raise UnsupportTypeError("mp4") 188 | else: 189 | raise UnknownTypeError(type) 190 | 191 | 192 | @MaxRetry(2) 193 | def get_bangumi_subtitle(avid: str = "", bvid: str = "", cid: str = ""): 194 | if not (avid or bvid): 195 | raise ArgumentsError("avid", "bvid") 196 | subtitle_api = "https://api.bilibili.com/x/player/v2?cid={cid}&aid={avid}&bvid={bvid}" 197 | subtitle_url = subtitle_api.format(avid=avid, bvid=bvid, cid=cid) 198 | subtitles_info = spider.get(subtitle_url, timeout=3).json()["data"]["subtitle"] 199 | return [ 200 | { 201 | "lang": sub_info["lan_doc"], 202 | "lines": spider.get("https:" + sub_info["subtitle_url"], timeout=(3, 10)).json()["body"] 203 | } 204 | for sub_info in subtitles_info["subtitles"] 205 | ] # fmt: skip 206 | -------------------------------------------------------------------------------- /src/bilili/api/danmaku.py: -------------------------------------------------------------------------------- 1 | from ..tools import spider 2 | from .utils import MaxRetry 3 | 4 | 5 | @MaxRetry(2) 6 | def get_danmaku(cid: str) -> str: 7 | danmaku_api = "http://comment.bilibili.com/{cid}.xml" 8 | res = spider.get(danmaku_api.format(cid=cid), timeout=(3, 18)) 9 | res.encoding = "utf-8" 10 | return res.text 11 | -------------------------------------------------------------------------------- /src/bilili/api/exceptions.py: -------------------------------------------------------------------------------- 1 | class APIException(Exception): 2 | def __init__(self, code, message): 3 | super().__init__(code, message) 4 | self.code = code 5 | self.message = message 6 | 7 | 8 | class ArgumentsError(APIException): 9 | def __init__(self, *args): 10 | message = "参数 " + ",".join(args) + " 均空" 11 | super().__init__(101, message) 12 | 13 | 14 | class CannotDownloadError(APIException): 15 | def __init__(self, code, message): 16 | message = f"「{code}」 {message}" 17 | super().__init__(102, message) 18 | 19 | 20 | class UnknownTypeError(APIException): 21 | def __init__(self, type): 22 | message = f"未知类型:{type}" 23 | super().__init__(103, message) 24 | 25 | 26 | class UnsupportTypeError(APIException): 27 | def __init__(self, type): 28 | message = f"不受支持的类型:{type}" 29 | super().__init__(104, message) 30 | 31 | 32 | class IsPreviewError(APIException): 33 | def __init__(self): 34 | message = "本视频是预览视频" 35 | super().__init__(105, message) 36 | 37 | 38 | class MaxRetryError(APIException): 39 | def __init__(self): 40 | message = "超出最大重试次数" 41 | super().__init__(106, message) 42 | -------------------------------------------------------------------------------- /src/bilili/api/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TypeVar 2 | 3 | import requests 4 | 5 | from ..utils.console.logger import Logger 6 | from .exceptions import MaxRetryError 7 | 8 | T = TypeVar("T") 9 | 10 | 11 | class MaxRetry: 12 | def __init__(self, max_retry: int = 2): 13 | self.max_retry = max_retry 14 | 15 | def __call__(self, connect_once: Callable[..., T]) -> Callable[..., T]: 16 | def connect_n_times(*args: Any, **kwargs: Any) -> T: 17 | retry = self.max_retry + 1 18 | while retry: 19 | try: 20 | return connect_once(*args, **kwargs) 21 | except requests.exceptions.Timeout: 22 | Logger.warning("抓取超时,正在尝试重新连接~") 23 | finally: 24 | retry -= 1 25 | raise MaxRetryError() 26 | 27 | return connect_n_times 28 | -------------------------------------------------------------------------------- /src/bilili/api/vip.py: -------------------------------------------------------------------------------- 1 | from ..tools import spider 2 | from .utils import MaxRetry 3 | 4 | 5 | @MaxRetry(2) 6 | def is_vip() -> bool: 7 | info_api = "https://api.bilibili.com/x/web-interface/nav" 8 | res_json = spider.get(info_api, timeout=3).json() 9 | res_json_data = res_json.get("data") 10 | if res_json_data.get("vipStatus") == 1: 11 | return True 12 | return False 13 | -------------------------------------------------------------------------------- /src/bilili/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/handlers/__init__.py -------------------------------------------------------------------------------- /src/bilili/handlers/base.py: -------------------------------------------------------------------------------- 1 | noop = lambda *args, **kwargs: None 2 | 3 | 4 | class Handler: 5 | """事件处理器""" 6 | 7 | def __init__(self, events: list[str] = []): 8 | """初始化事件处理器 9 | 10 | Args: 11 | events (List[str], optional): 事件名称. Defaults to []. 12 | """ 13 | self.events = events 14 | for event in self.events: 15 | setattr(self, event, noop) 16 | 17 | def on(self, event: str): 18 | """事件添加装饰器 19 | 20 | Args: 21 | event (str): 事件名称 22 | 23 | Returns: 24 | function: 在发生 event 后的触发事件,会自动注册在 handler 上 25 | """ 26 | assert event in self.events 27 | 28 | def on_event(func): 29 | setattr(self, event, func) 30 | 31 | return on_event 32 | -------------------------------------------------------------------------------- /src/bilili/handlers/downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | from collections.abc import Callable 4 | from typing import Union 5 | 6 | import requests 7 | 8 | from ..handlers.base import Handler 9 | from ..utils.console.logger import Logger 10 | from ..utils.crawler import Crawler 11 | 12 | 13 | class RemoteFile(Handler): 14 | """远程文件类 15 | 16 | 网络 url 与本地文件的绑定,可调用 download 进行下载 17 | download 支持断点续传 18 | """ 19 | 20 | before_download: Callable[..., None] 21 | downloaded: Callable[..., None] 22 | before_update: Callable[..., None] 23 | updated: Callable[..., None] 24 | 25 | def __init__( 26 | self, url: str, local_path: str, mirrors: list[str] = [], range: tuple[int, Union[int, str]] = (0, "") 27 | ): 28 | super().__init__(["before_download", "before_update", "updated", "downloaded"]) 29 | self.url = url 30 | self.mirrors = mirrors 31 | self.path = local_path 32 | self.name = os.path.split(self.path)[-1] 33 | self.tmp_path = self.path + ".dl" 34 | self.size = self.get_local_size() 35 | self.range = range 36 | 37 | def get_local_size(self) -> int: 38 | """通过 os.path.getsize 获取本地文件大小""" 39 | try: 40 | if os.path.exists(self.tmp_path): 41 | size = os.path.getsize(self.tmp_path) 42 | elif os.path.exists(self.path): 43 | size = os.path.getsize(self.path) 44 | else: 45 | size = 0 46 | except FileNotFoundError: 47 | size = 0 48 | return size 49 | 50 | def download(self, thread_spider: Crawler, stream: bool = True, chunk_size: int = 1024): 51 | """[summary] 52 | 53 | Args: 54 | thread_spider (requests.Session): 线程全局下载器,由线程池管理并传入,每个线程拥有一个 55 | stream (bool, optional): 是否启用流式下载. Defaults to True. 56 | chunk_size (int, optional): 块大小. Defaults to 1024. 57 | """ 58 | spider = thread_spider 59 | self.before_download(self) 60 | if not os.path.exists(self.path): 61 | downloaded = False 62 | while not downloaded: 63 | # 设置 headers 64 | headers = dict(spider.headers) 65 | headers["Range"] = f"bytes={self.size + self.range[0]}-{self.range[1]}" 66 | url = random.choice([self.url] + self.mirrors) if self.mirrors else self.url 67 | 68 | try: 69 | # 尝试建立连接 70 | res = spider.get(url, stream=stream, headers=headers, timeout=(5, 10)) 71 | # 下载到临时路径 72 | with open(self.tmp_path, "ab") as f: 73 | if stream: 74 | for chunk in res.iter_content(chunk_size=chunk_size): 75 | if not chunk: 76 | break 77 | self.before_update(self) 78 | f.write(chunk) 79 | self.size += len(chunk) 80 | self.updated(self) 81 | else: 82 | f.write(res.content) 83 | # size 检验,因为有时明明没下完仍然会停止下载 84 | if self.range[1] and (self.range[1] - self.range[0] + 1 != self.size): 85 | downloaded = False 86 | else: 87 | downloaded = True 88 | except requests.exceptions.RequestException: 89 | Logger.warning(f"文件 {self.name} 下载超时,正在重试...") 90 | 91 | # 从临时文件迁移,并删除临时文件 92 | if os.path.exists(self.path): 93 | os.remove(self.path) 94 | os.rename(self.tmp_path, self.path) 95 | 96 | self.downloaded(self) 97 | -------------------------------------------------------------------------------- /src/bilili/handlers/merger.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Callable 3 | 4 | from ..handlers.base import Handler 5 | from ..utils.console.logger import Logger 6 | from ..utils.ffmpeg import FFmpeg 7 | 8 | ffmpeg = FFmpeg(tmp_dir=".bilili_cache") 9 | 10 | 11 | class MergingFile(Handler): 12 | before_merge: Callable[..., None] 13 | merged: Callable[..., None] 14 | 15 | def __init__(self, type: str, src_path_list: list[str] = [], dst_path: str = ""): 16 | super().__init__(["before_merge", "merged"]) 17 | self.type = type 18 | self.src_path_list = src_path_list 19 | self.dst_path = dst_path 20 | 21 | def merge(self): 22 | self.before_merge(self) 23 | if self.type == "mp4" or self.type is None: 24 | with open(self.dst_path, "wb") as fw: 25 | for src_path in self.src_path_list: 26 | with open(src_path, "rb") as fr: 27 | fw.write(fr.read()) 28 | elif self.type == "flv": 29 | ffmpeg.join_videos(self.src_path_list, self.dst_path) 30 | elif self.type == "dash": 31 | if len(self.src_path_list) == 2: 32 | ffmpeg.join_video_audio(self.src_path_list[0], self.src_path_list[1], self.dst_path) 33 | else: 34 | ffmpeg.convert(self.src_path_list[0], self.dst_path) 35 | else: 36 | Logger.error(f"未知类型: {self.type}") 37 | for src_path in self.src_path_list: 38 | os.remove(src_path) 39 | self.merged(self) 40 | -------------------------------------------------------------------------------- /src/bilili/handlers/status.py: -------------------------------------------------------------------------------- 1 | from ..utils.console.logger import Logger 2 | 3 | 4 | class Status: 5 | """多层次状态管理类""" 6 | 7 | def __init__(self, parent: "Status | None" = None, children: list["Status"] = []): 8 | self.parent = None 9 | self.children = [] 10 | if parent is not None: 11 | self.set_parent(parent) 12 | if children: 13 | self.add_children(children) 14 | 15 | def add_child(self, child: "Status"): 16 | self.children.append(child) 17 | child.parent = self 18 | 19 | def set_parent(self, parent: "Status"): 20 | parent.add_child(self) 21 | 22 | def add_children(self, children: list["Status"]): 23 | for child in children: 24 | self.add_child(child) 25 | 26 | @property 27 | def is_leaf(self) -> bool: 28 | return not self.children 29 | 30 | @property 31 | def is_root(self) -> bool: 32 | return self.parent is None 33 | 34 | 35 | class DownloaderStatus(Status): 36 | """下载状态类""" 37 | 38 | def __init__(self, parent: "Status | None" = None, children: list["Status"] = []): 39 | super().__init__(parent=parent, children=children) 40 | self.__total_size: int = 0 41 | self.__size: int = 0 42 | self.__downloading: bool = False 43 | self.__downloaded: bool = False 44 | self.__merging: bool = False 45 | self.__merged: bool = False 46 | 47 | @property 48 | def total_size(self) -> int: 49 | if self.is_leaf: 50 | return self.__total_size 51 | else: 52 | return sum([child.total_size for child in self.children]) 53 | 54 | @total_size.setter 55 | def total_size(self, value: int): 56 | if self.is_leaf: 57 | self.__total_size = value 58 | else: 59 | Logger.error("无法设定非叶子结点的 total_size") 60 | 61 | @property 62 | def size(self) -> int: 63 | if self.is_leaf: 64 | if self.downloaded: 65 | return self.total_size 66 | return self.__size 67 | else: 68 | return sum([child.size for child in self.children]) 69 | 70 | @size.setter 71 | def size(self, value: int): 72 | if self.is_leaf: 73 | self.__size = value 74 | else: 75 | Logger.error("无法设定非叶子结点的 size") 76 | 77 | @property 78 | def downloading(self) -> bool: 79 | if self.is_leaf: 80 | return self.__downloading 81 | else: 82 | return any([child.downloading for child in self.children]) 83 | 84 | @downloading.setter 85 | def downloading(self, value: bool): 86 | if self.is_leaf: 87 | self.__downloading = value 88 | else: 89 | if value: 90 | Logger.error("无法设定非叶子结点的 downloading 为 True") 91 | else: 92 | for child in self.children: 93 | child.downloading = False 94 | 95 | @property 96 | def downloaded(self) -> bool: 97 | if self.is_leaf: 98 | return self.__downloaded 99 | else: 100 | return all([child.downloaded for child in self.children]) 101 | 102 | @downloaded.setter 103 | def downloaded(self, value: bool): 104 | if self.is_leaf: 105 | self.__downloaded = value 106 | else: 107 | if value: 108 | for child in self.children: 109 | child.downloaded = True 110 | else: 111 | Logger.error("无法设定非叶子结点的 downloaded 为 False") 112 | 113 | @property 114 | def merging(self) -> bool: 115 | if self.is_leaf: 116 | return self.__merging 117 | else: 118 | return any([child.merging for child in self.children]) 119 | 120 | @merging.setter 121 | def merging(self, value: bool): 122 | if self.is_leaf: 123 | self.__merging = value 124 | else: 125 | if value: 126 | # 由于合并是共同进行的,所以可以由父结点来设置 127 | for child in self.children: 128 | child.merging = True 129 | else: 130 | for child in self.children: 131 | child.merging = False 132 | 133 | @property 134 | def merged(self) -> bool: 135 | if self.is_leaf: 136 | return self.__merged 137 | else: 138 | return all([child.merged for child in self.children]) 139 | 140 | @merged.setter 141 | def merged(self, value: bool): 142 | if self.is_leaf: 143 | self.__merged = value 144 | else: 145 | if value: 146 | for child in self.children: 147 | child.merged = True 148 | else: 149 | Logger.error("无法设定非叶子结点的 merged 为 False") 150 | -------------------------------------------------------------------------------- /src/bilili/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/parser/__init__.py -------------------------------------------------------------------------------- /src/bilili/parser/acg_video.py: -------------------------------------------------------------------------------- 1 | from ..api.acg_video import get_acg_video_list, get_acg_video_playurl, get_acg_video_subtitle, get_acg_video_title 2 | 3 | 4 | def get_title(resource_id): 5 | return get_acg_video_title(avid=resource_id.avid, bvid=resource_id.bvid) 6 | 7 | 8 | def get_list(resource_id, with_section: bool = False): 9 | video_list = get_acg_video_list(avid=resource_id.avid, bvid=resource_id.bvid) 10 | return [ 11 | { 12 | "id": video["id"], 13 | "name": video["name"], 14 | "meta": { 15 | "avid": resource_id.avid, 16 | "bvid": resource_id.bvid, 17 | "cid": video["cid"] 18 | }, 19 | } 20 | for video in video_list 21 | ] # fmt: skip 22 | 23 | 24 | def get_playurl(container, quality, audio_quality): 25 | play_list = get_acg_video_playurl( 26 | avid=container.meta["avid"], 27 | bvid=container.meta["bvid"], 28 | cid=container.meta["cid"], 29 | quality=quality, 30 | audio_quality=audio_quality, 31 | type=container.type, 32 | ) 33 | return [ 34 | { 35 | "id": play_info["id"], 36 | "url": play_info["url"], 37 | "mirrors": play_info["mirrors"], 38 | "quality": play_info["quality"], 39 | "height": play_info["height"], 40 | "width": play_info["width"], 41 | "size": play_info["size"], 42 | "type": play_info["type"], 43 | } 44 | for play_info in play_list 45 | ] 46 | 47 | 48 | def get_subtitle(container): 49 | return get_acg_video_subtitle( 50 | avid=container.meta["avid"], 51 | bvid=container.meta["bvid"], 52 | cid=container.meta["cid"], 53 | ) 54 | -------------------------------------------------------------------------------- /src/bilili/parser/bangumi.py: -------------------------------------------------------------------------------- 1 | from ..api.bangumi import get_bangumi_list, get_bangumi_playurl, get_bangumi_subtitle, get_bangumi_title 2 | 3 | 4 | def get_title(resource_id): 5 | return get_bangumi_title(season_id=resource_id.season_id, episode_id=resource_id.episode_id) 6 | 7 | 8 | def get_list(resource_id, with_section: bool = False): 9 | video_list = get_bangumi_list( 10 | season_id=resource_id.season_id, episode_id=resource_id.episode_id, with_section=with_section 11 | ) 12 | return [ 13 | { 14 | "id": video["id"], 15 | "name": video["name"], 16 | "meta": { 17 | "avid": video["avid"], 18 | "bvid": video["bvid"], 19 | "cid": video["cid"], 20 | "episode_id": video["episode_id"] 21 | }, 22 | } 23 | for video in video_list 24 | ] # fmt: skip 25 | 26 | 27 | def get_playurl(container, quality, audio_quality): 28 | play_list = get_bangumi_playurl( 29 | avid=container.meta["avid"], 30 | episode_id=container.meta["episode_id"], 31 | cid=container.meta["cid"], 32 | quality=quality, 33 | audio_quality=audio_quality, 34 | type=container.type, 35 | ) 36 | return [ 37 | { 38 | "id": play_info["id"], 39 | "url": play_info["url"], 40 | "mirrors": play_info["mirrors"], 41 | "quality": play_info["quality"], 42 | "height": play_info["height"], 43 | "width": play_info["width"], 44 | "size": play_info["size"], 45 | "type": play_info["type"], 46 | } 47 | for play_info in play_list 48 | ] 49 | 50 | 51 | def get_subtitle(container): 52 | return get_bangumi_subtitle( 53 | avid=container.meta["avid"], 54 | bvid=container.meta["bvid"], 55 | cid=container.meta["cid"], 56 | ) 57 | -------------------------------------------------------------------------------- /src/bilili/quality.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Media(Enum): 5 | VIDEO = 0 6 | AUDIO = 30200 7 | 8 | 9 | video_quality_sequence_default = [127, 125, 120, 116, 112, 80, 74, 64, 32, 16] 10 | audio_quality_sequence_default = [30280, 30232, 30216] 11 | 12 | video_quality_map = { 13 | 127: { 14 | "description": "8K 超高清", 15 | "width": 8192, 16 | "height": 4320, 17 | }, 18 | 125: { 19 | "description": "HDR 真彩", 20 | "width": 3840, 21 | "height": 1920, 22 | }, 23 | 120: { 24 | "description": "4K 超清", 25 | "width": 3840, 26 | "height": 1920, 27 | }, 28 | 116: { 29 | "description": "1080P 60帧", 30 | "width": 2160, 31 | "height": 1080, 32 | }, 33 | 112: { 34 | "description": "1080P 高码率", 35 | "width": 2160, 36 | "height": 1080, 37 | }, 38 | 80: { 39 | "description": "1080P 高清", 40 | "width": 2160, 41 | "height": 1080, 42 | }, 43 | 74: { 44 | "description": "720P 60帧", 45 | "width": 1440, 46 | "height": 720, 47 | }, 48 | 64: { 49 | "description": "720P 高清", 50 | "width": 1440, 51 | "height": 720, 52 | }, 53 | 32: { 54 | "description": "480P 清晰", 55 | "width": 960, 56 | "height": 480, 57 | }, 58 | 16: { 59 | "description": "360P 流畅", 60 | "width": 720, 61 | "height": 360, 62 | }, 63 | 6: { 64 | "description": "240P 极速", 65 | "width": 320, 66 | "height": 240, 67 | }, 68 | 208: { 69 | "description": "1080P 高清", 70 | "width": 1920, 71 | "height": 1080, 72 | }, 73 | 192: { 74 | "description": "720P 高清", 75 | "width": 1280, 76 | "height": 720, 77 | }, 78 | } 79 | 80 | audio_quality_map = { 81 | 30280: { 82 | "description": "320kbps", 83 | "bitrate": 320, 84 | }, 85 | 30232: { 86 | "description": "128kbps", 87 | "bitrate": 128, 88 | }, 89 | 30216: { 90 | "description": "64kbps", 91 | "bitrate": 64, 92 | }, 93 | 0: {"description": "Unknown", "bitrate": 0}, 94 | } 95 | 96 | 97 | def gen_quality_sequence(quality: int = 127, type: Media = Media.VIDEO) -> list[int]: 98 | """根据默认先降后升的清晰度机制生成清晰度序列""" 99 | quality_sequence_default = { 100 | Media.VIDEO: video_quality_sequence_default, 101 | Media.AUDIO: audio_quality_sequence_default, 102 | }[type] 103 | return quality_sequence_default[quality_sequence_default.index(quality) :] + list( 104 | reversed(quality_sequence_default[: quality_sequence_default.index(quality)]) 105 | ) 106 | -------------------------------------------------------------------------------- /src/bilili/tools.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .handlers.status import DownloaderStatus 4 | from .utils.crawler import BililiCrawler 5 | from .utils.functiontools.attrdict import AttrDict 6 | 7 | # avid 8 | regex_acg_video_av = re.compile(r"https?://(www\.|m\.)?bilibili\.com/video/av(?P<avid>\d+)") 9 | regex_acg_video_av_short = re.compile(r"https?://b23\.tv/av(?P<avid>\d+)") 10 | 11 | # bvid 12 | regex_acg_video_bv = re.compile(r"https?://(www\.|m\.)?bilibili\.com/video/(?P<bvid>(bv|BV)\w+)") 13 | regex_acg_video_bv_short = re.compile(r"https?://b23\.tv/(?P<bvid>(bv|BV)\w+)") 14 | 15 | # media id 16 | regex_bangumi_md = re.compile(r"https?://(www\.|m\.)?bilibili\.com/bangumi/media/md(?P<media_id>\d+)") 17 | 18 | # episode id 19 | regex_bangumi_ep = re.compile(r"https?://(www\.|m\.)?bilibili\.com/bangumi/play/ep(?P<episode_id>\d+)") 20 | regex_bangumi_ep_short = re.compile(r"https?://b23\.tv/ep(?P<episode_id>\d+)") 21 | 22 | # season id 23 | regex_bangumi_ss = re.compile(r"https?://(www\.|m\.)?bilibili\.com/bangumi/play/ss(?P<season_id>\d+)") 24 | regex_bangumi_ss_short = re.compile(r"https?://b23\.tv/ss(?P<season_id>\d+)") 25 | 26 | 27 | spider = BililiCrawler() 28 | global_status = DownloaderStatus() 29 | regex = { 30 | "acg_video": { 31 | "av": { 32 | "origin": regex_acg_video_av, 33 | "short": regex_acg_video_av_short, 34 | }, 35 | "bv": { 36 | "origin": regex_acg_video_bv, 37 | "short": regex_acg_video_bv_short, 38 | }, 39 | }, 40 | "bangumi": { 41 | "md": { 42 | "origin": regex_bangumi_md, 43 | }, 44 | "ep": { 45 | "origin": regex_bangumi_ep, 46 | "short": regex_bangumi_ep_short, 47 | }, 48 | "ss": { 49 | "origin": regex_bangumi_ss, 50 | "short": regex_bangumi_ss_short, 51 | }, 52 | }, 53 | } >> AttrDict() 54 | -------------------------------------------------------------------------------- /src/bilili/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/utils/__init__.py -------------------------------------------------------------------------------- /src/bilili/utils/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import re 4 | from typing import Any, Optional 5 | 6 | 7 | class Ref: 8 | """引用类 9 | 10 | 用于包裹基本数据类型,将其封装为对象,其值通过 var.value 来访问 11 | """ 12 | 13 | def __init__(self, value: Any): 14 | self.value = value 15 | 16 | 17 | class Writer: 18 | """文件写入器,持续打开文件对象,直到使用完毕后才关闭""" 19 | 20 | def __init__(self, path: str, mode: str = "wb", **kwargs: Any): 21 | self.path = path 22 | self._f = open(path, mode, **kwargs) 23 | 24 | def __del__(self): 25 | self._f.close() 26 | 27 | def flush(self): 28 | self._f.flush() 29 | 30 | def write(self, content: str): 31 | self._f.write(content) 32 | 33 | 34 | class Text(Writer): 35 | """文本写入器""" 36 | 37 | def __init__(self, path: str, **kwargs: Any): 38 | kwargs["encoding"] = kwargs.get("encoding", "utf-8") 39 | super().__init__(path, "w", **kwargs) 40 | 41 | def write_string(self, string: str): 42 | self.write(string + "\n") 43 | 44 | 45 | def touch_dir(path: str) -> str: 46 | """若文件夹不存在则新建,并返回标准路径""" 47 | if not os.path.exists(path): 48 | os.makedirs(path) 49 | return os.path.normpath(path) 50 | 51 | 52 | def touch_file(path: str) -> str: 53 | """若文件不存在则新建,并返回标准路径""" 54 | if not os.path.exists(path): 55 | open(path, "w").close() 56 | return os.path.normpath(path) 57 | 58 | 59 | def touch_url(url: str, spider) -> tuple[Optional[str], bool]: 60 | """与资源进行测试连接,并获取该资源的 size 与 是否可以断点续传""" 61 | # 某些资源 head 无法获得真实 size 62 | methods = [spider.head, spider.get] 63 | for method in methods: 64 | res = method(url, headers={"Range": "bytes=0-4"}) 65 | size, resumable = None, False 66 | if res.headers.get("Content-Range"): 67 | size = int(res.headers["Content-Range"].split("/")[-1]) 68 | resumable = True 69 | elif res.headers.get("Content-Length"): 70 | size = int(res.headers["Content-Length"]) 71 | resumable = False 72 | else: 73 | size = None 74 | resumable = False 75 | if size and resumable: 76 | break 77 | return size, resumable 78 | 79 | 80 | def repair_filename(filename: str) -> str: 81 | """修复不合法的文件名""" 82 | 83 | def to_full_width_chr(matchobj: "re.Match[str]") -> str: 84 | char = matchobj.group(0) 85 | full_width_char = chr(ord(char) + ord("?") - ord("?")) 86 | return full_width_char 87 | 88 | # 路径非法字符,转全角 89 | regex_path = re.compile(r'[\\/:*?"<>|]') 90 | # 空格类字符,转空格 91 | regex_spaces = re.compile(r"\s+") 92 | # 不可打印字符,移除 93 | regex_non_printable = re.compile( 94 | r"[\001\002\003\004\005\006\007\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" 95 | r"\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a]" 96 | ) 97 | 98 | filename = regex_path.sub(to_full_width_chr, filename) 99 | filename = regex_spaces.sub(" ", filename) 100 | filename = regex_non_printable.sub("", filename) 101 | filename = filename.strip() 102 | filename = filename if filename else f"file_{random.randint(0, 9999):04}" 103 | return filename 104 | 105 | 106 | def get_size(path: str) -> int: 107 | """获取文件夹或文件的字节数""" 108 | if os.path.isfile(path): 109 | return os.path.getsize(path) 110 | elif os.path.isdir(path): 111 | size = 0 112 | for subpath in os.listdir(path): 113 | size += get_size(os.path.join(path, subpath)) 114 | return size 115 | else: 116 | return 0 117 | 118 | 119 | def size_format(size: float, ndigits: int = 2) -> str: 120 | """输入数据字节数,与保留小数位数,返回数据量字符串""" 121 | flag = "-" if size < 0 else "" 122 | size = abs(size) 123 | units = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "BiB"] 124 | idx = len(units) - 1 125 | unit = "" 126 | unit_size = 0 127 | while idx >= 0: 128 | unit_size = 2 ** (idx * 10) 129 | if size >= unit_size: 130 | unit = units[idx] 131 | break 132 | idx -= 1 133 | return "{}{:.{}f} {}".format(flag, size / unit_size, ndigits, unit) 134 | 135 | 136 | def get_char_width(char: str) -> int: 137 | """计算单个字符的宽度""" 138 | widths = [ 139 | (126, 1), (159, 0), (687, 1), (710, 0), (711, 1), 140 | (727, 0), (733, 1), (879, 0), (1154, 1), (1161, 0), 141 | (4347, 1), (4447, 2), (7467, 1), (7521, 0), (8369, 1), 142 | (8426, 0), (9000, 1), (9002, 2), (11021, 1), (12350, 2), 143 | (12351, 1), (12438, 2), (12442, 0), (19893, 2), (19967, 1), 144 | (55203, 2), (63743, 1), (64106, 2), (65039, 1), (65059, 0), 145 | (65131, 2), (65279, 1), (65376, 2), (65500, 1), (65510, 2), 146 | (120831, 1), (262141, 2), (1114109, 1), 147 | ] # fmt: skip 148 | 149 | o = ord(char) 150 | if o == 0xE or o == 0xF: 151 | return 0 152 | for num, wid in widths: 153 | if o <= num: 154 | return wid 155 | return 1 156 | 157 | 158 | def get_string_width(string: str) -> int: 159 | """计算包含中文的字符串宽度""" 160 | # 去除颜色码 161 | regex_color = re.compile(r"\033\[\d+m") 162 | string = regex_color.sub("", string) 163 | try: 164 | length = sum([get_char_width(c) for c in string]) 165 | except Exception: 166 | length = len(string) 167 | return length 168 | -------------------------------------------------------------------------------- /src/bilili/utils/console/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/utils/console/__init__.py -------------------------------------------------------------------------------- /src/bilili/utils/console/colorful.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Optional 4 | 5 | # 使部分终端正确显示颜色 6 | if sys.platform == "win32": 7 | os.system("") 8 | 9 | Fore = str 10 | Back = str 11 | Style = str 12 | CodeMap = dict[str, dict[str, int]] 13 | 14 | _no_color = False 15 | 16 | 17 | code_map: CodeMap = { 18 | "fore": { 19 | "black": 30, 20 | "red": 31, 21 | "green": 32, 22 | "yellow": 33, 23 | "blue": 34, 24 | "magenta": 35, 25 | "cyan": 36, 26 | "white": 37, 27 | }, 28 | "back": { 29 | "black": 40, 30 | "red": 41, 31 | "green": 42, 32 | "yellow": 43, 33 | "blue": 44, 34 | "magenta": 45, 35 | "cyan": 46, 36 | "white": 47, 37 | }, 38 | "style": { 39 | "reset": 0, 40 | "bold": 1, 41 | "italic": 3, 42 | "underline": 4, 43 | "defaultfg": 39, 44 | "defaultbg": 49, 45 | }, 46 | } 47 | 48 | 49 | def colored_string( 50 | string: str, fore: Optional[Fore] = None, back: Optional[Back] = None, style: Optional[Style] = None 51 | ) -> str: 52 | if _no_color: 53 | return string 54 | template = "\033[{code}m" 55 | result = "" 56 | if fore is not None: 57 | result += template.format(code=code_map["fore"][fore]) 58 | if back is not None: 59 | result += template.format(code=code_map["back"][back]) 60 | if style is not None: 61 | result += template.format(code=code_map["style"][style]) 62 | result += string 63 | result += template.format(code=code_map["style"]["reset"]) 64 | return result 65 | 66 | 67 | def set_no_color(): 68 | global _no_color 69 | _no_color = True 70 | -------------------------------------------------------------------------------- /src/bilili/utils/console/logger.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from ..base import get_string_width 4 | from ..functiontools.singleton import Singleton 5 | from .colorful import Back, Fore, Style, colored_string 6 | 7 | _logger_debug = False 8 | 9 | 10 | def set_logger_debug(): 11 | global _logger_debug 12 | _logger_debug = True 13 | 14 | 15 | class Badge: 16 | def __init__( 17 | self, 18 | text: str = "CUSTOM", 19 | fore: Optional[Fore] = None, 20 | back: Optional[Back] = None, 21 | style: Optional[Style] = None, 22 | ): 23 | self.text: str = text 24 | self.fore: Optional[Fore] = fore 25 | self.back: Optional[Back] = back 26 | self.style: Optional[Style] = style 27 | 28 | def __str__(self): 29 | return colored_string(f" {self.text} ", fore=self.fore, back=self.back, style=self.style) 30 | 31 | def __repr__(self): 32 | return str(self) 33 | 34 | def __len__(self): 35 | return get_string_width(str(self)) 36 | 37 | def __add__(self, other: str) -> str: 38 | return str(self) + other 39 | 40 | 41 | WARNING_BADGE = Badge("WARN", fore="black", back="yellow") 42 | ERROR_BADGE = Badge("ERROR", fore="white", back="red") 43 | INFO_BADGE = Badge("INFO", fore="black", back="green") 44 | DEBUG_BADGE = Badge("DEBUG", fore="black", back="blue") 45 | 46 | 47 | class Logger(metaclass=Singleton): 48 | @classmethod 49 | def custom(cls, string: Any, badge: Badge, *print_args: Any, **print_kwargs: Any): 50 | prefix = badge + " " 51 | print(prefix + str(string), *print_args, **print_kwargs) 52 | 53 | @classmethod 54 | def warning(cls, string: Any, *print_args: Any, **print_kwargs: Any): 55 | Logger.custom(string, WARNING_BADGE, *print_args, **print_kwargs) 56 | 57 | @classmethod 58 | def error(cls, string: Any, *print_args: Any, **print_kwargs: Any): 59 | Logger.custom(string, ERROR_BADGE, *print_args, **print_kwargs) 60 | 61 | @classmethod 62 | def info(cls, string: Any, *print_args: Any, **print_kwargs: Any): 63 | Logger.custom(string, INFO_BADGE, *print_args, **print_kwargs) 64 | 65 | @classmethod 66 | def debug(cls, string: Any, *print_args: Any, **print_kwargs: Any): 67 | if not _logger_debug: 68 | return 69 | Logger.custom(string, DEBUG_BADGE, *print_args, **print_kwargs) 70 | 71 | @classmethod 72 | def custom_multiline(cls, string: Any, badge: Badge, *print_args: Any, **print_kwargs: Any): 73 | prefix = badge + " " 74 | lines = string.split("\n") 75 | multiline_string = prefix + "\n".join( 76 | [((" " * get_string_width(prefix)) if i != 0 else "") + line for i, line in enumerate(lines)] 77 | ) 78 | print(multiline_string, *print_args, **print_kwargs) 79 | 80 | @classmethod 81 | def warning_multiline(cls, string: Any, *print_args: Any, **print_kwargs: Any): 82 | Logger.custom_multiline(string, WARNING_BADGE, *print_args, **print_kwargs) 83 | 84 | @classmethod 85 | def error_multiline(cls, string: Any, *print_args: Any, **print_kwargs: Any): 86 | Logger.custom_multiline(string, ERROR_BADGE, *print_args, **print_kwargs) 87 | 88 | @classmethod 89 | def info_multiline(cls, string: Any, *print_args: Any, **print_kwargs: Any): 90 | Logger.custom_multiline(string, INFO_BADGE, *print_args, **print_kwargs) 91 | 92 | @classmethod 93 | def debug_multiline(cls, string: Any, *print_args: Any, **print_kwargs: Any): 94 | if not _logger_debug: 95 | return 96 | Logger.custom_multiline(string, INFO_BADGE, *print_args, **print_kwargs) 97 | 98 | @classmethod 99 | def print(cls, string: Any, *print_args: Any, **print_kwargs: Any): 100 | print(string, *print_args, **print_kwargs) 101 | 102 | @classmethod 103 | def is_debug(cls) -> bool: 104 | return _logger_debug 105 | -------------------------------------------------------------------------------- /src/bilili/utils/console/ui.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import platform 4 | import shutil 5 | import sys 6 | from typing import Any, Optional, Union 7 | 8 | from ..base import get_string_width 9 | from .colorful import Back, Fore, Style, colored_string 10 | from .logger import Logger 11 | 12 | IS_WINDOWS = platform.system() == "Windows" 13 | 14 | 15 | def get_terminal_size() -> tuple[int, int]: 16 | """Get the size of the console. 17 | @refs: https://github.com/willmcgugan/rich/blob/e5246436cd75de32f3436cc88d6e4fdebe13bd8d/rich/console.py#L918-L951 18 | Returns: 19 | tuple[int, int]: A named tuple containing the dimensions. 20 | """ 21 | 22 | width: Optional[int] = None 23 | height: Optional[int] = None 24 | if IS_WINDOWS: # pragma: no cover 25 | width, height = shutil.get_terminal_size() 26 | else: 27 | try: 28 | width, height = os.get_terminal_size(sys.stdin.fileno()) 29 | except (AttributeError, ValueError, OSError): 30 | try: 31 | width, height = os.get_terminal_size(sys.stdout.fileno()) 32 | except (AttributeError, ValueError, OSError): 33 | pass 34 | 35 | width = width or 80 36 | height = height or 25 37 | return (width, height) 38 | 39 | 40 | class View: 41 | max_width = 100 42 | min_width = 50 43 | 44 | def __init__(self, debug: bool = False): 45 | self.debug = debug 46 | self.components = [] 47 | self.rendered_area = (0, 0) 48 | 49 | def add_component(self, component: "Component"): 50 | self.components.append(component) 51 | 52 | def render(self, data: Any) -> str: 53 | if data is None: 54 | return "" 55 | assert len(self.components) == len(data), "数据个数与组件个数不匹配" 56 | result = "" 57 | for component, component_data in zip(self.components, data): 58 | result += component.render(component_data) 59 | self.rendered_area = View.calc_area_size(result) 60 | return result 61 | 62 | def refresh(self, data: Any): 63 | if not self.debug: 64 | self.clear() 65 | Logger.print(self.render(data)) 66 | 67 | def clear(self): 68 | os.system("cls" if os.name == "nt" else "clear") 69 | # 暂时无法判断什么终端支持什么终端不支持(如 cmd),暂时不使用 70 | # if self.rendered_area == (0, 0): 71 | # return 72 | # clear_str = "" 73 | # clear_str += "\x1b[1A" * self.rendered_area[0] 74 | # clear_str += "\r" 75 | # # 这里不应该使用 Logger 打印,因为可能被去除颜色控制符(虽然现在不会) 76 | # print(clear_str, end="") 77 | 78 | @classmethod 79 | def get_width(cls) -> int: 80 | width = get_terminal_size()[0] 81 | width = min(width, View.max_width) 82 | width = max(width, View.min_width) 83 | return width 84 | 85 | @classmethod 86 | def get_height(cls) -> int: 87 | return get_terminal_size()[1] 88 | 89 | @classmethod 90 | def calc_area_size(cls, string: str) -> tuple[int, int]: 91 | lines = string.split("\n") 92 | return (len(lines), get_string_width(lines[0])) 93 | 94 | 95 | class Component: 96 | def __init__(self): 97 | pass 98 | 99 | def render(self, data: Any) -> str: 100 | raise NotImplementedError 101 | 102 | 103 | class String(Component): 104 | def __init__(self): 105 | super().__init__() 106 | 107 | def render(self, data: Any) -> str: 108 | if data is None: 109 | return "" 110 | return data 111 | 112 | 113 | class EndLine(Component): 114 | def __init__(self): 115 | super().__init__() 116 | 117 | def render(self, data: Any) -> str: 118 | if data is None: 119 | return "" 120 | return "\n" 121 | 122 | 123 | class Font(Component): 124 | def __init__(self, char_a: str = "a", char_A: Optional[str] = None): 125 | super().__init__() 126 | self.char_a = char_a 127 | self.char_A = char_A 128 | 129 | def render(self, data: Any) -> str: 130 | if data is None: 131 | return "" 132 | result = "" 133 | for char in data: 134 | if ord(char) >= ord("a") and ord(char) <= ord("z"): 135 | result += chr(ord(char) + ord(self.char_a) - ord("a")) 136 | elif ord(char) >= ord("A") and ord(char) <= ord("Z"): 137 | if self.char_A is None: 138 | result += chr(ord(char) + ord(self.char_a) - ord("a")) 139 | else: 140 | result += chr(ord(char) + ord(self.char_A) - ord("A")) 141 | else: 142 | result += char 143 | return result 144 | 145 | 146 | class ColorString(Component): 147 | def __init__( 148 | self, 149 | fore: Optional[Fore] = None, 150 | back: Optional[Back] = None, 151 | style: Optional[Style] = None, 152 | subcomponent: Optional[Component] = None, 153 | ): 154 | super().__init__() 155 | self.fore: Optional[Fore] = fore 156 | self.back: Optional[Back] = back 157 | self.style: Optional[Style] = style 158 | self.subcomponent: Optional[Component] = subcomponent 159 | 160 | def render(self, data: Any) -> str: 161 | if data is None: 162 | return "" 163 | subcomponet_string = self.subcomponent.render(data) if self.subcomponent is not None else data 164 | return colored_string(subcomponet_string, self.fore, self.back, self.style) 165 | 166 | 167 | class Line(Component): 168 | def __init__( 169 | self, 170 | left: Optional[Component] = None, 171 | center: Optional[Component] = None, 172 | right: Optional[Component] = None, 173 | fillchar: str = " ", 174 | ): 175 | super().__init__() 176 | self.left = left 177 | self.center = center 178 | self.right = right 179 | self.fillchar = fillchar 180 | 181 | def render(self, data: Any) -> str: 182 | if data is None: 183 | return "" 184 | left_data = data.get("left", None) 185 | center_data = data.get("center", None) 186 | right_data = data.get("right", None) 187 | 188 | left_result, center_result, right_result = "", "", "" 189 | left_width, center_width, right_width = 0, 0, 0 190 | left_placeholder_width, right_placeholder_width = 0, 0 191 | if self.left is not None: 192 | assert left_data is not None 193 | left_result = self.left.render(left_data) 194 | left_width: int = get_string_width(left_result) 195 | if self.right is not None: 196 | assert right_data is not None 197 | right_result = self.right.render(right_data) 198 | right_width: int = get_string_width(right_result) 199 | if self.center is not None: 200 | assert center_data is not None 201 | center_result = self.center.render(center_data) 202 | center_width: int = get_string_width(center_result) 203 | 204 | if self.center is not None: 205 | left_placeholder_width = (View.get_width() - center_width) // 2 - left_width 206 | right_placeholder_width = ( 207 | View.get_width() - left_width - left_placeholder_width - center_width - right_width 208 | ) 209 | 210 | return ( 211 | left_result 212 | + left_placeholder_width * self.fillchar 213 | + center_result 214 | + right_placeholder_width * self.fillchar 215 | + right_result 216 | + "\n" 217 | ) 218 | else: 219 | left_placeholder_width = View.get_width() - left_width - right_width 220 | return left_result + left_placeholder_width * self.fillchar + right_result + "\n" 221 | 222 | 223 | class Center(Component): 224 | def __init__(self, fillchar: str = " "): 225 | super().__init__() 226 | self.fillchar = fillchar 227 | 228 | def render(self, data: Any) -> str: 229 | if data is None: 230 | return "" 231 | return data.center(View.get_width(), self.fillchar) + "\n" 232 | 233 | 234 | class ProgressBar(Component): 235 | def __init__(self, symbols: Union[str, list[str]] = "░▏▎▍▌▋▊▉█", width: int = View.get_width()): 236 | super().__init__() 237 | self.width = width 238 | self.symbols = symbols 239 | assert len(symbols) >= 2, "symbols 至少为 2 个" 240 | self.num_symbol = len(symbols) 241 | 242 | def render(self, data: Any) -> str: 243 | if data is None: 244 | return "" 245 | if data == 1: 246 | return self.symbols[-1] * self.width 247 | length = self.width * data 248 | length_int = int(length) 249 | length_float = length - length_int 250 | 251 | return ( 252 | length_int * self.symbols[-1] 253 | + self.symbols[math.floor(length_float * (self.num_symbol - 1))] 254 | + (self.width - length_int - 1) * self.symbols[0] 255 | ) 256 | 257 | 258 | class DynamicSymbol(Component): 259 | def __init__(self, symbols: Union[str, list[str]] = "⠁⠂⠄⡀⢀⠠⠐⠈"): 260 | super().__init__() 261 | self.symbols = symbols 262 | self.index = 0 263 | 264 | def render(self, data: Any) -> str: 265 | if data is None: 266 | return "" 267 | self.index += 1 268 | self.index %= len(self.symbols) 269 | return self.symbols[self.index] 270 | 271 | 272 | class LineList(Component): 273 | def __init__(self, subcomponent: Component): 274 | super().__init__() 275 | self.subcomponent = subcomponent 276 | 277 | def render(self, data: Any) -> str: 278 | if data is None: 279 | return "" 280 | result = "" 281 | for item in data: 282 | result += self.subcomponent.render(item) 283 | return result 284 | 285 | 286 | if __name__ == "__main__": 287 | import time 288 | 289 | console = View() 290 | console.add_component(Line(center=Font(char_a="𝓪", char_A="𝓐"), fillchar="=")) 291 | console.add_component(Line(left=ColorString(fore="cyan", style="italic"), fillchar=" ")) 292 | console.add_component(LineList(Line(left=String(), right=String(), fillchar="-"))) 293 | console.add_component(Line(left=ColorString(fore="blue", style="italic"), fillchar=" ")) 294 | console.add_component(LineList(Line(left=String(), right=String(), fillchar="-"))) 295 | console.add_component( 296 | Line( 297 | left=ColorString( 298 | fore="green", 299 | back="white", 300 | subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=70), 301 | ), 302 | right=String(), 303 | fillchar=" ", 304 | ) 305 | ) 306 | for i in range(100): 307 | console.refresh([ 308 | { 309 | 'center': ' 🍻 bilili ', 310 | }, 311 | { 312 | 'left': '🌠 Downloading videos:' 313 | }, 314 | [ 315 | {'left': '视频 1 ', 'right': ' 50%'}, 316 | {'left': '视频 2 ', 'right': ' 40%'} 317 | ], 318 | { 319 | 'left': '🍰 Merging videos:' 320 | }, 321 | [ 322 | {'left': '视频 3 ', 'right': ' 50%'}, 323 | {'left': '视频 4 ', 'right': ' 40%'} 324 | ], 325 | { 326 | 'left': (i+1) / 100, 327 | 'right': "100MB/123MB 11.2 MB/s ⚡" 328 | } 329 | ]) # fmt: skip 330 | time.sleep(0.01) 331 | -------------------------------------------------------------------------------- /src/bilili/utils/crawler.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any 3 | 4 | import requests 5 | 6 | 7 | class DownloadFailureError(Exception): 8 | pass 9 | 10 | 11 | class Crawler(requests.Session): 12 | header = { 13 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_3_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36", 14 | } 15 | 16 | def __init__(self): 17 | super().__init__() 18 | requests.packages.urllib3.disable_warnings() 19 | self.headers.update(Crawler.header) 20 | self.verify = False 21 | 22 | def set_cookies(self, cookies: dict[str, str]): 23 | """传入一个字典,用于设置 cookies""" 24 | 25 | self.cookies_dict = cookies 26 | requests.utils.add_dict_to_cookiejar(self.cookies, cookies) 27 | 28 | def download_bin(self, url: str, file_path: str, stream: bool = True, chunk_size: int = 1024, **kw: Any) -> None: 29 | """下载二进制文件""" 30 | 31 | res = self.get(url, stream=stream, **kw) 32 | tmp_path = file_path + ".t" 33 | try: 34 | with open(tmp_path, "wb") as f: 35 | if stream: 36 | for chunk in res.iter_content(chunk_size=chunk_size): 37 | if not chunk: 38 | break 39 | f.write(chunk) 40 | else: 41 | f.write(res.content) 42 | except Exception: 43 | os.remove(tmp_path) 44 | raise DownloadFailureError() 45 | if os.path.exists(file_path): 46 | with open(tmp_path, "rb") as fr: 47 | with open(file_path, "wb") as fw: 48 | fw.write(fr.read()) 49 | os.remove(tmp_path) 50 | else: 51 | os.rename(tmp_path, file_path) 52 | 53 | def download_text(self, url: str, file_path: str, **kw: Any) -> None: 54 | """下载文本,以 UTF-8 编码保存文件""" 55 | 56 | res = self.get(url, **kw) 57 | res.encoding = res.apparent_encoding 58 | with open(file_path, "w", encoding="utf_8") as f: 59 | f.write(res.text) 60 | 61 | def clone(self): 62 | new_one = self.__class__() 63 | new_one.set_cookies(self.cookies_dict) 64 | new_one.trust_env = self.trust_env 65 | return new_one 66 | 67 | 68 | class BililiCrawler(Crawler): 69 | header = { 70 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36", 71 | "Referer": "https://www.bilibili.com", 72 | } 73 | 74 | def __init__(self): 75 | super().__init__() 76 | self.headers.update(BililiCrawler.header) 77 | -------------------------------------------------------------------------------- /src/bilili/utils/danmaku.py: -------------------------------------------------------------------------------- 1 | from biliass import Danmaku2ASS 2 | 3 | 4 | def convert_xml_danmaku_to_ass(xml_text: str, height: int, width: int) -> str: 5 | return Danmaku2ASS( 6 | xml_text, 7 | width, 8 | height, 9 | input_format="xml", 10 | reserve_blank=0, 11 | font_face="sans-serif", 12 | font_size=width / 40, 13 | text_opacity=0.8, 14 | duration_marquee=15.0, 15 | duration_still=10.0, 16 | comment_filter=None, 17 | is_reduce_comments=False, 18 | progress_callback=None, 19 | ) 20 | -------------------------------------------------------------------------------- /src/bilili/utils/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import shutil 4 | import subprocess 5 | 6 | 7 | class FFmpegNotFoundError(Exception): 8 | def __init__(self): 9 | super().__init__("请配置正确的 FFmpeg 路径") 10 | 11 | 12 | class FFmpeg: 13 | """ 14 | @refs : https://github.com/soimort/you-get 15 | """ 16 | 17 | def __init__(self, ffmpeg_path: str = "ffmpeg", tmp_dir: str = ".ffmpeg_tmp"): 18 | try: 19 | if subprocess.run([ffmpeg_path], capture_output=True).returncode != 1: 20 | raise FFmpegNotFoundError() 21 | except FileNotFoundError: 22 | raise FFmpegNotFoundError() 23 | self.path = os.path.normpath(ffmpeg_path) 24 | tmp_dir = os.path.join(os.path.dirname(ffmpeg_path), tmp_dir) 25 | if not os.path.exists(tmp_dir): 26 | os.makedirs(tmp_dir) 27 | self.tmp_dir = os.path.normpath(tmp_dir) 28 | 29 | def __del__(self): 30 | if hasattr(self, "tmp_dir") and os.path.exists(self.tmp_dir): 31 | shutil.rmtree(self.tmp_dir) 32 | 33 | def exec(self, params: list[str]): 34 | """调用 ffmpeg""" 35 | cmd = [self.path] 36 | cmd.extend(params) 37 | return subprocess.run(cmd, capture_output=True) 38 | 39 | def convert(self, input_path: str, output_path: str) -> None: 40 | """视频格式转换""" 41 | params = [ 42 | "-i", input_path, 43 | "-c", "copy", 44 | "-map", "0", 45 | "-y", 46 | output_path 47 | ] # fmt: skip 48 | self.exec(params) 49 | 50 | def join_videos(self, video_path_list: list[str], output_path: str) -> None: 51 | """将视频拼接起来""" 52 | 53 | concat_list_path = os.path.join(self.tmp_dir, f"concat_list_{random.randint(0, 9999):04}.tmp").replace( 54 | "\\", "/" 55 | ) 56 | with open(concat_list_path, "w", encoding="utf-8") as f: 57 | for video_path in video_path_list: 58 | if os.path.isfile(video_path): 59 | video_relpath = os.path.relpath(video_path, start=self.tmp_dir) 60 | f.write(f"file '{video_relpath}'\n") 61 | params = [ 62 | "-f", "concat", 63 | "-safe", "-1", 64 | "-i", concat_list_path, 65 | "-c", "copy", 66 | "-y", 67 | output_path 68 | ] # fmt: skip 69 | self.exec(params) 70 | os.remove(concat_list_path) 71 | 72 | def join_video_audio(self, video_path: str, audio_path: str, output_path: str) -> None: 73 | """将视频和音频合并""" 74 | params = [ 75 | "-i", video_path, 76 | "-i", audio_path, 77 | "-codec", "copy", 78 | "-y", 79 | output_path 80 | ] # fmt: skip 81 | 82 | self.exec(params) 83 | -------------------------------------------------------------------------------- /src/bilili/utils/functiontools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/src/bilili/utils/functiontools/__init__.py -------------------------------------------------------------------------------- /src/bilili/utils/functiontools/attrdict.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | 4 | class AttrDict(dict): 5 | """AttrDict 类 6 | 像 JS 的 Object 一样方便地读写键值对 7 | 继承于 dict,也可直接使用属性进行读写,避免大量使用字符串的繁琐行为 8 | 9 | 将 dict 转化为 AttrDict 的方法: 10 | ``` 11 | >>> ad = AttrDict({'key': 'value'}) 12 | >>> ad = {'key': 'value'} >> AttrDict() 13 | ``` 14 | """ 15 | 16 | def __init__(self, iterable=None, **kwargs): 17 | if iterable is not None: 18 | self.__init(iterable, **kwargs) 19 | 20 | def __init(self, iterable, **kwargs): 21 | """通过 dict 初始化""" 22 | super().__init__(iterable, **kwargs) 23 | for key, value in chain(self.items(), kwargs.items()): 24 | if isinstance(value, dict): 25 | self[key] = AttrDict(value) 26 | 27 | def __getattr__(self, key): 28 | """将属性的 get 重定向到 dict 的 get""" 29 | try: 30 | return self[key] 31 | except KeyError: 32 | raise AttributeError(f"'AttrDict' object has no attribute '{key}'") 33 | 34 | def __setattr__(self, key, value): 35 | """将属性的 set 重定向到 dict 的 set""" 36 | self[key] = value 37 | 38 | def __delattr__(self, key): 39 | """将属性的 del 重定向到 dict 的 del""" 40 | del self[key] 41 | 42 | def __setitem__(self, key, value): 43 | """确保当新的 value 为 dict 时需要将其转为 AttrDict""" 44 | if isinstance(value, dict): 45 | super().__setitem__(key, AttrDict(value)) 46 | else: 47 | super().__setitem__(key, value) 48 | 49 | def __rrshift__(self, d): 50 | """添加 >> 快速转换方法""" 51 | self.__init(d) 52 | return self 53 | -------------------------------------------------------------------------------- /src/bilili/utils/functiontools/singleton.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | class Singleton(type): 3 | """单例模式元类 4 | 5 | @refs: https://stackoverflow.com/questions/6760685/creating-a-singleton-in-python 6 | 7 | # Usage 8 | ``` 9 | class MyClass(BaseClass, metaclass=Singleton): 10 | pass 11 | ``` 12 | """ 13 | 14 | _instances = {} 15 | 16 | def __call__(cls, *args, **kwargs): 17 | if cls not in cls._instances: 18 | cls._instances[cls] = super().__call__(*args, **kwargs) 19 | return cls._instances[cls] 20 | -------------------------------------------------------------------------------- /src/bilili/utils/playlist.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from ..utils.base import Text 5 | 6 | 7 | class Playlist(Text): 8 | """播放列表类""" 9 | 10 | def __init__(self, path: str, path_type: str): 11 | super().__init__(path) 12 | self.path_type = path_type 13 | 14 | def switch_path(self, path: str) -> str: 15 | path = os.path.normpath(path) 16 | if self.path_type == "AP": 17 | path = os.path.abspath(path) 18 | elif self.path_type == "RP": 19 | path = os.path.relpath(path, start=os.path.dirname(self.path)) 20 | return path 21 | 22 | def write_path(self, path: str) -> None: 23 | path = self.switch_path(path) 24 | self.write_string(path) 25 | 26 | 27 | class M3u(Playlist): 28 | """m3u 播放列表""" 29 | 30 | def __init__(self, path: str, path_type: str = "RP"): 31 | super().__init__(path, path_type) 32 | 33 | 34 | class Dpl(Playlist): 35 | """potplayer 播放列表""" 36 | 37 | def __init__(self, path: str, path_type: str = "RP"): 38 | super().__init__(path, path_type) 39 | self.write_string("DAUMPLAYLIST\n") 40 | self._count = 0 41 | 42 | def write_path(self, path: str, name: Optional[str] = None): 43 | self._count += 1 44 | path = self.switch_path(path) 45 | self.write_string(f"{self._count}*file*{path}") 46 | if name is not None: 47 | self.write_string(f"{self._count}*title*{name}\n") 48 | -------------------------------------------------------------------------------- /src/bilili/utils/subtitle.py: -------------------------------------------------------------------------------- 1 | from ..utils.base import Text 2 | 3 | 4 | class Subtitle(Text): 5 | """播放列表类""" 6 | 7 | def __init__(self, path: str): 8 | super().__init__(path) 9 | self._count = 0 10 | 11 | @staticmethod 12 | def time_format(seconds: int): 13 | ms = int(1000 * (seconds - int(seconds))) 14 | seconds = int(seconds) 15 | minutes, sec = seconds // 60, seconds % 60 16 | hour, min = minutes // 60, minutes % 60 17 | return f"{hour:02}:{min:02}:{sec:02},{ms}" 18 | 19 | def write_line(self, content: str, from_time: int, to_time: int) -> None: 20 | self._count += 1 21 | self.write_string(str(self._count)) 22 | self.write_string(f"{self.time_format(from_time)} --> {self.time_format(to_time)}") 23 | self.write_string(content + "\n") 24 | -------------------------------------------------------------------------------- /src/bilili/utils/thread.py: -------------------------------------------------------------------------------- 1 | import queue 2 | import threading 3 | import time 4 | 5 | from ..utils.base import Ref 6 | 7 | 8 | class Flag(Ref): 9 | def __init__(self, value=False): 10 | super().__init__(value) 11 | 12 | 13 | class Task: 14 | """任务对象""" 15 | 16 | def __init__(self, func, args=(), kwargs={}): 17 | """接受函数与参数以初始化对象""" 18 | 19 | self.func = func 20 | self.args = args 21 | self.kwargs = kwargs 22 | 23 | def __call__(self, **extra_params): 24 | """执行函数 25 | 26 | 同步函数直接执行并返回结果 27 | """ 28 | 29 | result = self.func(*self.args, **self.kwargs, **extra_params) 30 | return result 31 | 32 | 33 | class ThreadPool: 34 | """线程池类 35 | 快速创建多个相同任务的线程池 36 | """ 37 | 38 | def __init__(self, num, wait=Flag(True), daemon=False, thread_globals_creator={}): 39 | self.num = num 40 | self.daemon = daemon 41 | self._taskQ = queue.Queue() 42 | self.threads = [] 43 | self.__wait_flag = wait 44 | self.thread_globals_creator = thread_globals_creator 45 | 46 | def add_task(self, func, args=(), kwargs={}): 47 | """添加任务""" 48 | self._taskQ.put(Task(func, args, kwargs)) 49 | 50 | def _run_task(self, **thread_globals): 51 | """启动任务线程""" 52 | while True: 53 | if not self._taskQ.empty(): 54 | task = self._taskQ.get(block=True, timeout=1) 55 | task(**thread_globals) 56 | self._taskQ.task_done() 57 | elif not self.__wait_flag.value: 58 | time.sleep(1) 59 | else: 60 | break 61 | 62 | def run(self): 63 | """启动线程池""" 64 | for _ in range(self.num): 65 | thread_globals = {} 66 | for key, creator in self.thread_globals_creator.items(): 67 | thread_globals[key] = creator() 68 | th = threading.Thread(target=self._run_task, kwargs=thread_globals) 69 | th.setDaemon(self.daemon) 70 | self.threads.append(th) 71 | th.start() 72 | 73 | def join(self): 74 | """等待所有任务结束""" 75 | for th in self.threads: 76 | th.join() 77 | -------------------------------------------------------------------------------- /src/bilili/video.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .handlers.status import DownloaderStatus 4 | from .quality import audio_quality_map, video_quality_map 5 | from .tools import global_status 6 | from .utils.base import repair_filename 7 | from .utils.console.colorful import colored_string 8 | from .utils.console.logger import Logger 9 | 10 | 11 | class BililiContainer: 12 | """bilibili 媒体容器类 13 | 即 B 站上的单个视频,其中可能包含多个媒体单元 14 | * 包含多个 flv 片段 15 | * 包含 m4s 的视频与音频流 16 | * 包含完整的一个 mp4 17 | """ 18 | 19 | def __init__(self, id, name, meta, type="dash", video_dir=""): 20 | self.id = id 21 | self.name = name 22 | self.meta = meta 23 | self.type = type 24 | self.path = os.path.join(video_dir, f"{repair_filename(self.name)}.mp4") 25 | 26 | self.medias = [] 27 | self.quality = None 28 | self.height = None 29 | self.width = None 30 | self._ = DownloaderStatus(parent=global_status) 31 | 32 | def append_media(self, *args, **kwargs): 33 | self.medias.append(BililiMedia(*args, **kwargs, container=self)) 34 | 35 | def __str__(self): 36 | quality_description: str = "" 37 | if self.type == "dash": 38 | quality_description = " & ".join( 39 | [ 40 | { 41 | "dash_video": video_quality_map, 42 | "dash_audio": audio_quality_map 43 | }[media.type][media.quality]["description"] 44 | for media in self.medias 45 | ] 46 | ) # fmt: skip 47 | else: 48 | assert self.quality is not None, "quality 仍然为 None" 49 | quality_description = video_quality_map[self.quality]["description"] 50 | return "{} 「{}」".format( 51 | colored_string(self.name, fore="magenta"), colored_string(quality_description, fore="cyan") 52 | ) 53 | 54 | def check_needs_download(self, overwrite: bool = False): 55 | """检查是否需要下载""" 56 | if overwrite: 57 | if os.path.exists(self.path): 58 | os.remove(self.path) 59 | return True 60 | if os.path.exists(self.path): 61 | return False 62 | return True 63 | 64 | 65 | class BililiMedia: 66 | """bilibili 媒体单元类 67 | 从 B 站直接获取的可下载的媒体单元,可能是 flv、mp4、m4s 68 | """ 69 | 70 | def __init__( 71 | self, 72 | id, 73 | url, 74 | quality, 75 | size, 76 | height, 77 | width, 78 | container, 79 | mirrors=[], 80 | type="dash_video", 81 | block_size=0, 82 | ): 83 | self.id = id 84 | self.quality = quality 85 | self.height = height 86 | self.width = width 87 | self.url = url 88 | self.mirrors = mirrors 89 | self.container = container 90 | self.block_size = block_size 91 | self.path = os.path.splitext(self.container.path)[0] 92 | self.type = type 93 | if self.container.type == "flv": 94 | self.path += f"_{id:02d}.flv" 95 | elif self.container.type == "dash": 96 | self.path += f"_{type}.m4s" 97 | elif self.container.type == "mp4": 98 | self.path += "_dl.mp4" 99 | else: 100 | Logger.warning(f"未知的容器类型:{self.container.type}") 101 | self.name = os.path.split(self.path)[-1] 102 | self._ = DownloaderStatus(parent=self.container._) 103 | self._.total_size = size 104 | 105 | if self.container.quality is None: 106 | self.container.quality = quality 107 | if self.container.width is None: 108 | self.container.width = width 109 | if self.container.height is None: 110 | self.container.height = height 111 | if self._.total_size == 0: 112 | Logger.warning(f"{self.name} 获取 size 为 0") 113 | self._.total_size = 0 114 | if self._.total_size is None: 115 | Logger.warning(f"{self.name} 无法获取 size") 116 | self._.total_size = 0 117 | 118 | self.blocks = self.chunking() 119 | 120 | def chunking(self): 121 | block_size = self.block_size 122 | blocks = [] 123 | total_size = self._.total_size 124 | if block_size: 125 | block_range_list = [(i, i + block_size - 1) for i in range(0, total_size, block_size)] 126 | if total_size % block_size != 0: 127 | block_range_list[-1] = ( 128 | total_size // block_size * block_size, 129 | total_size - 1, 130 | ) 131 | else: 132 | block_range_list = [(0, total_size - 1)] 133 | for i, block_range in enumerate(block_range_list): 134 | blocks.append( 135 | BililiBlock( 136 | id=i, 137 | url=self.url, 138 | mirrors=self.mirrors, 139 | media=self, 140 | block_size=block_size, 141 | range=block_range, 142 | ) 143 | ) 144 | assert self._.total_size == total_size, "重新设置的 total size 与原来值不匹配" 145 | return blocks 146 | 147 | def check_needs_download(self, overwrite=False): 148 | """检查是否需要下载""" 149 | if overwrite: 150 | if os.path.exists(self.path): 151 | os.remove(self.path) 152 | return True 153 | if os.path.exists(self.path): 154 | return False 155 | return True 156 | 157 | 158 | class BililiBlock: 159 | """bilibili 媒体块类""" 160 | 161 | def __init__(self, id, url, mirrors, media, block_size, range): 162 | self.id = id 163 | self.url = url 164 | self.mirrors = mirrors 165 | self.block_size = block_size 166 | self.media = media 167 | self.range = range 168 | # 假设最大 10 GB 时所需的位数 169 | ndigits = 1 if block_size == 0 else len(str(10 * 1024 * 1024 * 1024 // self.block_size)) 170 | self.path = "_{:0{}}".format(self.id, ndigits).join(os.path.splitext(self.media.path)) 171 | self.name = os.path.split(self.path)[-1] 172 | self._ = DownloaderStatus(parent=self.media._) 173 | self._.total_size = self.range[1] - self.range[0] + 1 174 | 175 | def check_needs_download(self, overwrite=False): 176 | """检查是否需要下载""" 177 | if overwrite: 178 | if os.path.exists(self.path): 179 | os.remove(self.path) 180 | if os.path.exists(self.path + ".dl"): 181 | os.remove(self.path + ".dl") 182 | return True 183 | if os.path.exists(self.path): 184 | return False 185 | return True 186 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yutto-dev/bilili/5520f7a3bc4ae38746bec3004b7443f1816f468d/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_acg_video.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bilili.api.acg_video import ( 4 | get_acg_video_list, 5 | get_acg_video_playurl, 6 | get_acg_video_subtitle, 7 | get_acg_video_title, 8 | get_video_info, 9 | ) 10 | 11 | 12 | @pytest.mark.api 13 | def test_get_video_info(): 14 | bvid = "BV1vZ4y1M7mQ" 15 | assert get_video_info(bvid=bvid)["bvid"] == bvid 16 | 17 | 18 | @pytest.mark.api 19 | def test_get_title(): 20 | bvid = "BV1vZ4y1M7mQ" 21 | assert get_acg_video_title(bvid=bvid) == "用 bilili 下载 B 站视频" 22 | 23 | 24 | @pytest.mark.api 25 | def test_get_list(): 26 | bvid = "BV1vZ4y1M7mQ" 27 | video_list = get_acg_video_list(bvid=bvid) 28 | assert video_list[0]["cid"] == "222190584" 29 | assert video_list[1]["cid"] == "222200470" 30 | 31 | 32 | @pytest.mark.api 33 | @pytest.mark.ci_skip 34 | @pytest.mark.parametrize("type", ["flv", "mp4", "dash"]) 35 | def test_get_playurl(type: str): 36 | bvid = "BV1vZ4y1M7mQ" 37 | cid = "222190584" 38 | play_list = get_acg_video_playurl(bvid=bvid, cid=cid, quality=120, audio_quality=30280, type=type) 39 | 40 | 41 | @pytest.mark.api 42 | def test_get_subtitle(): 43 | bvid = "BV1i741187Dp" 44 | cid = "149439373" 45 | get_acg_video_subtitle(bvid=bvid, cid=cid) 46 | -------------------------------------------------------------------------------- /tests/test_bangumi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bilili.api.bangumi import ( 4 | get_bangumi_list, 5 | get_bangumi_playurl, 6 | get_bangumi_subtitle, 7 | get_bangumi_title, 8 | get_season_id, 9 | ) 10 | 11 | 12 | @pytest.mark.api 13 | def test_get_season_id(): 14 | media_id = "28223066" 15 | assert get_season_id(media_id=media_id) == "28770" 16 | 17 | 18 | @pytest.mark.api 19 | def test_get_title_by_media_id(): 20 | media_id = "28223066" 21 | assert get_bangumi_title(media_id=media_id) == "我的三体之章北海传" 22 | 23 | 24 | @pytest.mark.api 25 | def test_get_title_by_episode_id(): 26 | episode_id = "300998" 27 | assert get_bangumi_title(episode_id=episode_id) == "我的三体之章北海传" 28 | 29 | 30 | @pytest.mark.api 31 | def test_get_title_by_season_id(): 32 | season_id = "28770" 33 | assert get_bangumi_title(season_id=season_id) == "我的三体之章北海传" 34 | 35 | 36 | @pytest.mark.api 37 | def test_get_list(): 38 | season_id = "28770" 39 | video_list = get_bangumi_list(season_id=season_id, with_section=False) 40 | assert video_list[0]["cid"] == "144541892" 41 | assert video_list[0]["avid"] == "84271171" 42 | assert video_list[0]["bvid"] == "BV1q7411v7Vd" 43 | assert video_list[0]["episode_id"] == "300998" 44 | 45 | 46 | @pytest.mark.api 47 | @pytest.mark.ci_skip 48 | @pytest.mark.parametrize("type", ["flv", "dash"]) 49 | def test_get_playurl(type: str): 50 | avid = "84271171" 51 | bvid = "BV1q7411v7Vd" 52 | cid = "144541892" 53 | episode_id = "300998" 54 | play_list = get_bangumi_playurl( 55 | avid=avid, 56 | bvid=bvid, 57 | cid=cid, 58 | episode_id=episode_id, 59 | quality=120, 60 | audio_quality=30280, 61 | type=type, 62 | ) 63 | 64 | 65 | @pytest.mark.api 66 | def test_get_subtitle(): 67 | # TODO: 暂未找到需要字幕的番剧(非港澳台) 68 | pass 69 | -------------------------------------------------------------------------------- /tests/test_danmaku.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bilili.api.danmaku import get_danmaku 4 | 5 | 6 | @pytest.mark.api 7 | def test_danmaku(): 8 | cid = "144541892" 9 | get_danmaku(cid=cid) 10 | -------------------------------------------------------------------------------- /tests/test_e2e.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import pytest 5 | 6 | from bilili.__version__ import VERSION as bilili_version 7 | 8 | PYTHON = sys.executable 9 | 10 | 11 | @pytest.mark.e2e 12 | def test_version_e2e(): 13 | p = subprocess.run([PYTHON, "-m", "bilili", "-v"], capture_output=True, check=True) 14 | res = p.stdout.decode() 15 | assert res.strip().endswith(bilili_version) 16 | 17 | 18 | @pytest.mark.e2e 19 | def test_ui_e2e(): 20 | p = subprocess.run([PYTHON, "-m", "bilili.utils.console.ui"], capture_output=True, check=True) 21 | 22 | 23 | @pytest.mark.e2e 24 | @pytest.mark.ci_skip 25 | def test_bangumi_e2e(): 26 | short_bangumi = "https://www.bilibili.com/bangumi/play/ep100367" 27 | p = subprocess.run( 28 | [PYTHON, "-m", "bilili", short_bangumi, "-p=^", "-q=16", "-y", "-w"], 29 | capture_output=True, 30 | check=True, 31 | ) 32 | 33 | 34 | @pytest.mark.e2e 35 | def test_acg_video_e2e(): 36 | short_acg_video = "https://www.bilibili.com/video/BV1AZ4y147Yg" 37 | p = subprocess.run( 38 | [PYTHON, "-m", "bilili", short_acg_video, "-q=16", "-y", "-w"], 39 | capture_output=True, 40 | check=True, 41 | ) 42 | 43 | 44 | @pytest.mark.e2e 45 | def test_acg_video_8k_e2e(): 46 | acg_video_8k = "https://www.bilibili.com/video/BV1qM4y1w716" 47 | p = subprocess.run( 48 | [PYTHON, "-m", "bilili", acg_video_8k, "-q=127", "-y", "-c=*****"], 49 | capture_output=True, 50 | check=True, 51 | ) 52 | --------------------------------------------------------------------------------