diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 499b6ebe2..624b2a177 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -24,7 +24,7 @@ body: 请在这里提供你所使用/调用 yutto 的方式。如果与特定 url 有关,请直接在命令中提供该 url。 为了节省彼此交流的时间,麻烦在提交 issue 前多次测试该命令是能够反复复现的(非网络问题), 如果可以,麻烦在提交 issue 前对其他的情况进行测试,并将相关信息详细描述在问题简述中, - 这里仅提供**最小可复现**的命令 + 这里仅提供**最小可复现**的命令(注意,如果使用了自定义的配置文件,请将配置文件内容一并提供)。 placeholder: "注意在粘贴的命令中隐去所有隐私信息哦(*/ω\*)" validations: required: true diff --git a/.github/workflows/biliass-build-and-release.yml b/.github/workflows/biliass-build-and-release.yml index ed2493f99..708de7537 100644 --- a/.github/workflows/biliass-build-and-release.yml +++ b/.github/workflows/biliass-build-and-release.yml @@ -2,12 +2,21 @@ name: Build and Release (biliass) on: push: - branches: - - main tags: - "biliass*" # Push events to matching biliass*, i.e. biliass@1.0.0 + branches: + - main + paths: + - "packages/biliass/**" + - ".github/**" pull_request: + paths: + - "packages/biliass/**" + - ".github/**" merge_group: + paths: + - "packages/biliass/**" + - ".github/**" workflow_dispatch: permissions: @@ -40,7 +49,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: --release --out dist + args: --release --out dist --interpreter '3.13 3.13t' sccache: "true" manylinux: auto working-directory: packages/biliass @@ -72,7 +81,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: --release --out dist + args: --release --out dist --interpreter '3.13 3.13t' sccache: "true" manylinux: musllinux_1_2 working-directory: packages/biliass @@ -95,7 +104,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: 3.x + python-version: "3.13" architecture: ${{ matrix.platform.target }} - name: Build wheels uses: PyO3/maturin-action@v1 @@ -110,12 +119,44 @@ jobs: name: wheels-windows-${{ matrix.platform.target }} path: packages/biliass/dist + # Python 3.13 standard and free-threaded versions cannot be + # available at the same time on Windows machines, so we + # split it into two jobs. + # https://github.com/Quansight-Labs/setup-python/issues/5 + windows-free-threaded: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: Quansight-Labs/setup-python@v5 + with: + python-version: 3.13t + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist + sccache: "true" + working-directory: packages/biliass + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-free-threaded-${{ matrix.platform.target }} + path: packages/biliass/dist + macos: runs-on: ${{ matrix.platform.runner }} strategy: matrix: platform: - - runner: macos-12 + - runner: macos-13 target: x86_64 - runner: macos-14 target: aarch64 @@ -128,7 +169,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: --release --out dist + args: --release --out dist --interpreter '3.13 3.13t' sccache: "true" working-directory: packages/biliass - name: Upload wheels @@ -161,6 +202,7 @@ jobs: - linux - musllinux - windows + - windows-free-threaded - macos - sdist permissions: @@ -174,8 +216,11 @@ jobs: merge-multiple: true path: dist/ + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + run: uv publish -v publish-release: runs-on: ubuntu-latest @@ -185,6 +230,7 @@ jobs: - linux - musllinux - windows + - windows-free-threaded - macos - sdist permissions: diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 43d2ea2f6..23ac6ddfd 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13-dev"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] architecture: ["x64"] name: Python ${{ matrix.python-version }} on ${{ matrix.architecture }} e2e test steps: @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v5 - name: Install python uses: actions/setup-python@v5 diff --git a/.github/workflows/latest-release-test.yml b/.github/workflows/latest-release-test.yml index 5391f1034..82ace2f0c 100644 --- a/.github/workflows/latest-release-test.yml +++ b/.github/workflows/latest-release-test.yml @@ -9,10 +9,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13-dev"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] architecture: ["x64"] name: Python ${{ matrix.python-version }} on ${{ matrix.architecture }} latest release test steps: + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install python uses: actions/setup-python@v5 with: @@ -24,12 +27,36 @@ jobs: sudo apt update sudo apt install ffmpeg - - name: Install yutto latest + - name: Test yutto from source + run: | + uv cache clean + uvx --no-binary yutto@latest -v + uvx --no-binary yutto@latest -h + uvx --no-binary yutto@latest https://www.bilibili.com/video/BV1AZ4y147Yg -w --no-progress + + - name: Test yutto from wheel + run: | + uv cache clean + uvx yutto@latest -v + uvx yutto@latest -h + uvx yutto@latest https://www.bilibili.com/video/BV1AZ4y147Yg -w --no-progress + + - name: Prepare data for biliass + run: | + git clone https://github.com/yutto-dev/biliass-corpus.git --depth 1 + + - name: Test biliass from source run: | - pip install yutto + uv cache clean + uvx --no-binary biliass@latest -v + uvx --no-binary biliass@latest -h + uvx --no-binary biliass@latest biliass-corpus/corpus/xml/18678311.xml -s 1920x1080 -f xml -o xml.ass + uvx --no-binary biliass@latest biliass-corpus/corpus/protobuf/18678311-0.pb biliass-corpus/corpus/protobuf/18678311-1.pb biliass-corpus/corpus/protobuf/18678311-2.pb biliass-corpus/corpus/protobuf/18678311-3.pb -s 1920x1080 -f protobuf -o protobuf.ass - - name: Test yutto + - name: Test biliass from wheel run: | - yutto -v - yutto -h - yutto https://www.bilibili.com/video/BV1AZ4y147Yg -w + uv cache clean + uvx biliass@latest -v + uvx biliass@latest -h + uvx biliass@latest biliass-corpus/corpus/xml/18678311.xml -s 1920x1080 -f xml -o xml.ass + uvx biliass@latest biliass-corpus/corpus/protobuf/18678311-0.pb biliass-corpus/corpus/protobuf/18678311-1.pb biliass-corpus/corpus/protobuf/18678311-2.pb biliass-corpus/corpus/protobuf/18678311-3.pb -s 1920x1080 -f protobuf -o protobuf.ass diff --git a/.github/workflows/lint-and-fmt.yml b/.github/workflows/lint-and-fmt.yml index e7ba95901..421b95359 100644 --- a/.github/workflows/lint-and-fmt.yml +++ b/.github/workflows/lint-and-fmt.yml @@ -20,7 +20,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v5 - name: Install python uses: actions/setup-python@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e459ce577..00422dcb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v5 - name: Install python uses: actions/setup-python@v5 @@ -53,8 +53,11 @@ jobs: name: release-dists path: dist/ + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + run: uv publish -v publish-release: runs-on: ubuntu-latest diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 37853e39e..cdde79c53 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13-dev"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] architecture: ["x64"] name: Python ${{ matrix.python-version }} on ${{ matrix.architecture }} unit test steps: @@ -22,7 +22,7 @@ jobs: submodules: recursive - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v5 - name: Install python uses: actions/setup-python@v5 @@ -39,6 +39,12 @@ jobs: run: | just ci-install - - name: unit test + - name: Run unit tests run: | just ci-test + + - name: Run benchmarks + uses: CodSpeedHQ/action@v3 + if: "matrix.python-version == '3.13' && github.event_name != 'merge_group'" + with: + run: uv run pytest --codspeed diff --git a/.gitignore b/.gitignore index 70737b9c6..b9e1dfbca 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,7 @@ dmypy.json .DS_Store # Media files +*.m4a *.aac *.mp3 *.flac @@ -141,3 +142,6 @@ log # test files __test_files__ + +# config file +yutto.toml diff --git a/.vscode/settings.json b/.vscode/settings.json index 3a6e1aef0..a70e6cc86 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,10 @@ }, "rust-analyzer.linkedProjects": [ "${workspaceFolder}/packages/biliass/rust/Cargo.toml" - ] + ], + "search.exclude": { + "**/*.ambr": true, + "**/*.xml": true, + "**/*.pb": true + } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ab09d308..1825f7b57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,12 @@ 当然,如果你有更熟悉的编辑器或 IDE 的话,也是完全可以的。 +### Rust 开发工具链(可选) + +本 repo 是一个 monorepo,同时包含 yutto 和 biliass 两个包,其中 biliass 采用 Rust 编写,如果你有 biliass 联调的需求,则需要安装 Rust 工具链,安装方法请参考 [Rust 官方文档](https://www.rust-lang.org/tools/install) + +如果你不需要联调 biliass,那么可以通过注释掉 [pyproject.toml](./pyproject.toml) 中的 `tool.uv.sources` 和 `tool.uv.workspace`,避免 uv 将其当作一个子项目来处理。此时 uv 会安装 pypi 上预编译的 biliass wheel 包,而不会编译源码。这在大多数情况下是没有问题的,除非 yutto 使用了 biliass 的最新特性。 + ## 本地调试 如果你想要本地调试,最佳实践是从 GitHub 上下载最新的源码来运行 diff --git a/Dockerfile b/Dockerfile index b224e247d..d573ca1c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ -FROM alpine:3.20 +FROM alpine:3.21 LABEL maintainer="siguremo" \ - version="2.0.0-rc.1" \ + version="2.0.0" \ description="light-weight container based on alpine for yutto" RUN set -x \ && sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \ && apk add -q --progress --update --no-cache ffmpeg python3 tzdata \ && python3 -m venv /opt/venv \ - && /opt/venv/bin/pip install --no-cache-dir --pre --compile yutto \ + && /opt/venv/bin/pip install --no-cache-dir --compile yutto \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime WORKDIR /app diff --git a/README.md b/README.md index eb7574018..40b22dd26 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# yutto2.0.0-beta +# yutto2.0.0 + +

+ +

PyPI - Python Version @@ -12,9 +16,11 @@ discord chat

-yutto,一个可爱且任性的 B 站下载器(CLI) +

🧊 yutto,一个可爱且任性的 B 站下载器(CLI)

-当前 yutto 尚处于 beta 阶段,有任何建议尽管在 [Discussions](https://github.com/yutto-dev/yutto/discussions) 提出~~~ +> [!TIP] +> +> 如果在使用过程中遇到问题,请通过 [Issues](https://github.com/yutto-dev/yutto/issues) 反馈功能正确性问题和功能请求,其他问题请通过 [Discussions](https://github.com/yutto-dev/yutto/discussions) 反馈~ ## 版本号为什么是 2.0 @@ -56,14 +62,14 @@ docker run --rm -it -v /path/to/download:/app siguremo/yutto [options] > 在此之前请确保安装 Python3.9 及以上版本,并配置好 FFmpeg(参照 [bilili 文档](https://bilili.nyakku.moe/guide/getting-started.html)) ```bash -pip install --pre yutto +pip install yutto ``` 当然,你也可以通过 [pipx](https://github.com/pypa/pipx)/[uv](https://github.com/astral-sh/uv) 来安装 yutto(当然,前提是你要自己先安装它) ```bash -pipx install --pre yutto # 使用 pipx -uv tool install --pre yutto # 或者使用 uv +pipx install yutto # 使用 pipx +uv tool install yutto # 或者使用 uv ``` pipx/uv 会类似 Homebrew 无感地为 yutto 创建一个虚拟环境,与其余环境隔离开,避免污染 pip 的环境,因此相对于 pip,pipx/uv 是更推荐的安装方式(uv 会比 pipx 更快些~)。 @@ -99,8 +105,8 @@ uv tool install git+https://github.com/yutto-dev/yutto.git@main # 通过 | 当前用户稍后再看 批量 | :x: | `https://www.bilibili.com/watchlater` | `稍后再看/{title}/{name}` | | 用户全部收藏夹 批量 | :x: | `https://space.bilibili.com/100969474/favlist` | `{username}的收藏夹/{series_title}/{title}/{name}` | | UP 主个人空间 批量 | :x: | `https://space.bilibili.com/100969474/video` | `{username}的全部投稿视频/{title}/{name}` | -| 合集 批量 | :white_check_mark: | `https://space.bilibili.com/361469957/channel/collectiondetail?sid=23195`
`https://www.bilibili.com/medialist/play/361469957?business=space_collection&business_id=23195` | `{series_title}/{title}` | -| 视频列表 批量 | :x: | `https://space.bilibili.com/100969474/channel/seriesdetail?sid=1947439`
`https://www.bilibili.com/medialist/play/100969474?business=space_series&business_id=1947439`
`https://space.bilibili.com/100969474/favlist?fid=270359&ftype=collect` | `{series_title}/{title}/{name}` | +| 合集 批量 | :white_check_mark: | `https://space.bilibili.com/3546619314178489/lists?sid=3221717?type=season`
`https://space.bilibili.com/3546619314178489/channel/collectiondetail?sid=3221717`旧版页面
`https://space.bilibili.com/100969474/favlist?fid=3221717&ftype=collect&ctype=21` | `{series_title}/{title}` | +| 视频列表 批量 | :x: | `https://space.bilibili.com/100969474/lists/1947439?type=series`
`https://space.bilibili.com/100969474/channel/seriesdetail?sid=1947439`旧版页面
`https://www.bilibili.com/list/100969474?sid=1947439` | `{series_title}/{title}/{name}` | > [!NOTE] > @@ -256,13 +262,13 @@ https://github.com/orgs/community/discussions/16925#discussioncomment-7571187 #### 指定在仅包含音频流时的输出格式 - 参数 `--output-format-audio-only` -- 可选值 `"infer" | "aac" | "mp3" | "flac" | "mp4" | "mkv" | "mov"` +- 可选值 `"infer" | "m4a" | "aac" | "mp3" | "flac" | "mp4" | "mkv" | "mov"` - 默认值 `"infer"` 在仅包含音频流时所使用的输出格式,默认选值 `"infer"` 表示自动根据情况进行推导以保证输出的可用,推导规则如下: - 如果音频流编码为 `"fLaC"`,则输出格式为 `"flac"` -- 否则为 `"aac"` +- 否则为 `"m4a"` > **Note** > @@ -356,12 +362,14 @@ yutto -c "d8bc7493%2C2843925707%2C08c3e*81" | - | - | - | | title | 系列视频总标题(番剧名/投稿视频标题) | 全部 | | id | 系列视频单 p 顺序标号 | 全部 | +| aid | 视频 AV 号,早期使用的视频 ID,不建议使用,详见 [AV 号全面升级公告](https://www.bilibili.com/blackboard/activity-BV-PC.html) | 全部 | +| bvid | 视频 BV 号,即视频 ID | 全部 | | name | 系列视频单 p 标题 | 全部 | | username | UP 主用户名 | 个人空间、收藏夹、稍后再看、合集、视频列表下载 | | series_title | 合集标题 | 收藏夹、视频合集、视频列表下载 | | pubdate🕛 | 投稿日期 | 仅投稿视频 | | download_date🕛 | 下载日期 | 全部 | -| owner_uid | UP 主UID | 个人空间、收藏夹、稍后再看、合集、视频列表下载 | +| owner_uid | UP 主 UID | 个人空间、收藏夹、稍后再看、合集、视频列表下载 | > **Note** > @@ -392,6 +400,62 @@ yutto tensura1 --batch --alias-file='~/.yutto_alias' cat ~/.yutto_alias | yutto tensura-nikki --batch --alias-file - ``` +#### 指定媒体元数据值的格式 + +当前仅支持 `premiered` + +- 参数 `--metadata-format-premiered` +- 默认值 `"%Y-%m-%d"` +- 常用值 `"%Y-%m-%d %H:%M:%S"` + +#### 严格校验大会员状态有效 + +- 参数 `--vip-strict` +- 默认值 `False` + +#### 严格校验登录状态有效 + +- 参数 `--login-strict` +- 默认值 `False` + +#### 设置下载间隔 + +- 参数 `--download-interval` +- 默认值 `0` + +设置两话之间的下载间隔(单位为秒),避免短时间內下载大量视频导致账号被封禁 + +#### 禁用下载镜像 + +- 参数 `--banned-mirrors-pattern` +- 默认值 `None` + +使用正则禁用特定镜像,比如 `--banned-mirrors-pattern "mirrorali"` 将禁用 url 中包含 `mirrorali` 的镜像 + +#### 不显示颜色 + +- 参数 `--no-color` +- 默认值 `False` + +#### 不显示进度条 + +- 参数 `--no-progress` +- 默认值 `False` + +#### 启用 Debug 模式 + +- 参数 `--debug` +- 默认值 `False` + + + +### 资源选择参数 + +此外有一些参数专用于资源选择,比如选择是否下载弹幕、音频、视频等等。 + +
+点击展开详细参数 + #### 仅下载视频流 - 参数 `--video-only` @@ -408,20 +472,13 @@ cat ~/.yutto_alias | yutto tensura-nikki --batch --alias-file - - 参数 `--audio-only` - 默认值 `False` -仅下载其中的音频流,保存为 `.aac` 文件。 +仅下载其中的音频流,保存为 `.m4a` 文件。 #### 不生成弹幕文件 - 参数 `--no-danmaku` - 默认值 `False` -#### 不生成章节信息 - -- 参数 `--no-chapter-info` -- 默认值 `False` - -不生成章节信息,包含 MetaData 和嵌入视频流的章节信息。 - #### 仅生成弹幕文件 - 参数 `--danmaku-only` @@ -458,53 +515,99 @@ cat ~/.yutto_alias | yutto tensura-nikki --batch --alias-file - > > 当前仅支持为包含视频流的视频生成封面。 -#### 指定媒体元数据值的格式 +#### 生成视频流封面时单独保存封面 -当前仅支持 `premiered` +- 参数 `--save-cover` +- 默认值 `False` -- 参数 `--metadata-format-premiered` -- 默认值 `"%Y-%m-%d"` -- 常用值 `"%Y-%m-%d %H:%M:%S"` +#### 仅生成视频封面 -#### 严格校验大会员状态有效 +- 参数 `--cover-only` +- 默认值 `False` -- 参数 `--vip-strict` +#### 不生成章节信息 + +- 参数 `--no-chapter-info` - 默认值 `False` -#### 严格校验登录状态有效 +不生成章节信息,包含 MetaData 和嵌入视频流的章节信息。 -- 参数 `--login-strict` +
+ +### 弹幕设置参数Experimental + +yutto 通过与 biliass 的集成,提供了一些 ASS 弹幕选项,包括字号、字体、速度等~ + +
+点击展开详细参数 + +#### 弹幕字体大小 + +- 参数 `--danmaku-font-size` +- 默认值 `video_width / 40` + +#### 弹幕字体 + +- 参数 `--danmaku-font` +- 默认值 `"SimHei"` + +#### 弹幕不透明度 + +- 参数 `--danmaku-opacity` +- 默认值 `0.8` + +#### 弹幕显示区域与视频高度的比例 + +- 参数 `--danmaku-display-region-ratio` +- 默认值 `1.0` + +#### 弹幕速度 + +- 参数 `--danmaku-speed` +- 默认值 `1.0` + +#### 屏蔽顶部弹幕 + +- 参数 `--danmaku-block-top` - 默认值 `False` -#### 设置下载间隔 +#### 屏蔽底部弹幕 -- 参数 `--download-interval` -- 默认值 `0` +- 参数 `--danmaku-block-bottom` +- 默认值 `False` -设置两话之间的下载间隔(单位为秒),避免短时间內下载大量视频导致账号被封禁 +#### 屏蔽滚动弹幕 -#### 禁用下载镜像 +- 参数 `--danmaku-block-scroll` +- 默认值 `False` -- 参数 `--banned-mirrors-pattern` -- 默认值 `None` +#### 屏蔽逆向弹幕 -使用正则禁用特定镜像,比如 `--banned-mirrors-pattern "mirrorali"` 将禁用 url 中包含 `mirrorali` 的镜像 +- 参数 `--danmaku-block-reverse` +- 默认值 `False` -#### 不显示颜色 +#### 屏蔽固定弹幕(顶部、底部) -- 参数 `--no-color` +- 参数 `--danmaku-block-fixed` - 默认值 `False` -#### 不显示进度条 +#### 屏蔽高级弹幕 -- 参数 `--no-progress` +- 参数 `--danmaku-block-special` - 默认值 `False` -#### 启用 Debug 模式 +#### 屏蔽彩色弹幕 -- 参数 `--debug` +- 参数 `--danmaku-block-colorful` - 默认值 `False` +#### 屏蔽关键词 + +- 参数 `--danmaku-block-keyword-patterns` +- 默认值 `None` + +按关键词屏蔽,支持正则,使用 `,` 分隔 +
### 批量参数 @@ -591,6 +694,57 @@ yutto -b -p "~3,10,12~14,16,-4~" +### 配置文件Experimental + +yutto 自 `2.0.0-rc.3` 起增加了实验性的配置文件功能,你可以通过 `--config` 选项来指定配置文件路径,比如 + +```bash +yutto --config /path/to/config.toml +``` + +如果不指定配置文件路径,yutto 也支持配置自动发现,根据优先级,搜索路径如下: + +- 当前目录下的 `yutto.toml` +- 搜索 [`XDG_CONFIG_HOME`](https://specifications.freedesktop.org/basedir-spec/latest/) 下的 `yutto/yutto.toml` 文件 +- 非 Windows 系统下的 `~/.config/yutto/yutto.toml`,Windows 系统下的 `~/AppData/Roaming/yutto/yutto.toml` + +你可以通过配置文件来设置一些默认参数,整体上与命令行参数基本一致,下面以一些示例来展示配置文件的写法: + +```toml +# yutto.toml +#:schema https://raw.githubusercontent.com/yutto-dev/yutto/refs/heads/main/schemas/config.json +[basic] +# 设置下载目录 +dir = "/path/to/download" +# 设置临时文件目录 +tmp_dir = "/path/to/tmp" +# 设置 SESSDATA +sessdata = "***************" +# 设置大会员严格校验 +vip_strict = true +# 设置登录严格校验 +login_strict = true + +[resource] +# 不下载字幕 +require_subtitle = false + +[danmaku] +# 设置弹幕速度 +speed = 2.0 +# 设置弹幕屏蔽关键词 +block_keyword_patterns = [ + ".*keyword1.*", + ".*keyword2.*", +] + +[batch] +# 下载额外剧集 +with_section = true +``` + +如果你使用 VS Code 对配置文件编辑,强烈建议使用 [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) 扩展,配合 yutto 提供的 schema,可以获得最佳的提示体验。 + ## 从 bilili1.x 迁移 ### 取消的功能 @@ -633,6 +787,41 @@ yutto -b -p "~3,10,12~14,16,-4~" yutto --no-color --no-progress > log ``` +### 使用配置自定义默认参数 + +如果你希望修改 yutto 的部分参数,那么可能每次运行都需要在后面加上长长一串选项,为了避免这个问题,你可以尝试使用配置文件 + +```toml +# ~/.config/yutto/yutto.toml +#:schema https://raw.githubusercontent.com/yutto-dev/yutto/refs/heads/main/schemas/config.json +[basic] +dir = "~/Movies/yutto" +sessdata = "***************" +num_workers = 16 +vcodec = "av1:copy" +``` + +当然,请手动修改 `sessdata` 内容为自己的 `SESSDATA` 哦~ + +> [!TIP] +> +> 本方案可替代原有的「自定义命令别名」方式~ +> +>
+> 原「自定义命令别名」方案 +> +> 在 `~/.zshrc` / `~/.bashrc` 中自定义一条 alias,像这样 +> +> ```bash +> alias ytt='yutto -d ~/Movies/yutto/ -c `cat ~/.sessdata` -n 16 --vcodec="av1:copy"' +> ``` +> +> 这样我每次只需要 `ytt ` 就可以直接使用这些参数进行下载啦~ +> +> 由于我提前在 `~/.sessdata` 存储了我的 `SESSDATA`,所以避免每次都要手动输入 cookie 的问题。 +> +>
+ ### 使用 url alias yutto 新增的 url alias 可以让你下载正在追的番剧时不必每次都打开浏览器复制 url,只需要将追番列表存储在一个文件中,并为这些 url 起一个别名即可 @@ -647,6 +836,15 @@ tensura-nikki=https://www.bilibili.com/bangumi/play/ss38221/ yutto --batch tensura-nikki --alias-file=/path/to/alias-file ``` +你同样可以通过配置文件来实现这一点(推荐) + +```toml +# ~/.config/yutto/yutto.toml +#:schema https://raw.githubusercontent.com/yutto-dev/yutto/refs/heads/main/schemas/config.json +[basic.aliases] +tensura-nikki = "https://www.bilibili.com/bangumi/play/ss38221/" +``` + ### 使用任务列表 现在 url 不仅支持 http/https 链接与裸 id,还支持使用文件路径与 file scheme 来用于表示文件列表,文件列表以行分隔,每行写一次命令的参数,该参数会覆盖掉主程序中所使用的参数,示例如下: @@ -691,20 +889,6 @@ yutto file:///path/to/list --vcodec="avc:copy" 最后,列表也是支持嵌套的哦(虽然没什么用 2333) -### 自定义命令别名 - -如果你不习惯于 yutto 的默认参数,那么可能每次运行都需要在后面加上长长一串参数,为了避免这一点,我是这样做的: - -在 `~/.zshrc` / `~/.bashrc` 中自定义一条 alias,像这样 - -```bash -alias ytt='yutto -d ~/Movies/yutto/ -c `cat ~/.sessdata` -n 16 --vcodec="av1:copy"' -``` - -这样我每次只需要 `ytt ` 就可以直接使用这些参数进行下载啦~ - -由于我提前在 `~/.sessdata` 存储了我的 `SESSDATA`,所以避免每次都要手动输入 cookie 的问题。 - ## FAQ ### 名字的由来 @@ -717,7 +901,7 @@ yutto 添加任何特性都需要以保证可维护性为前提,因此 yutto ### yutto 会替代 bilili 吗 -yutto 自诞生以来已经过去三年多了,功能上基本可以替代 bilili 了,因此 bilili 将会在 yutto 正式版发布后正式停止维护~(咳,正式版还要再过段时间~ ○ω●) +yutto 自诞生以来已经过去三年多了,功能上基本可以替代 bilili 了,由于 B 站接口的不断变化,bilili 也不再适用于现在的环境,因此请 bilili 用户尽快迁移到 yutto ~ ## 其他应用 @@ -727,21 +911,21 @@ yutto 自诞生以来已经过去三年多了,功能上基本可以替代 bili ## Roadmap -### 2.0.0-rc - -- [x] feat: 投稿视频描述文件支持 -- [x] refactor: 整理路径变量名 -- [x] feat: 视频合集选集支持(合集貌似有取代分 p 的趋势,需要对其进行合适的处理) -- [x] refactor: 重写 biliass - ### 2.0.0 -- [ ] refactor: 针对视频合集优化路径变量 -- [ ] refactor: 优化杜比视界/音效/全景声选取逻辑(Discussing in [#62](https://github.com/yutto-dev/yutto/discussions/62)) -- [ ] docs: 可爱的静态文档(WIP in [#86](https://github.com/yutto-dev/yutto/pull/86)) +- [x] feat: 支持弹幕字体、字号、速度等设置 +- [x] feat: 配置文件支持 +- [x] feat: 配置文件功能优化,支持自定义配置路径 +- [x] docs: issue template 添加配置引导 +- [x] docs: 优化 biliass rust 重构后的贡献指南 ### future +- [ ] docs: 可爱的静态文档(WIP in [#86](https://github.com/yutto-dev/yutto/pull/86)) +- [ ] feat: 新的基于 toml 的任务列表 +- [ ] refactor: 配置参数复用 pydantic 验证 +- [ ] refactor: 针对视频合集优化路径变量 +- [ ] refactor: 优化杜比视界/音效/全景声选取逻辑(Discussing in [#62](https://github.com/yutto-dev/yutto/discussions/62)) - [ ] refactor: 直接使用 rich 替代内置的终端显示模块 - [ ] feat: 更多批下载支持 - [ ] feat: 以及更加可爱~ diff --git a/justfile b/justfile index 7eaeb4023..8605c6c7f 100644 --- a/justfile +++ b/justfile @@ -18,7 +18,7 @@ fmt: uv run ruff format . lint: - uv run pyright src/yutto tests + uv run pyright src/yutto packages/biliass/src/biliass tests uv run ruff check . uv run typos @@ -45,6 +45,7 @@ clean: -e mp4 \ -e mkv \ -e mov \ + -e m4a \ -e aac \ -e mp3 \ -e flac \ @@ -66,6 +67,9 @@ clean-builds: rm -rf dist/ rm -rf yutto.egg-info/ +generate-schema: + uv run scripts/generate-schema.py + # CI specific ci-install: uv sync --all-extras --dev diff --git a/logo/logo.png b/logo/logo.png new file mode 100644 index 000000000..c88a718cd Binary files /dev/null and b/logo/logo.png differ diff --git a/packages/biliass/README.md b/packages/biliass/README.md index 1c26cb4dc..cce8d25da 100644 --- a/packages/biliass/README.md +++ b/packages/biliass/README.md @@ -7,13 +7,10 @@ Build Status LICENSE Gitmoji + CodSpeed Badge

-biliass,只是 Danmaku2ASS 的 bilili 与 yutto 适配版 - -原版: - -仅支持 bilibili 弹幕,支持 XML 弹幕和 Protobuf 弹幕 +biliass,高性能且易于使用的 bilibili 弹幕转换工具(XML/Protobuf 格式转 ASS),基于 [Danmaku2ASS](https://github.com/m13253/danmaku2ass),使用 rust 重写 ## Install @@ -36,36 +33,32 @@ from biliass import convert_to_ass # xml convert_to_ass( xml_text_or_bytes, - width, - height, + 1920, + 1080, input_format="xml", - reserve_blank=0, + display_region_ratio=1.0, font_face="sans-serif", - font_size=width / 40, + font_size=25, text_opacity=0.8, duration_marquee=15.0, duration_still=10.0, - comment_filter=None, - is_reduce_comments=False, + block_options=None, + reduce_comments=False, ) # protobuf convert_to_ass( protobuf_bytes, # only bytes - width, - height, + 1920, + 1080, input_format="protobuf", - reserve_blank=0, + display_region_ratio=1.0, font_face="sans-serif", - font_size=width / 40, + font_size=25, text_opacity=0.8, duration_marquee=15.0, duration_still=10.0, - comment_filter=None, - is_reduce_comments=False, + block_options=None, + reduce_comments=False, ) ``` - -## TODO - -- 导出 bilibili 网页上的弹幕设置,并导入到 biliass diff --git a/packages/biliass/pyproject.toml b/packages/biliass/pyproject.toml index 679c8f9c2..a41459a74 100644 --- a/packages/biliass/pyproject.toml +++ b/packages/biliass/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "biliass" -description = "将 B 站弹幕转换为 ASS 弹幕" +description = "💬 将 B 站 XML/protobuf 弹幕转换为 ASS 弹幕" readme = "README.md" requires-python = ">=3.9" authors = [ diff --git a/packages/biliass/rust/Cargo.lock b/packages/biliass/rust/Cargo.lock index 1e3255173..eb0d41e49 100644 --- a/packages/biliass/rust/Cargo.lock +++ b/packages/biliass/rust/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -49,7 +49,7 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "biliass-core" -version = "2.0.0" +version = "2.2.0" dependencies = [ "bytes", "cached", @@ -58,10 +58,11 @@ dependencies = [ "protox", "pyo3", "quick-xml", + "rayon", "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.11", "tracing", "tracing-subscriber", ] @@ -80,22 +81,22 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytes" -version = "1.7.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cached" -version = "0.53.1" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4d73155ae6b28cf5de4cfc29aeb02b8a1c6dab883cb015d15cd514e42766846" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" dependencies = [ "ahash", "cached_proc_macro", "cached_proc_macro_types", "hashbrown", "once_cell", - "thiserror", + "thiserror 1.0.68", "web-time", ] @@ -123,6 +124,31 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + [[package]] name = "darling" version = "0.20.10" @@ -340,7 +366,7 @@ checksum = "4edc8853320c2a0dab800fbda86253c8938f6ea88510dc92c5f1ed20e794afc1" dependencies = [ "cfg-if", "miette-derive", - "thiserror", + "thiserror 1.0.68", "unicode-width", ] @@ -426,9 +452,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +checksum = "2c0fef6c4230e4ccf618a35c59d7ede15dea37de8427500f50aff708806e42ec" dependencies = [ "bytes", "prost-derive", @@ -436,11 +462,10 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1318b19085f08681016926435853bbf7858f9c082d0999b80550ff5d9abe15" +checksum = "d0f3e5beed80eb580c68e2c600937ac2c4eedabdfd5ef1e5b7ea4f3fba84497b" dependencies = [ - "bytes", "heck", "itertools", "log", @@ -457,9 +482,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3" dependencies = [ "anyhow", "itertools", @@ -483,18 +508,18 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4759aa0d3a6232fb8dbdb97b61de2c20047c68aca932c7ed76da9d788508d670" +checksum = "cc2f1e56baa61e93533aebc21af4d2134b70f66275e0fcdf3cbe43d77ff7e8fc" dependencies = [ "prost", ] [[package]] name = "protox" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873f359bdecdfe6e353752f97cb9ee69368df55b16363ed2216da85e03232a58" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" dependencies = [ "bytes", "miette", @@ -502,7 +527,7 @@ dependencies = [ "prost-reflect", "prost-types", "protox-parse", - "thiserror", + "thiserror 1.0.68", ] [[package]] @@ -514,14 +539,14 @@ dependencies = [ "logos", "miette", "prost-types", - "thiserror", + "thiserror 1.0.68", ] [[package]] name = "pyo3" -version = "0.22.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15ee168e30649f7f234c3d49ef5a7a6cbf5134289bc46c29ff3155fa3221c225" +checksum = "57fe09249128b3173d092de9523eaa75136bf7ba85e0d69eca241c7939c933cc" dependencies = [ "cfg-if", "indoc", @@ -537,9 +562,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.22.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e61cef80755fe9e46bb8a0b8f20752ca7676dcc07a5277d8b7768c6172e529b3" +checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7" dependencies = [ "once_cell", "target-lexicon", @@ -547,9 +572,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.22.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ce096073ec5405f5ee2b8b31f03a68e02aa10d5d4f565eca04acc41931fa1c" +checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d" dependencies = [ "libc", "pyo3-build-config", @@ -557,9 +582,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.22.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2440c6d12bc8f3ae39f1e775266fa5122fd0c8891ce7520fa6048e683ad3de28" +checksum = "91871864b353fd5ffcb3f91f2f703a22a9797c91b9ab497b1acac7b07ae509c7" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -569,9 +594,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.22.3" +version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1be962f0e06da8f8465729ea2cb71a416d2257dff56cbe40a70d3e62a93ae5d1" +checksum = "43abc3b80bc20f3facd86cd3c60beed58c3e2aa26213f3cda368de39c60a27e4" dependencies = [ "heck", "proc-macro2", @@ -582,9 +607,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" dependencies = [ "memchr", ] @@ -598,11 +623,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -648,18 +693,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", @@ -668,9 +713,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" dependencies = [ "itoa", "memchr", @@ -701,9 +746,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.77" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -731,18 +776,38 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.64" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" +dependencies = [ + "thiserror-impl 1.0.68", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "thiserror-impl" -version = "1.0.64" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", @@ -761,9 +826,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -772,9 +837,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -783,9 +848,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -804,9 +869,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "nu-ansi-term", "sharded-slab", diff --git a/packages/biliass/rust/Cargo.toml b/packages/biliass/rust/Cargo.toml index 3f4261259..55e000574 100644 --- a/packages/biliass/rust/Cargo.toml +++ b/packages/biliass/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "biliass-core" -version = "2.0.0" +version = "2.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -9,18 +9,23 @@ name = "biliass_core" crate-type = ["cdylib"] [dependencies] -pyo3 = { version = "0.22.3", features = ["abi3-py39"] } -bytes = "1.7.2" +pyo3 = { version = "0.23.2", features = ["abi3-py39"] } +bytes = "1.9.0" prost = "0.13.3" -thiserror = "1.0.63" -quick-xml = "0.36.2" -cached = "0.53.1" -serde = "1.0.210" -serde_json = "1.0.128" -regex = "1.10.6" -tracing = "0.1.40" +thiserror = "2.0.11" +quick-xml = "0.37.1" +cached = "0.54.0" +serde = "1.0.215" +serde_json = "1.0.133" +regex = "1.11.1" +tracing = "0.1.41" tracing-subscriber = "0.3.18" +rayon = "1.10.0" [build-dependencies] prost-build = "0.13.3" protox = "0.7.1" + +[profile.release] +lto = true # Enables link to optimizations +opt-level = "s" # Optimize for binary size diff --git a/packages/biliass/rust/src/comment.rs b/packages/biliass/rust/src/comment.rs index 8386ba6d4..6ab7b87e0 100644 --- a/packages/biliass/rust/src/comment.rs +++ b/packages/biliass/rust/src/comment.rs @@ -12,6 +12,55 @@ pub enum CommentPosition { Special, } +#[derive(Debug, PartialEq, Clone)] +pub struct NormalCommentData { + /// The estimated height in pixels + /// i.e. (comment.count('\n')+1)*size + pub height: f32, + /// The estimated width in pixels + /// i.e. calculate_length(comment)*size + pub width: f32, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct SpecialCommentData { + pub rotate_y: i64, + pub rotate_z: i64, + pub from_x: f64, + pub from_y: f64, + pub to_x: f64, + pub to_y: f64, + pub from_alpha: u8, + pub to_alpha: u8, + pub delay: i64, + pub lifetime: f64, + pub duration: i64, + pub fontface: String, + pub is_border: bool, +} + +#[derive(Debug, PartialEq, Clone)] +pub enum CommentData { + Normal(NormalCommentData), + Special(SpecialCommentData), +} + +impl CommentData { + pub fn as_normal(&self) -> Result<&NormalCommentData, &str> { + match self { + CommentData::Normal(data) => Ok(data), + CommentData::Special(_) => Err("CommentData is Special"), + } + } + + pub fn as_special(&self) -> Result<&SpecialCommentData, &str> { + match self { + CommentData::Normal(_) => Err("CommentData is Normal"), + CommentData::Special(data) => Ok(data), + } + } +} + #[derive(Debug, PartialEq, Clone)] pub struct Comment { /// The position when the comment is replayed @@ -21,7 +70,7 @@ pub struct Comment { /// A sequence of 1, 2, 3, ..., used for sorting pub no: u64, /// The content of the comment - pub comment: String, + pub content: String, /// The comment position pub pos: CommentPosition, /// Font color represented in 0xRRGGBB, @@ -29,10 +78,6 @@ pub struct Comment { pub color: u32, /// Font size pub size: f32, - /// The estimated height in pixels - /// i.e. (comment.count('\n')+1)*size - pub height: f32, - /// The estimated width in pixels - /// i.e. calculate_length(comment)*size - pub width: f32, + /// The comment data + pub data: CommentData, } diff --git a/packages/biliass/rust/src/convert.rs b/packages/biliass/rust/src/convert.rs index 2aca59844..fd5aba3b5 100644 --- a/packages/biliass/rust/src/convert.rs +++ b/packages/biliass/rust/src/convert.rs @@ -1,44 +1,36 @@ use crate::comment::{Comment, CommentPosition}; use crate::error::BiliassError; +use crate::filter::BlockOptions; use crate::writer; use crate::writer::rows; -use regex::Regex; +use rayon::prelude::*; #[allow(clippy::too_many_arguments)] pub fn process_comments( comments: &Vec, width: u32, height: u32, - bottom_reserved: u32, + zoom_factor: (f32, f32, f32), + display_region_ratio: f32, fontface: &str, fontsize: f32, alpha: f32, duration_marquee: f64, duration_still: f64, - filters_regex: Vec, reduced: bool, ) -> Result { let styleid = "biliass"; let mut ass_result = "".to_owned(); ass_result += &writer::ass::write_head(width, height, fontface, fontsize, alpha, styleid); + let bottom_reserved = ((height as f32) * (1. - display_region_ratio)) as u32; let mut rows = rows::init_rows(4, (height - bottom_reserved + 1) as usize); - let compiled_regexes_res: Result, regex::Error> = filters_regex - .into_iter() - .map(|pattern| Regex::new(&pattern)) - .collect(); - let compiled_regexes = compiled_regexes_res.map_err(BiliassError::from)?; + for comment in comments { match comment.pos { CommentPosition::Scroll | CommentPosition::Bottom | CommentPosition::Top | CommentPosition::Reversed => { - if compiled_regexes - .iter() - .any(|regex| regex.is_match(&comment.comment)) - { - continue; - }; ass_result += &writer::ass::write_normal_comment( rows.as_mut(), comment, @@ -53,7 +45,13 @@ pub fn process_comments( ); } CommentPosition::Special => { - ass_result += &writer::ass::write_special_comment(comment, width, height, styleid); + ass_result += &writer::ass::write_special_comment( + comment, + width, + height, + zoom_factor, + styleid, + ); } } } @@ -66,46 +64,61 @@ pub fn convert_to_ass( reader: Reader, stage_width: u32, stage_height: u32, - reserve_blank: u32, + display_region_ratio: f32, font_face: &str, font_size: f32, text_opacity: f32, duration_marquee: f64, duration_still: f64, - comment_filters: Vec, is_reduce_comments: bool, + block_options: &BlockOptions, ) -> Result where - Reader: Fn(Input, f32) -> Result, BiliassError>, + Reader: Fn(Input, f32, (f32, f32, f32), &BlockOptions) -> Result, BiliassError> + + Send + + Sync, + Input: Send, { + let zoom_factor = crate::writer::utils::get_zoom_factor( + crate::reader::special::BILI_PLAYER_SIZE, + (stage_width, stage_height), + ); let comments_result: Result>, BiliassError> = inputs - .into_iter() - .map(|input| reader(input, font_size)) + .into_par_iter() + .map(|input| reader(input, font_size, zoom_factor, block_options)) .collect(); + let comments = comments_result?; let mut comments = comments.concat(); + if !block_options.block_keyword_patterns.is_empty() { + comments.retain(|comment| { + !block_options + .block_keyword_patterns + .iter() + .any(|regex| regex.is_match(&comment.content)) + }); + } + if block_options.block_colorful { + comments.retain(|comment| comment.color == 0xffffff); + } comments.sort_by(|a, b| { ( a.timeline, a.timestamp, a.no, - &a.comment, + &a.content, &a.pos, a.color, a.size, - a.height, - a.width, ) .partial_cmp(&( b.timeline, b.timestamp, b.no, - &b.comment, + &b.content, &b.pos, b.color, a.size, - a.height, - a.width, )) .unwrap_or(std::cmp::Ordering::Less) }); @@ -113,13 +126,13 @@ where &comments, stage_width, stage_height, - reserve_blank, + zoom_factor, + display_region_ratio, font_face, font_size, text_opacity, duration_marquee, duration_still, - comment_filters, is_reduce_comments, ) } diff --git a/packages/biliass/rust/src/filter.rs b/packages/biliass/rust/src/filter.rs new file mode 100644 index 000000000..45d570f3d --- /dev/null +++ b/packages/biliass/rust/src/filter.rs @@ -0,0 +1,20 @@ +use crate::comment::CommentPosition; +use regex::Regex; + +#[derive(Default, Clone)] +pub struct BlockOptions { + pub block_top: bool, + pub block_bottom: bool, + pub block_scroll: bool, + pub block_reverse: bool, + pub block_special: bool, + pub block_colorful: bool, + pub block_keyword_patterns: Vec, +} + +pub fn should_skip_parse(pos: &CommentPosition, block_options: &BlockOptions) -> bool { + matches!(pos, CommentPosition::Top) && block_options.block_top + || matches!(pos, CommentPosition::Bottom) && block_options.block_bottom + || matches!(pos, CommentPosition::Scroll) && block_options.block_scroll + || matches!(pos, CommentPosition::Special) && block_options.block_reverse +} diff --git a/packages/biliass/rust/src/lib.rs b/packages/biliass/rust/src/lib.rs index 7c81e8e92..ca7de1413 100644 --- a/packages/biliass/rust/src/lib.rs +++ b/packages/biliass/rust/src/lib.rs @@ -1,6 +1,7 @@ mod comment; mod convert; mod error; +mod filter; mod logging; mod proto; mod python; @@ -18,12 +19,14 @@ impl std::convert::From for PyErr { } /// Bindings for biliass core. -#[pymodule] +#[pymodule(gil_used = false)] #[pyo3(name = "_core")] fn biliass_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(python::py_get_danmaku_meta_size, m)?)?; m.add_function(wrap_pyfunction!(python::py_xml_to_ass, m)?)?; m.add_function(wrap_pyfunction!(python::py_protobuf_to_ass, m)?)?; m.add_function(wrap_pyfunction!(python::py_enable_tracing, m)?)?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/packages/biliass/rust/src/python/convert.rs b/packages/biliass/rust/src/python/convert.rs index 89065c238..6b27dc2d0 100644 --- a/packages/biliass/rust/src/python/convert.rs +++ b/packages/biliass/rust/src/python/convert.rs @@ -1,68 +1,163 @@ +use crate::error::BiliassError; +use crate::filter::BlockOptions; use crate::{convert, reader}; - use pyo3::{ prelude::*, pybacked::{PyBackedBytes, PyBackedStr}, }; +use regex::Regex; + +#[pyclass(name = "BlockOptions")] +#[derive(Clone)] +pub struct PyBlockOptions { + pub block_top: bool, + pub block_bottom: bool, + pub block_scroll: bool, + pub block_reverse: bool, + pub block_special: bool, + pub block_colorful: bool, + pub block_keyword_patterns: Vec, +} +impl PyBlockOptions { + pub fn to_block_options(&self) -> Result { + let block_keyword_patterns_res: Result, regex::Error> = self + .block_keyword_patterns + .iter() + .map(|pattern| regex::Regex::new(pattern)) + .collect(); + let block_keyword_patterns = block_keyword_patterns_res.map_err(BiliassError::from)?; + Ok(BlockOptions { + block_top: self.block_top, + block_bottom: self.block_bottom, + block_scroll: self.block_scroll, + block_reverse: self.block_reverse, + block_special: self.block_special, + block_colorful: self.block_colorful, + block_keyword_patterns, + }) + } +} + +#[pymethods] +impl PyBlockOptions { + #[new] + fn new( + block_top: bool, + block_bottom: bool, + block_scroll: bool, + block_reverse: bool, + block_special: bool, + block_colorful: bool, + block_keyword_patterns: Vec, + ) -> PyResult { + Ok(PyBlockOptions { + block_top, + block_bottom, + block_scroll, + block_reverse, + block_special, + block_colorful, + block_keyword_patterns, + }) + } + + #[staticmethod] + pub fn default() -> Self { + PyBlockOptions { + block_top: false, + block_bottom: false, + block_scroll: false, + block_reverse: false, + block_special: false, + block_colorful: false, + block_keyword_patterns: vec![], + } + } +} + +#[pyclass(name = "ConversionOptions")] +pub struct PyConversionOptions { + pub stage_width: u32, + pub stage_height: u32, + pub display_region_ratio: f32, + pub font_face: String, + pub font_size: f32, + pub text_opacity: f32, + pub duration_marquee: f64, + pub duration_still: f64, + pub is_reduce_comments: bool, +} + +#[pymethods] #[allow(clippy::too_many_arguments)] +impl PyConversionOptions { + #[new] + fn new( + stage_width: u32, + stage_height: u32, + display_region_ratio: f32, + font_face: String, + font_size: f32, + text_opacity: f32, + duration_marquee: f64, + duration_still: f64, + is_reduce_comments: bool, + ) -> Self { + PyConversionOptions { + stage_width, + stage_height, + display_region_ratio, + font_face, + font_size, + text_opacity, + duration_marquee, + duration_still, + is_reduce_comments, + } + } +} + #[pyfunction(name = "xml_to_ass")] pub fn py_xml_to_ass( inputs: Vec, - stage_width: u32, - stage_height: u32, - reserve_blank: u32, - font_face: &str, - font_size: f32, - text_opacity: f32, - duration_marquee: f64, - duration_still: f64, - comment_filters: Vec, - is_reduce_comments: bool, + conversion_options: &PyConversionOptions, + block_options: &PyBlockOptions, ) -> PyResult { Ok(convert::convert_to_ass( inputs, crate::reader::xml::read_comments_from_xml, - stage_width, - stage_height, - reserve_blank, - font_face, - font_size, - text_opacity, - duration_marquee, - duration_still, - comment_filters, - is_reduce_comments, + conversion_options.stage_width, + conversion_options.stage_height, + conversion_options.display_region_ratio, + &conversion_options.font_face, + conversion_options.font_size, + conversion_options.text_opacity, + conversion_options.duration_marquee, + conversion_options.duration_still, + conversion_options.is_reduce_comments, + &block_options.to_block_options()?, )?) } -#[allow(clippy::too_many_arguments)] #[pyfunction(name = "protobuf_to_ass")] pub fn py_protobuf_to_ass( inputs: Vec, - stage_width: u32, - stage_height: u32, - reserve_blank: u32, - font_face: &str, - font_size: f32, - text_opacity: f32, - duration_marquee: f64, - duration_still: f64, - comment_filters: Vec, - is_reduce_comments: bool, + conversion_options: &PyConversionOptions, + block_options: &PyBlockOptions, ) -> PyResult { Ok(convert::convert_to_ass( inputs, reader::protobuf::read_comments_from_protobuf, - stage_width, - stage_height, - reserve_blank, - font_face, - font_size, - text_opacity, - duration_marquee, - duration_still, - comment_filters, - is_reduce_comments, + conversion_options.stage_width, + conversion_options.stage_height, + conversion_options.display_region_ratio, + &conversion_options.font_face, + conversion_options.font_size, + conversion_options.text_opacity, + conversion_options.duration_marquee, + conversion_options.duration_still, + conversion_options.is_reduce_comments, + &block_options.to_block_options()?, )?) } diff --git a/packages/biliass/rust/src/python/mod.rs b/packages/biliass/rust/src/python/mod.rs index b3cfa870d..3cc45fe2a 100644 --- a/packages/biliass/rust/src/python/mod.rs +++ b/packages/biliass/rust/src/python/mod.rs @@ -1,6 +1,6 @@ mod convert; mod logging; mod proto; -pub use convert::{py_protobuf_to_ass, py_xml_to_ass}; +pub use convert::{py_protobuf_to_ass, py_xml_to_ass, PyBlockOptions, PyConversionOptions}; pub use logging::py_enable_tracing; pub use proto::py_get_danmaku_meta_size; diff --git a/packages/biliass/rust/src/python/proto.rs b/packages/biliass/rust/src/python/proto.rs index b4665f6aa..c2a6ce888 100644 --- a/packages/biliass/rust/src/python/proto.rs +++ b/packages/biliass/rust/src/python/proto.rs @@ -4,133 +4,6 @@ use prost::Message; use pyo3::prelude::*; use std::io::Cursor; -#[pyclass(name = "DanmakuElem")] -pub struct PyDanmakuElem { - inner: proto::danmaku::DanmakuElem, -} - -impl PyDanmakuElem { - fn new(inner: proto::danmaku::DanmakuElem) -> Self { - PyDanmakuElem { inner } - } -} - -#[pymethods] -impl PyDanmakuElem { - #[getter] - fn id(&self) -> PyResult { - Ok(self.inner.id) - } - - #[getter] - fn progress(&self) -> PyResult { - Ok(self.inner.progress) - } - - #[getter] - fn mode(&self) -> PyResult { - Ok(self.inner.mode) - } - - #[getter] - fn fontsize(&self) -> PyResult { - Ok(self.inner.fontsize) - } - - #[getter] - fn color(&self) -> PyResult { - Ok(self.inner.color) - } - - #[getter] - fn mid_hash(&self) -> PyResult { - Ok(self.inner.mid_hash.clone()) - } - - #[getter] - fn content(&self) -> PyResult { - Ok(self.inner.content.clone()) - } - - #[getter] - fn ctime(&self) -> PyResult { - Ok(self.inner.ctime) - } - - #[getter] - fn weight(&self) -> PyResult { - Ok(self.inner.weight) - } - - #[getter] - fn action(&self) -> PyResult { - Ok(self.inner.action.clone()) - } - - #[getter] - fn pool(&self) -> PyResult { - Ok(self.inner.pool) - } - - #[getter] - fn id_str(&self) -> PyResult { - Ok(self.inner.id_str.clone()) - } - - #[getter] - fn attr(&self) -> PyResult { - Ok(self.inner.attr) - } - - #[getter] - fn animation(&self) -> PyResult { - Ok(self.inner.animation.clone()) - } - - fn __repr__(&self) -> PyResult { - Ok(format!("DanmakuElem({:?})", self.inner)) - } -} - -#[pyclass(name = "DmSegMobileReply")] -pub struct PyDmSegMobileReply { - inner: proto::danmaku::DmSegMobileReply, -} - -impl PyDmSegMobileReply { - fn new(inner: proto::danmaku::DmSegMobileReply) -> Self { - PyDmSegMobileReply { inner } - } -} - -#[pymethods] -impl PyDmSegMobileReply { - #[getter] - fn elems(&self) -> PyResult { - Python::with_gil(|py| { - let list = pyo3::types::PyList::empty_bound(py); - for item in &self.inner.elems { - let item = PyDanmakuElem::new(item.clone()); - list.append(item.into_py(py))?; - } - Ok(list.into()) - }) - } - - fn __repr__(&self) -> PyResult { - Ok(format!("DmSegMobileReply({:?})", self.inner)) - } - - #[staticmethod] - fn decode(buffer: &[u8]) -> PyResult { - Ok(PyDmSegMobileReply::new( - proto::danmaku::DmSegMobileReply::decode(&mut Cursor::new(buffer)) - .map_err(error::DecodeError::from) - .map_err(error::BiliassError::from)?, - )) - } -} - #[pyfunction(name = "get_danmaku_meta_size")] pub fn py_get_danmaku_meta_size(buffer: &[u8]) -> PyResult { let dm_sge_opt = proto::danmaku_view::DmWebViewReply::decode(&mut Cursor::new(buffer)) diff --git a/packages/biliass/rust/src/reader/protobuf.rs b/packages/biliass/rust/src/reader/protobuf.rs index 1704f279f..8c1101213 100644 --- a/packages/biliass/rust/src/reader/protobuf.rs +++ b/packages/biliass/rust/src/reader/protobuf.rs @@ -1,11 +1,18 @@ -use crate::comment::{Comment, CommentPosition}; +use crate::comment::{Comment, CommentData, CommentPosition, NormalCommentData}; use crate::error::{BiliassError, DecodeError}; +use crate::filter::{should_skip_parse, BlockOptions}; use crate::proto::danmaku::DmSegMobileReply; -use crate::reader::utils; +use crate::reader::{special, utils}; use prost::Message; use std::io::Cursor; +use tracing::warn; -pub fn read_comments_from_protobuf(data: T, fontsize: f32) -> Result, BiliassError> +pub fn read_comments_from_protobuf( + data: T, + fontsize: f32, + zoom_factor: (f32, f32, f32), + block_options: &BlockOptions, +) -> Result, BiliassError> where T: AsRef<[u8]>, { @@ -26,9 +33,12 @@ where 7 => CommentPosition::Special, _ => unreachable!("Impossible danmaku type"), }; + if should_skip_parse(&comment_pos, block_options) { + continue; + } let color = elem.color; let size = elem.fontsize; - let (comment_content, size, height, width) = + let (comment_content, size, comment_data) = if comment_pos != CommentPosition::Special { let comment_content = utils::unescape_newline(&utils::filter_bad_chars(&elem.content)); @@ -37,20 +47,36 @@ where (comment_content.chars().filter(|&c| c == '\n').count() as f32 + 1.0) * size; let width = utils::calculate_length(&comment_content) * size; - (comment_content, size, height, width) + ( + comment_content, + size, + CommentData::Normal(NormalCommentData { height, width }), + ) } else { - (utils::filter_bad_chars(&elem.content), size as f32, 0., 0.) + let parsed_data = special::parse_special_comment( + &utils::filter_bad_chars(&elem.content), + zoom_factor, + ); + if parsed_data.is_err() { + warn!("Failed to parse special comment: {:?}", parsed_data); + continue; + } + let (content, special_comment_data) = parsed_data.unwrap(); + ( + content, + size as f32, + CommentData::Special(special_comment_data), + ) }; comments.push(Comment { timeline, timestamp, no: i as u64, - comment: comment_content, + content: comment_content, pos: comment_pos, color, size, - height, - width, + data: comment_data, }) } 8 => { diff --git a/packages/biliass/rust/src/reader/special.rs b/packages/biliass/rust/src/reader/special.rs index 8579ca570..ed59b9804 100644 --- a/packages/biliass/rust/src/reader/special.rs +++ b/packages/biliass/rust/src/reader/special.rs @@ -1,4 +1,5 @@ -use crate::error::{BiliassError, DecodeError, ParseError}; +use crate::comment::SpecialCommentData; +use crate::error::ParseError; use crate::reader::utils; // pub const BILI_PLAYER_SIZE: (u32, u32) = (512, 384); // Bilibili player version 2010 @@ -23,26 +24,17 @@ fn get_position(input_pos: f64, is_height: bool, zoom_factor: (f32, f32, f32)) - pub fn parse_special_comment( content: &str, zoom_factor: (f32, f32, f32), -) -> Result< - ( - (i64, i64, f64, f64, f64, f64), - u8, - u8, - String, - i64, - f64, - i64, - String, - bool, - ), - BiliassError, -> { +) -> Result<(String, SpecialCommentData), ParseError> { let special_comment_parsed_data = - serde_json::from_str::(content).map_err(DecodeError::from)?; + serde_json::from_str::(content).map_err(|e| { + ParseError::SpecialComment(format!( + "Error occurred while parsing special comment: {e}, content: {content}", + )) + })?; if !special_comment_parsed_data.is_array() { - return Err( - ParseError::SpecialComment("Special comment is not an array".to_owned()).into(), - ); + return Err(ParseError::SpecialComment( + "Special comment is not an array".to_owned(), + )); } let special_comment_array = special_comment_parsed_data.as_array().unwrap(); let text = utils::unescape_newline(special_comment_array[4].as_str().ok_or( @@ -93,15 +85,22 @@ pub fn parse_special_comment( // let is_border = parse_array_item_at_index(special_comment_array, 11, true, parse_bool_value)?; let is_border = true; Ok(( - (rotate_y, rotate_z, from_x, from_y, to_x, to_y), - from_alpha, - to_alpha, text.to_owned(), - delay, - lifetime, - duration, - fontface, - is_border, + SpecialCommentData { + rotate_y, + rotate_z, + from_x, + from_y, + to_x, + to_y, + from_alpha, + to_alpha, + delay, + lifetime, + duration, + fontface, + is_border, + }, )) } @@ -110,44 +109,52 @@ fn parse_array_item_at_index( array: &[serde_json::Value], index: usize, default: T, - item_parser: fn(&serde_json::Value, T) -> Result, -) -> Result { + item_parser: fn(&serde_json::Value, T) -> Result, +) -> Result { match array.get(index) { Some(value) => item_parser(value, default), None => Ok(default), } } -fn parse_float_value(value: &serde_json::Value, default: f64) -> Result { +fn parse_float_value(value: &serde_json::Value, default: f64) -> Result { match value { serde_json::Value::Number(num) => Ok(num.as_f64().unwrap_or(default)), serde_json::Value::String(str) => Ok(str.parse::().unwrap_or(default)), serde_json::Value::Null => Ok(default), - _ => Err(ParseError::SpecialComment("Value is not a number".to_owned()).into()), + _ => Err(ParseError::SpecialComment( + "Value is not a number".to_owned(), + )), } } -fn parse_int_value(value: &serde_json::Value, default: i64) -> Result { +fn parse_int_value(value: &serde_json::Value, default: i64) -> Result { match value { serde_json::Value::Number(num) => Ok(num.as_f64().unwrap_or(default as f64) as i64), serde_json::Value::String(str) => Ok(str.parse::().unwrap_or(default as f64) as i64), serde_json::Value::Null => Ok(default), - _ => Err(ParseError::SpecialComment("Value is not a number".to_owned()).into()), + _ => Err(ParseError::SpecialComment( + "Value is not a number".to_owned(), + )), } } -fn parse_string_value(value: &serde_json::Value, _: String) -> Result { +fn parse_string_value(value: &serde_json::Value, _: String) -> Result { match value { serde_json::Value::String(str) => Ok(str.to_owned()), - _ => Err(ParseError::SpecialComment("Value is not a string".to_owned()).into()), + _ => Err(ParseError::SpecialComment( + "Value is not a string".to_owned(), + )), } } #[allow(unused)] -fn parse_bool_value(value: &serde_json::Value, default: bool) -> Result { +fn parse_bool_value(value: &serde_json::Value, default: bool) -> Result { match value { serde_json::Value::Bool(b) => Ok(*b), serde_json::Value::Number(num) => Ok(num.as_i64().unwrap_or(default as i64) != 0), - _ => Err(ParseError::SpecialComment("Value is not a boolean".to_owned()).into()), + _ => Err(ParseError::SpecialComment( + "Value is not a boolean".to_owned(), + )), } } diff --git a/packages/biliass/rust/src/reader/xml.rs b/packages/biliass/rust/src/reader/xml.rs index f3e41829b..f4d5fb21d 100644 --- a/packages/biliass/rust/src/reader/xml.rs +++ b/packages/biliass/rust/src/reader/xml.rs @@ -1,8 +1,10 @@ -use crate::comment::{Comment, CommentPosition}; +use crate::comment::{Comment, CommentData, CommentPosition, NormalCommentData}; use crate::error::{BiliassError, DecodeError, ParseError}; -use crate::reader::utils; +use crate::filter::{should_skip_parse, BlockOptions}; +use crate::reader::{special, utils}; use quick_xml::events::{BytesStart, Event}; use quick_xml::reader::Reader; +use tracing::warn; #[derive(PartialEq, Clone)] enum XmlVersion { @@ -44,7 +46,9 @@ fn parse_comment_item( content: &str, version: XmlVersion, fontsize: f32, + zoom_factor: (f32, f32, f32), id: u64, + block_options: &BlockOptions, ) -> Result, ParseError> { let split_p = raw_p.split(',').collect::>(); if split_p.len() < 5 { @@ -73,33 +77,49 @@ fn parse_comment_item( "7" => CommentPosition::Special, _ => unreachable!("Impossible danmaku type"), }; + if should_skip_parse(&comment_pos, block_options) { + return Ok(None); + } let color = split_p[3 + p_offset] .parse::() .map_err(|e| ParseError::Xml(format!("Error parsing color: {}", e)))?; let size = split_p[2 + p_offset] .parse::() .map_err(|e| ParseError::Xml(format!("Error parsing size: {}", e)))?; - let (comment_content, size, height, width) = if comment_pos != CommentPosition::Special - { + let (comment_content, size, comment_data) = if comment_pos != CommentPosition::Special { let comment_content = utils::unescape_newline(content); let size = (size as f32) * fontsize / 25.0; let height = (comment_content.chars().filter(|&c| c == '\n').count() as f32 + 1.0) * size; let width = utils::calculate_length(&comment_content) * size; - (comment_content, size, height, width) + ( + comment_content, + size, + CommentData::Normal(NormalCommentData { height, width }), + ) } else { - (content.to_string(), size as f32, 0., 0.) + let parsed_data = + special::parse_special_comment(&utils::filter_bad_chars(content), zoom_factor); + if parsed_data.is_err() { + warn!("Failed to parse special comment: {:?}", parsed_data); + return Ok(None); + } + let (content, special_comment_data) = parsed_data.unwrap(); + ( + content, + size as f32, + CommentData::Special(special_comment_data), + ) }; Ok(Some(Comment { timeline, timestamp, no: id, - comment: comment_content, + content: comment_content, pos: comment_pos, color, size, - height, - width, + data: comment_data, })) } @@ -117,18 +137,33 @@ fn parse_comment( element: BytesStart, version: XmlVersion, fontsize: f32, + zoom_factor: (f32, f32, f32), id: u64, + block_options: &BlockOptions, ) -> Result, ParseError> { if version == XmlVersion::V2 { return Err(ParseError::Xml("Not implemented".to_string())); } let raw_p = parse_raw_p(reader, &element)?; let content = parse_comment_content(reader)?; - let parsed_p = parse_comment_item(&raw_p, &content, version.clone(), fontsize, id)?; + let parsed_p = parse_comment_item( + &raw_p, + &content, + version.clone(), + fontsize, + zoom_factor, + id, + block_options, + )?; Ok(parsed_p) } -pub fn read_comments_from_xml(text: T, fontsize: f32) -> Result, BiliassError> +pub fn read_comments_from_xml( + text: T, + fontsize: f32, + zoom_factor: (f32, f32, f32), + block_options: &BlockOptions, +) -> Result, BiliassError> where T: AsRef, { @@ -166,9 +201,15 @@ where "No version specified".to_string(), ))); } - if let Ok(comment_option) = - parse_comment(&mut reader, e, version.clone().unwrap(), fontsize, count) - { + if let Ok(comment_option) = parse_comment( + &mut reader, + e, + version.clone().unwrap(), + fontsize, + zoom_factor, + count, + block_options, + ) { if let Some(comment) = comment_option { comments.push(comment); } diff --git a/packages/biliass/rust/src/writer/ass.rs b/packages/biliass/rust/src/writer/ass.rs index 8895a18f6..ecd99eb7b 100644 --- a/packages/biliass/rust/src/writer/ass.rs +++ b/packages/biliass/rust/src/writer/ass.rs @@ -1,5 +1,4 @@ -use crate::comment::{Comment, CommentPosition}; -use crate::reader::special::parse_special_comment; +use crate::comment::{Comment, CommentPosition, SpecialCommentData}; use crate::writer::rows; use crate::writer::utils; use tracing::warn; @@ -54,7 +53,11 @@ pub fn write_comment( duration_still: f64, styleid: &str, ) -> String { - let text = utils::ass_escape(&comment.comment); + let text = utils::ass_escape(&comment.content); + let comment_data = comment + .data + .as_normal() + .expect("comment_data is not normal"); let (style, duration) = match comment.pos { CommentPosition::Bottom => { let halfwidth = width / 2; @@ -66,14 +69,14 @@ pub fn write_comment( (format!("\\an2\\pos({halfwidth}, {row})"), duration_still) } CommentPosition::Reversed => { - let neglen = -(comment.width.ceil()) as i32; + let neglen = -(comment_data.width.ceil()) as i32; ( format!("\\move({neglen}, {row}, {width}, {row})"), duration_marquee, ) } _ => { - let neglen = -(comment.width.ceil()) as i32; + let neglen = -(comment_data.width.ceil()) as i32; ( format!("\\move({width}, {row}, {neglen}, {row})"), duration_marquee, @@ -113,7 +116,11 @@ pub fn write_normal_comment<'a>( reduced: bool, ) -> String { let mut row: usize = 0; - let rowmax = height - bottom_reserved - comment.height as u32; + let comment_data = comment + .data + .as_normal() + .expect("comment_data is not normal"); + let rowmax = height - bottom_reserved - comment_data.height as u32; while row <= rowmax as usize { let freerows = rows::test_free_rows( rows, @@ -125,7 +132,7 @@ pub fn write_normal_comment<'a>( duration_marquee, duration_still, ); - if freerows >= comment.height as usize { + if freerows >= comment_data.height as usize { rows::mark_comment_row(rows, comment, row); return write_comment( comment, @@ -288,29 +295,14 @@ pub fn write_comment_with_animation( format!("Dialogue: -1,{start},{end},{styleid},,0,0,0,,{{{styles}}}{text}\n") } -pub fn write_special_comment(comment: &Comment, width: u32, height: u32, styleid: &str) -> String { - let zoom_factor = - utils::get_zoom_factor(crate::reader::special::BILI_PLAYER_SIZE, (width, height)); - let parsed_res = parse_special_comment(&comment.comment, zoom_factor); - if parsed_res.is_err() { - warn!("Invalid comment: {}", comment.comment); - return "".to_owned(); - } - let ( - (rotate_y, rotate_z, from_x, from_y, to_x, to_y), - from_alpha, - to_alpha, - text, - delay, - lifetime, - duration, - fontface, - is_border, - ) = parsed_res.unwrap(); - write_comment_with_animation( - comment, - width, - height, +pub fn write_special_comment( + comment: &Comment, + width: u32, + height: u32, + zoom_factor: (f32, f32, f32), + styleid: &str, +) -> String { + let SpecialCommentData { rotate_y, rotate_z, from_x, @@ -319,12 +311,30 @@ pub fn write_special_comment(comment: &Comment, width: u32, height: u32, styleid to_y, from_alpha, to_alpha, - &text, delay, lifetime, duration, - &fontface, + fontface, is_border, + } = comment.data.as_special().expect("comment is not special"); + write_comment_with_animation( + comment, + width, + height, + *rotate_y, + *rotate_z, + *from_x, + *from_y, + *to_x, + *to_y, + *from_alpha, + *to_alpha, + &comment.content, + *delay, + *lifetime, + *duration, + fontface, + *is_border, styleid, zoom_factor, ) diff --git a/packages/biliass/rust/src/writer/rows.rs b/packages/biliass/rust/src/writer/rows.rs index 88e8b5fa3..610aa6eb1 100644 --- a/packages/biliass/rust/src/writer/rows.rs +++ b/packages/biliass/rust/src/writer/rows.rs @@ -29,9 +29,13 @@ pub fn test_free_rows( let rowmax = (height - bottom_reserved) as usize; let mut target_row = None; let comment_pos_id = comment.pos.clone() as usize; + let comment_data = comment + .data + .as_normal() + .expect("comment_data is not normal"); if comment.pos == CommentPosition::Bottom || comment.pos == CommentPosition::Top { let mut current_row = row; - while current_row < rowmax && (res as f32) < comment.height { + while current_row < rowmax && (res as f32) < comment_data.height { if target_row != rows[comment_pos_id][current_row] { target_row = rows[comment_pos_id][current_row]; if let Some(target_row) = target_row { @@ -45,16 +49,20 @@ pub fn test_free_rows( } } else { let threshold_time: f64 = comment.timeline - - duration_marquee * (1.0 - width as f64 / (comment.width as f64 + width as f64)); + - duration_marquee * (1.0 - width as f64 / (comment_data.width as f64 + width as f64)); let mut current_row = row; - while current_row < rowmax && (res as f32) < comment.height { + while current_row < rowmax && (res as f32) < comment_data.height { if target_row != rows[comment_pos_id][current_row] { target_row = rows[comment_pos_id][current_row]; if let Some(target_row) = target_row { + let target_row_data = target_row + .data + .as_normal() + .expect("target_row_data is not normal"); if target_row.timeline > threshold_time || target_row.timeline - + target_row.width as f64 * duration_marquee - / (target_row.width as f64 + width as f64) + + target_row_data.width as f64 * duration_marquee + / (target_row_data.width as f64 + width as f64) > comment.timeline { break; @@ -76,7 +84,12 @@ pub fn find_alternative_row( ) -> usize { let mut res = 0; let comment_pos_id = comment.pos.clone() as usize; - for row in 0..(height as usize - bottom_reserved as usize - comment.height.ceil() as usize) { + let comment_data = comment + .data + .as_normal() + .expect("comment_data is not normal"); + for row in 0..(height as usize - bottom_reserved as usize - comment_data.height.ceil() as usize) + { match &rows[comment_pos_id][row] { None => return row, Some(comment) => { @@ -92,7 +105,11 @@ pub fn find_alternative_row( pub fn mark_comment_row<'a>(rows: &mut Rows<'a>, comment: &'a Comment, row: usize) { let comment_pos_id = comment.pos.clone() as usize; - for i in row..(row + comment.height.ceil() as usize) { + let comment_data = comment + .data + .as_normal() + .expect("comment_data is not normal"); + for i in row..(row + comment_data.height.ceil() as usize) { if i >= rows[comment_pos_id].len() { break; } diff --git a/packages/biliass/src/biliass/__init__.py b/packages/biliass/src/biliass/__init__.py index 1d69c7e5a..d907f144c 100644 --- a/packages/biliass/src/biliass/__init__.py +++ b/packages/biliass/src/biliass/__init__.py @@ -6,6 +6,6 @@ ) from .biliass import ( - Danmaku2ASS as Danmaku2ASS, + BlockOptions as BlockOptions, convert_to_ass as convert_to_ass, ) diff --git a/packages/biliass/src/biliass/__main__.py b/packages/biliass/src/biliass/__main__.py index fda8f7d7f..ed4009ed9 100644 --- a/packages/biliass/src/biliass/__main__.py +++ b/packages/biliass/src/biliass/__main__.py @@ -5,6 +5,7 @@ from biliass import convert_to_ass from biliass.__version__ import VERSION as biliass_version +from biliass._core import BlockOptions def main(): @@ -52,25 +53,28 @@ def main(): type=float, default=5.0, ) - parser.add_argument("-fl", "--filter", help="Regular expression to filter comments") + parser.add_argument("--block-top", action="store_true", help="Block top comments") + parser.add_argument("--block-bottom", action="store_true", help="Block bottom comments") + parser.add_argument("--block-scroll", action="store_true", help="Block scrolling comments") + parser.add_argument("--block-reverse", action="store_true", help="Block reverse comments") + parser.add_argument("--block-fixed", action="store_true", help="Block fixed comments (top, bottom)") + parser.add_argument("--block-special", action="store_true", help="Block special comments") + parser.add_argument("--block-colorful", action="store_true", help="Block colorful comments") parser.add_argument( - "-flf", - "--filter-file", - help="Regular expressions from file (one line one regex) to filter comments", + "--block-keyword-patterns", + default=None, + help="Block comments that match the keyword pattern, separated by commas", ) parser.add_argument( - "-p", - "--protect", - metavar="HEIGHT", - help="Reserve blank on the bottom of the stage", - type=int, - default=0, + "--display-region-ratio", + help="Ratio of the display region to the stage height [default: 1.0]", + type=float, + default=1.0, ) parser.add_argument( - "-r", - "--reduce", + "--skip-reduce", action="store_true", - help="Reduce the amount of comments if stage is full", + help="Do not reduce the amount of comments if stage is full", ) parser.add_argument( "-f", @@ -106,18 +110,34 @@ def main(): width, height, args.format, - args.protect, + args.display_region_ratio, args.font, args.fontsize, args.alpha, args.duration_marquee, args.duration_still, - args.filter, - args.reduce, + parse_block_options(args), + not args.skip_reduce, ) fout.write(output) fout.close() +def parse_block_options(args: argparse.Namespace) -> BlockOptions: + return BlockOptions( + block_top=args.block_top or args.block_fixed, + block_bottom=args.block_bottom or args.block_fixed, + block_scroll=args.block_scroll, + block_reverse=args.block_reverse, + block_special=args.block_special, + block_colorful=args.block_colorful, + block_keyword_patterns=( + [pattern.strip() for pattern in args.block_keyword_patterns.split(",")] + if args.block_keyword_patterns + else [] + ), + ) + + if __name__ == "__main__": main() diff --git a/packages/biliass/src/biliass/__version__.py b/packages/biliass/src/biliass/__version__.py index dacec3f74..2992328f6 100644 --- a/packages/biliass/src/biliass/__version__.py +++ b/packages/biliass/src/biliass/__version__.py @@ -1,3 +1,3 @@ from __future__ import annotations -VERSION = "2.0.0" +VERSION = "2.2.0" diff --git a/packages/biliass/src/biliass/_core.pyi b/packages/biliass/src/biliass/_core.pyi index d0ced1271..7cd120989 100644 --- a/packages/biliass/src/biliass/_core.pyi +++ b/packages/biliass/src/biliass/_core.pyi @@ -1,28 +1,40 @@ +class ConversionOptions: + def __init__( + self, + stage_width: int, + stage_height: int, + display_region_ratio: float, + font_face: str, + font_size: float, + text_opacity: float, + duration_marquee: float, + duration_still: float, + is_reduce_comments: bool, + ) -> None: ... + +class BlockOptions: + def __init__( + self, + block_top: bool, + block_bottom: bool, + block_scroll: bool, + block_reverse: bool, + block_special: bool, + block_colorful: bool, + block_keyword_patterns: list[str], + ) -> None: ... + @staticmethod + def default() -> BlockOptions: ... + def xml_to_ass( inputs: list[str], - stage_width: int, - stage_height: int, - reserve_blank: int, - font_face: str, - font_size: float, - text_opacity: float, - duration_marquee: float, - duration_still: float, - comment_filter: list[str], - is_reduce_comments: bool, + conversion_options: ConversionOptions, + block_options: BlockOptions, ) -> str: ... def protobuf_to_ass( inputs: list[bytes], - stage_width: int, - stage_height: int, - reserve_blank: int, - font_face: str, - font_size: float, - text_opacity: float, - duration_marquee: float, - duration_still: float, - comment_filter: list[str], - is_reduce_comments: bool, + conversion_options: ConversionOptions, + block_options: BlockOptions, ) -> str: ... def get_danmaku_meta_size(buffer: bytes) -> int: ... def enable_tracing() -> None: ... diff --git a/packages/biliass/src/biliass/biliass.py b/packages/biliass/src/biliass/biliass.py index 9b48cc94e..16b41b68d 100755 --- a/packages/biliass/src/biliass/biliass.py +++ b/packages/biliass/src/biliass/biliass.py @@ -3,82 +3,53 @@ from typing import TYPE_CHECKING, TypeVar, cast from biliass._core import ( + BlockOptions, + ConversionOptions, protobuf_to_ass, xml_to_ass, ) if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Sequence T = TypeVar("T") -def Danmaku2ASS( - inputs: list[str | bytes] | str | bytes, +def convert_to_ass( + inputs: Sequence[str | bytes] | str | bytes, stage_width: int, stage_height: int, input_format: str = "xml", - reserve_blank: int = 0, + display_region_ratio: float = 1.0, font_face: str = "sans-serif", font_size: float = 25.0, text_opacity: float = 1.0, duration_marquee: float = 5.0, duration_still: float = 5.0, - comment_filter: str | None = None, - is_reduce_comments: bool = False, - progress_callback: Callable[[int, int], None] | None = None, + block_options: BlockOptions | None = None, + reduce_comments: bool = True, ) -> str: - print("Function `Danmaku2ASS` is deprecated in biliass 2.0.0, Please use `convert_to_ass` instead.") - if progress_callback is not None: - print("`progress_callback` is deprecated in 2.0.0 and will be removed in 2.1.0") - return convert_to_ass( - inputs, + if isinstance(inputs, (str, bytes)): + inputs = [inputs] + conversion_options = ConversionOptions( stage_width, stage_height, - input_format, - reserve_blank, + display_region_ratio, font_face, font_size, text_opacity, duration_marquee, duration_still, - comment_filter, - is_reduce_comments, + reduce_comments, ) - - -def convert_to_ass( - inputs: list[str | bytes] | str | bytes, - stage_width: int, - stage_height: int, - input_format: str = "xml", - reserve_blank: int = 0, - font_face: str = "sans-serif", - font_size: float = 25.0, - text_opacity: float = 1.0, - duration_marquee: float = 5.0, - duration_still: float = 5.0, - comment_filter: str | None = None, - is_reduce_comments: bool = False, -) -> str: - comment_filters: list[str] = [comment_filter] if comment_filter is not None else [] - if not isinstance(inputs, list): - inputs = [inputs] + block_options = block_options or BlockOptions.default() if input_format == "xml": inputs = [text if isinstance(text, str) else text.decode() for text in inputs] return xml_to_ass( - cast(list[str], inputs), - stage_width, - stage_height, - reserve_blank, - font_face, - font_size, - text_opacity, - duration_marquee, - duration_still, - comment_filters, - is_reduce_comments, + inputs, + conversion_options, + block_options, ) elif input_format == "protobuf": for input in inputs: @@ -86,16 +57,8 @@ def convert_to_ass( raise ValueError("Protobuf can only be read from bytes") return protobuf_to_ass( cast(list[bytes], inputs), - stage_width, - stage_height, - reserve_blank, - font_face, - font_size, - text_opacity, - duration_marquee, - duration_still, - comment_filters, - is_reduce_comments, + conversion_options, + block_options, ) else: raise TypeError(f"Invalid input format {input_format}") diff --git a/pyproject.toml b/pyproject.toml index c9aaadc1e..8c609e905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yutto" -version = "2.0.0-rc.1" +version = "2.0.0" description = "🧊 一个可爱且任性的 B 站视频下载器" readme = "README.md" requires-python = ">=3.9" @@ -23,11 +23,13 @@ classifiers = [ ] dependencies = [ "aiofiles>=24.1.0", - "biliass==2.0.0", + "biliass==2.2.0", "colorama>=0.4.6; sys_platform == 'win32'", "typing-extensions>=4.12.2", "dict2xml>=1.7.6", - "httpx[http2,socks]>=0.27.0", + "httpx[http2,socks]>=0.28.1", + "tomli>=2.0.2; python_version < '3.11'", + "pydantic>=2.10.6", ] [project.urls] @@ -39,14 +41,15 @@ Issues = "https://github.com/yutto-dev/yutto/issues" [project.scripts] yutto = "yutto.__main__:main" -[tool.uv] -dev-dependencies = [ - "pytest>=8.3.2", - "pyright>=1.1.381", - "pytest-rerunfailures>=14.0", - "ruff>=0.6.7", - "typos>=1.24.6", - "syrupy>=4.7.1", +[dependency-groups] +dev = [ + "pyright>=1.1.393", + "ruff>=0.9.4", + "typos>=1.29.5", + "pytest>=8.3.4", + "pytest-rerunfailures>=15.0", + "syrupy>=4.8.1", + "pytest-codspeed>=3.1.2", ] [tool.uv.sources] @@ -59,14 +62,13 @@ members = ["packages/*"] markers = ["api", "e2e", "processor", "biliass", "ignore", "ci_skip", "ci_only"] [tool.pyright] -include = ["yutto", "tests"] +include = ["src/yutto", "packages/biliass/src/biliass", "tests"] pythonVersion = "3.9" typeCheckingMode = "strict" [tool.ruff] line-length = 120 target-version = "py39" -exclude = ["*_pb2.py", "*_pb2.pyi"] [tool.ruff.lint] select = [ @@ -96,7 +98,7 @@ select = [ # Pygrep-hooks "PGH004", # Flake8-type-checking - "TCH", + "TC", # Flake8-raise "RSE", # Refurb @@ -117,6 +119,9 @@ required-imports = ["from __future__ import annotations"] known-first-party = ["yutto"] combine-as-imports = true +[tool.ruff.lint.flake8-type-checking] +runtime-evaluated-base-classes = ["pydantic.BaseModel"] + [tool.ruff.lint.per-file-ignores] "setup.py" = ["I"] diff --git a/schemas/config.json b/schemas/config.json new file mode 100644 index 000000000..b477ecbab --- /dev/null +++ b/schemas/config.json @@ -0,0 +1,441 @@ +{ + "$defs": { + "YuttoBasicSettings": { + "properties": { + "num_workers": { + "default": 8, + "exclusiveMinimum": 0, + "title": "Num Workers", + "type": "integer" + }, + "video_quality": { + "default": 127, + "enum": [ + 127, + 126, + 125, + 120, + 116, + 112, + 100, + 80, + 74, + 64, + 32, + 16 + ], + "title": "Video Quality", + "type": "integer" + }, + "audio_quality": { + "default": 30251, + "enum": [ + 30251, + 30255, + 30250, + 30280, + 30232, + 30216 + ], + "title": "Audio Quality", + "type": "integer" + }, + "vcodec": { + "default": "avc:copy", + "title": "Vcodec", + "type": "string" + }, + "acodec": { + "default": "mp4a:copy", + "title": "Acodec", + "type": "string" + }, + "download_vcodec_priority": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Download Vcodec Priority" + }, + "output_format": { + "default": "infer", + "enum": [ + "infer", + "mp4", + "mkv", + "mov" + ], + "title": "Output Format", + "type": "string" + }, + "output_format_audio_only": { + "default": "infer", + "enum": [ + "infer", + "m4a", + "aac", + "mp3", + "flac", + "mp4", + "mkv", + "mov" + ], + "title": "Output Format Audio Only", + "type": "string" + }, + "danmaku_format": { + "default": "ass", + "enum": [ + "xml", + "ass", + "protobuf" + ], + "title": "Danmaku Format", + "type": "string" + }, + "block_size": { + "default": 0.5, + "title": "Block Size", + "type": "number" + }, + "overwrite": { + "default": false, + "title": "Overwrite", + "type": "boolean" + }, + "proxy": { + "default": "auto", + "title": "Proxy", + "type": "string" + }, + "dir": { + "default": "./", + "title": "Dir", + "type": "string" + }, + "tmp_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tmp Dir" + }, + "sessdata": { + "default": "", + "title": "Sessdata", + "type": "string" + }, + "subpath_template": { + "default": "{auto}", + "title": "Subpath Template", + "type": "string" + }, + "aliases": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "title": "Aliases", + "type": "object" + }, + "metadata_format_premiered": { + "default": "%Y-%m-%d", + "title": "Metadata Format Premiered", + "type": "string" + }, + "download_interval": { + "default": 0, + "title": "Download Interval", + "type": "integer" + }, + "banned_mirrors_pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Banned Mirrors Pattern" + }, + "no_color": { + "default": false, + "title": "No Color", + "type": "boolean" + }, + "no_progress": { + "default": false, + "title": "No Progress", + "type": "boolean" + }, + "debug": { + "default": false, + "title": "Debug", + "type": "boolean" + }, + "vip_strict": { + "default": false, + "title": "Vip Strict", + "type": "boolean" + }, + "login_strict": { + "default": false, + "title": "Login Strict", + "type": "boolean" + } + }, + "title": "YuttoBasicSettings", + "type": "object" + }, + "YuttoBatchSettings": { + "properties": { + "with_section": { + "default": false, + "title": "With Section", + "type": "boolean" + }, + "batch_filter_start_time": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Batch Filter Start Time" + }, + "batch_filter_end_time": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Batch Filter End Time" + } + }, + "title": "YuttoBatchSettings", + "type": "object" + }, + "YuttoDanmakuSettings": { + "properties": { + "font_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Font Size" + }, + "font": { + "default": "SimHei", + "title": "Font", + "type": "string" + }, + "opacity": { + "default": 0.8, + "title": "Opacity", + "type": "number" + }, + "display_region_ratio": { + "default": 1.0, + "title": "Display Region Ratio", + "type": "number" + }, + "speed": { + "default": 1.0, + "title": "Speed", + "type": "number" + }, + "block_top": { + "default": false, + "title": "Block Top", + "type": "boolean" + }, + "block_bottom": { + "default": false, + "title": "Block Bottom", + "type": "boolean" + }, + "block_scroll": { + "default": false, + "title": "Block Scroll", + "type": "boolean" + }, + "block_reverse": { + "default": false, + "title": "Block Reverse", + "type": "boolean" + }, + "block_fixed": { + "default": false, + "title": "Block Fixed", + "type": "boolean" + }, + "block_special": { + "default": false, + "title": "Block Special", + "type": "boolean" + }, + "block_colorful": { + "default": false, + "title": "Block Colorful", + "type": "boolean" + }, + "block_keyword_patterns": { + "default": [], + "items": { + "type": "string" + }, + "title": "Block Keyword Patterns", + "type": "array" + } + }, + "title": "YuttoDanmakuSettings", + "type": "object" + }, + "YuttoResourceSettings": { + "properties": { + "require_video": { + "default": true, + "title": "Require Video", + "type": "boolean" + }, + "require_audio": { + "default": true, + "title": "Require Audio", + "type": "boolean" + }, + "require_subtitle": { + "default": true, + "title": "Require Subtitle", + "type": "boolean" + }, + "require_metadata": { + "default": false, + "title": "Require Metadata", + "type": "boolean" + }, + "require_danmaku": { + "default": true, + "title": "Require Danmaku", + "type": "boolean" + }, + "require_cover": { + "default": true, + "title": "Require Cover", + "type": "boolean" + }, + "require_chapter_info": { + "default": true, + "title": "Require Chapter Info", + "type": "boolean" + }, + "save_cover": { + "default": false, + "title": "Save Cover", + "type": "boolean" + } + }, + "title": "YuttoResourceSettings", + "type": "object" + } + }, + "properties": { + "basic": { + "$ref": "#/$defs/YuttoBasicSettings", + "default": { + "num_workers": 8, + "video_quality": 127, + "audio_quality": 30251, + "vcodec": "avc:copy", + "acodec": "mp4a:copy", + "download_vcodec_priority": null, + "output_format": "infer", + "output_format_audio_only": "infer", + "danmaku_format": "ass", + "block_size": 0.5, + "overwrite": false, + "proxy": "auto", + "dir": "./", + "tmp_dir": null, + "sessdata": "", + "subpath_template": "{auto}", + "aliases": {}, + "metadata_format_premiered": "%Y-%m-%d", + "download_interval": 0, + "banned_mirrors_pattern": null, + "no_color": false, + "no_progress": false, + "debug": false, + "vip_strict": false, + "login_strict": false + } + }, + "resource": { + "$ref": "#/$defs/YuttoResourceSettings", + "default": { + "require_video": true, + "require_audio": true, + "require_subtitle": true, + "require_metadata": false, + "require_danmaku": true, + "require_cover": true, + "require_chapter_info": true, + "save_cover": false + } + }, + "danmaku": { + "$ref": "#/$defs/YuttoDanmakuSettings", + "default": { + "font_size": null, + "font": "SimHei", + "opacity": 0.8, + "display_region_ratio": 1.0, + "speed": 1.0, + "block_top": false, + "block_bottom": false, + "block_scroll": false, + "block_reverse": false, + "block_fixed": false, + "block_special": false, + "block_colorful": false, + "block_keyword_patterns": [] + } + }, + "batch": { + "$ref": "#/$defs/YuttoBatchSettings", + "default": { + "with_section": false, + "batch_filter_start_time": null, + "batch_filter_end_time": null + } + } + }, + "title": "YuttoSettings", + "type": "object" +} \ No newline at end of file diff --git a/scripts/generate-schema.py b/scripts/generate-schema.py new file mode 100644 index 000000000..504c92754 --- /dev/null +++ b/scripts/generate-schema.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from yutto.cli.settings import YuttoSettings + + +def main(): + schema = YuttoSettings.model_json_schema() + with Path("schemas/config.json").open("w") as f: + json.dump(schema, f, indent=2) + + +if __name__ == "__main__": + main() diff --git a/src/yutto/__main__.py b/src/yutto/__main__.py index 371a5de92..eb71f2796 100644 --- a/src/yutto/__main__.py +++ b/src/yutto/__main__.py @@ -1,21 +1,17 @@ from __future__ import annotations -import argparse import asyncio import copy import os import re +import shlex import sys -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Callable import httpx -from typing_extensions import TypeAlias +from biliass import BlockOptions -from yutto.__version__ import VERSION as yutto_version -from yutto.bilibili_typing.quality import ( - audio_quality_priority_default, - video_quality_priority_default, -) +from yutto.cli.cli import cli from yutto.exceptions import ErrorCode from yutto.extractor import ( BangumiBatchExtractor, @@ -32,13 +28,14 @@ UserWatchLaterExtractor, ) from yutto.processor.downloader import DownloadState, start_downloader -from yutto.processor.parser import alias_parser, file_scheme_parser +from yutto.processor.parser import file_scheme_parser from yutto.processor.path_resolver import create_unique_path_resolver from yutto.utils.asynclib import sleep_with_status_bar_refresh from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher, create_client +from yutto.utils.danmaku import DanmakuOptions +from yutto.utils.fetcher import Fetcher, FetcherContext, create_client from yutto.utils.funcutils import as_sync -from yutto.utils.time import TIME_DATE_FMT, TIME_FULL_FMT +from yutto.utils.time import TIME_FULL_FMT from yutto.validator import ( initial_validation, validate_basic_arguments, @@ -47,216 +44,39 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence + import argparse from yutto._typing import EpisodeData -DownloadResourceType: TypeAlias = Literal["video", "audio", "subtitle", "metadata", "danmaku", "cover", "chapter_info"] -DOWNLOAD_RESOURCE_TYPES: list[DownloadResourceType] = [ - "video", - "audio", - "subtitle", - "metadata", - "danmaku", - "cover", - "chapter_info", -] - def main(): parser = cli() args = parser.parse_args() - initial_validation(args) + ctx = FetcherContext() + initial_validation(ctx, args) args_list = flatten_args(args, parser) try: - run(args_list) + run(ctx, args_list) except (SystemExit, KeyboardInterrupt, asyncio.exceptions.CancelledError): Logger.info("已终止下载,再次运行即可继续下载~") sys.exit(ErrorCode.PAUSED_DOWNLOAD.value) -def cli() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="yutto 一个可爱且任性的 B 站视频下载器", prog="yutto") - parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {yutto_version}") - # 如果需要创建其他子命令可参考 - # https://stackoverflow.com/questions/29998417/create-parser-with-subcommands-in-argparse-customize-positional-arguments - parser.add_argument("url", help="视频主页 url 或 url 列表(需使用 file scheme)") - group_common = parser.add_argument_group("common", "通用参数") - group_common.add_argument("-n", "--num-workers", type=int, default=8, help="同时用于下载的最大 Worker 数") - group_common.add_argument( - "-q", - "--video-quality", - default=127, - choices=video_quality_priority_default, - type=int, - help="视频清晰度等级(127:8K, 126:Dolby Vision, 125:HDR, 120:4K, 116:1080P60, 112:1080P+, 100:智能修复, 80:1080P, 74:720P60, 64:720P, 32:480P, 16:360P)", - ) - group_common.add_argument( - "-aq", - "--audio-quality", - default=30251, - choices=audio_quality_priority_default, - type=int, - help="音频码率等级(30251:Hi-Res, 30255:Dolby Audio, 30250:Dolby Atmos, 30280:320kbps, 30232:128kbps, 30216:64kbps)", - ) - group_common.add_argument( - "--vcodec", - default="avc:copy", - metavar="DOWNLOAD_VCODEC:SAVE_VCODEC", - help="视频编码格式(<下载格式>:<生成格式>)", - ) - group_common.add_argument( - "--acodec", - default="mp4a:copy", - metavar="DOWNLOAD_ACODEC:SAVE_ACODEC", - help="音频编码格式(<下载格式>:<生成格式>)", - ) - group_common.add_argument( - "--download-vcodec-priority", - default="auto", - help="视频编码格式优先级,使用 `,` 分隔,如 `hevc,avc,av1`,默认为 `auto`,即根据 vcodec 中「下载编码」自动推断", - ) - group_common.add_argument( - "--output-format", default="infer", choices=["infer", "mp4", "mkv", "mov"], help="输出格式(infer 为自动推断)" - ) - group_common.add_argument( - "--output-format-audio-only", - default="infer", - choices=["infer", "aac", "mp3", "flac", "mp4", "mkv", "mov"], - help="仅包含音频流时所使用的输出格式(infer 为自动推断)", - ) - group_common.add_argument( - "-df", "--danmaku-format", default="ass", choices=["xml", "ass", "protobuf"], help="弹幕类型" - ) - group_common.add_argument( - "-bs", "--block-size", default=0.5, type=float, help="分块下载时各块大小,单位为 MiB,默认为 0.5MiB" - ) - group_common.add_argument("-w", "--overwrite", action="store_true", help="强制覆盖已下载内容") - group_common.add_argument( - "-x", "--proxy", default="auto", help="设置代理(auto 为系统代理、no 为不使用代理、当然也可以设置代理值)" - ) - group_common.add_argument("-d", "--dir", default="./", help="下载目录,默认为运行目录") - group_common.add_argument("--tmp-dir", help="用来存放下载过程中临时文件的目录,默认为下载目录") - group_common.add_argument("-c", "--sessdata", default="", help="Cookies 中的 SESSDATA 字段") - group_common.add_argument("-tp", "--subpath-template", default="{auto}", help="多级目录的存储路径模板") - group_common.add_argument( - "-af", "--alias-file", type=argparse.FileType("r", encoding="utf-8"), help="设置 url 别名文件路径" - ) - group_common.add_argument( - "--metadata-format-premiered", default=TIME_DATE_FMT, help="专用于 metadata 文件中 premiered 字段的日期格式" - ) - group_common.add_argument("--download-interval", default=0, type=int, help="设置下载间隔,单位为秒") - group_common.add_argument("--banned-mirrors-pattern", default=None, help="禁用下载链接的镜像源,使用正则匹配") - - # 资源选择 - group_common.add_argument( - "--video-only", - dest="require_audio", - action=create_select_required_action(deselect=["audio"]), - help="仅下载视频流", - ) - group_common.add_argument( - "--audio-only", - dest="require_video", - action=create_select_required_action(deselect=["video"]), - help="仅下载音频流", - ) # 视频和音频是反选对方,而不是其余反选所有的 - group_common.add_argument( - "--no-danmaku", - dest="require_danmaku", - action=create_select_required_action(deselect=["danmaku"]), - help="不生成弹幕文件", - ) - group_common.add_argument( - "--danmaku-only", - dest="require_danmaku", - action=create_select_required_action(select=["danmaku"], deselect=invert_selection(["danmaku"])), - help="仅生成弹幕文件", - ) - group_common.add_argument( - "--no-subtitle", - dest="require_subtitle", - action=create_select_required_action(deselect=["subtitle"]), - help="不生成字幕文件", - ) - group_common.add_argument( - "--subtitle-only", - dest="require_subtitle", - action=create_select_required_action(select=["subtitle"], deselect=invert_selection(["subtitle"])), - help="仅生成字幕文件", - ) - group_common.add_argument( - "--with-metadata", - dest="require_metadata", - action=create_select_required_action(select=["metadata"]), - help="生成元数据文件", - ) - group_common.add_argument( - "--metadata-only", - dest="require_metadata", - action=create_select_required_action(select=["metadata"], deselect=invert_selection(["metadata"])), - help="仅生成元数据文件", - ) - group_common.add_argument( - "--no-cover", - dest="require_cover", - action=create_select_required_action(deselect=["cover"]), - help="不生成封面", - ) - - group_common.add_argument( - "--no-chapter-info", - dest="require_chapter_info", - action=create_select_required_action(deselect=["chapter_info"]), - help="不封装章节信息", - ) - - group_common.set_defaults( - require_video=True, - require_audio=True, - require_subtitle=True, - require_metadata=False, - require_danmaku=True, - require_cover=True, - require_chapter_info=True, - ) - group_common.add_argument("--no-color", action="store_true", help="不使用颜色") - group_common.add_argument("--no-progress", action="store_true", help="不显示进度条") - group_common.add_argument("--debug", action="store_true", help="启用 debug 模式") - group_common.add_argument("--vip-strict", action="store_true", help="启用严格检查大会员生效") - group_common.add_argument("--login-strict", action="store_true", help="启用严格检查登录状态") - - # 仅批量下载使用 - group_batch = parser.add_argument_group("batch", "批量下载参数") - group_batch.add_argument("-b", "--batch", action="store_true", help="批量下载") - group_batch.add_argument("-p", "--episodes", default="1~-1", help="选集") - group_batch.add_argument( - "-s", "--with-section", action="store_true", help="同时下载附加剧集(PV、预告以及特别篇等专区内容)" - ) - group_batch.add_argument("--batch-filter-start-time", help="只下载该时间之后(包含临界值)发布的稿件") - group_batch.add_argument("--batch-filter-end-time", help="只下载该时间之前(不包含临界值)发布的稿件") - - # 仅任务列表中使用 - group_batch_file = parser.add_argument_group("batch file", "批量下载文件参数") - group_batch_file.add_argument("--no-inherit", action="store_true", help="不继承父级参数") - - return parser - - @as_sync -async def run(args_list: list[argparse.Namespace]): +async def run(ctx: FetcherContext, args_list: list[argparse.Namespace]): + ctx.set_fetch_semaphore(fetch_workers=8) unique_path = create_unique_path_resolver() async with create_client( - cookies=Fetcher.cookies, - trust_env=Fetcher.trust_env, - proxy=Fetcher.proxy, + cookies=ctx.cookies, + trust_env=ctx.trust_env, + proxy=ctx.proxy, ) as client: if len(args_list) > 1: Logger.info(f"列表里共检测到 {len(args_list)} 项") for i, args in enumerate(args_list): if len(args_list) > 1: - Logger.custom(f"列表项 {args.url}", Badge(f"[{i+1}/{len(args_list)}]", fore="black", back="cyan")) + Logger.custom(f"列表项 {args.url}", Badge(f"[{i + 1}/{len(args_list)}]", fore="black", back="cyan")) # 验证批量参数 if args.batch: @@ -290,12 +110,12 @@ async def run(args_list: list[argparse.Namespace]): break # 在开始前校验,减少对第一个视频的请求 - if not await validate_user_info({"is_login": args.login_strict, "vip_status": args.vip_strict}): + if not await validate_user_info(ctx, {"is_login": args.login_strict, "vip_status": args.vip_strict}): Logger.error("启用了严格校验大会员或登录模式,请检查 SESSDATA 或大会员状态!") sys.exit(ErrorCode.NOT_LOGIN_ERROR.value) # 重定向到可识别的 url try: - url = await Fetcher.get_redirected_url(client, url) + url = await Fetcher.get_redirected_url(ctx, client, url) except httpx.InvalidURL: Logger.error(f"无效的 url({url})~请检查一下链接是否正确~") sys.exit(ErrorCode.WRONG_URL_ERROR.value) @@ -311,7 +131,7 @@ async def run(args_list: list[argparse.Namespace]): # 提取信息,构造解析任务~ for extractor in extractors: if extractor.match(url): - download_list = await extractor(client, args) + download_list = await extractor(ctx, client, args) break else: if args.batch: @@ -329,7 +149,7 @@ async def run(args_list: list[argparse.Namespace]): continue # 中途校验,因为批量下载时可能会失效 - if not await validate_user_info({"is_login": args.login_strict, "vip_status": args.vip_strict}): + if not await validate_user_info(ctx, {"is_login": args.login_strict, "vip_status": args.vip_strict}): Logger.error("启用了严格校验大会员或登录模式,请检查 SESSDATA 或大会员状态!") sys.exit(ErrorCode.NOT_LOGIN_ERROR.value) @@ -346,10 +166,11 @@ async def run(args_list: list[argparse.Namespace]): if args.batch: Logger.custom( f"{episode_data['filename']}", - Badge(f"[{i+1}/{len(download_list)}]", fore="black", back="cyan"), + Badge(f"[{i + 1}/{len(download_list)}]", fore="black", back="cyan"), ) current_download_state = await start_downloader( + ctx, client, episode_data, { @@ -358,11 +179,7 @@ async def run(args_list: list[argparse.Namespace]): "video_quality": args.video_quality, "video_download_codec": args.vcodec.split(":")[0], "video_save_codec": args.vcodec.split(":")[1], - "video_download_codec_priority": ( - args.download_vcodec_priority.split(",") - if args.download_vcodec_priority != "auto" - else None - ), + "video_download_codec_priority": args.download_vcodec_priority, "require_audio": args.require_audio, "audio_quality": args.audio_quality, "audio_download_codec": args.acodec.split(":")[0], @@ -372,11 +189,13 @@ async def run(args_list: list[argparse.Namespace]): "overwrite": args.overwrite, "block_size": int(args.block_size * 1024 * 1024), "num_workers": args.num_workers, + "save_cover": args.save_cover, "metadata_format": { "premiered": args.metadata_format_premiered, "dateadded": TIME_FULL_FMT, }, "banned_mirrors_pattern": args.banned_mirrors_pattern, + "danmaku_options": parse_danmaku_options(args), }, ) Logger.new_line() @@ -388,7 +207,7 @@ def flatten_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> l args = copy.copy(args) validate_basic_arguments(args) # 查看是否存在于 alias 中 - alias_map = alias_parser(args.alias_file) + alias_map: dict[str, str] = args.aliases if args.aliases is not None else {} if args.url in alias_map: args.url = alias_map[args.url] @@ -397,9 +216,9 @@ def flatten_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> l args_list: list[argparse.Namespace] = [] # TODO: 如果是相对路径,需要相对于当前 list 路径 for line in file_scheme_parser(args.url): - local_args = parser.parse_args(line.split(), args) + local_args = parser.parse_args(shlex.split(line), args) if local_args.no_inherit: - local_args = parser.parse_args(line.split()) + local_args = parser.parse_args(shlex.split(line)) Logger.debug(f"列表参数: {local_args}") args_list += flatten_args(local_args, parser) return args_list @@ -407,37 +226,6 @@ def flatten_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> l return [args] -def create_select_required_action( - select: list[DownloadResourceType] | None = None, deselect: list[DownloadResourceType] | None = None -): - selected_items = select or [] - deselected_items = deselect or [] - - class SelectRequiredAction(argparse.Action): - def __init__(self, option_strings: str, dest: str, nargs: int | str | None = None, **kwargs: Any): - if nargs is not None: - raise ValueError("nargs not allowed") - super().__init__(option_strings, dest, nargs=0, **kwargs) - - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: str | Sequence[str] | None, - option_string: str | None = None, - ): - for select_item in selected_items: - setattr(namespace, f"require_{select_item}", True) - for deselect_item in deselected_items: - setattr(namespace, f"require_{deselect_item}", False) - - return SelectRequiredAction - - -def invert_selection(select: list[DownloadResourceType]) -> list[DownloadResourceType]: - return [tp for tp in DOWNLOAD_RESOURCE_TYPES if tp not in select] - - def ensure_unique_path(episode_data: EpisodeData, unique_name_resolver: Callable[[str], str]) -> EpisodeData: original_filename = episode_data["filename"] new_name = unique_name_resolver(original_filename) @@ -447,5 +235,25 @@ def ensure_unique_path(episode_data: EpisodeData, unique_name_resolver: Callable return episode_data +def parse_danmaku_options(args: argparse.Namespace) -> DanmakuOptions: + block_options = BlockOptions( + block_top=args.danmaku_block_top or args.danmaku_block_fixed, + block_bottom=args.danmaku_block_bottom or args.danmaku_block_fixed, + block_scroll=args.danmaku_block_scroll, + block_reverse=args.danmaku_block_reverse, + block_special=args.danmaku_block_special, + block_colorful=args.danmaku_block_colorful, + block_keyword_patterns=(args.danmaku_block_keyword_patterns if args.danmaku_block_keyword_patterns else []), + ) + return DanmakuOptions( + font_size=args.danmaku_font_size, + font=args.danmaku_font, + opacity=args.danmaku_opacity, + display_region_ratio=args.danmaku_display_region_ratio, + speed=args.danmaku_speed, + block_options=block_options, + ) + + if __name__ == "__main__": main() diff --git a/src/yutto/__version__.py b/src/yutto/__version__.py index c80008a8b..adc8aafe0 100644 --- a/src/yutto/__version__.py +++ b/src/yutto/__version__.py @@ -1,4 +1,4 @@ # 发版需要同时改这里和 pyproject.toml from __future__ import annotations -VERSION = "2.0.0-rc.1" +VERSION = "2.0.0" diff --git a/src/yutto/_typing.py b/src/yutto/_typing.py index 9b4a81268..a4996d387 100644 --- a/src/yutto/_typing.py +++ b/src/yutto/_typing.py @@ -3,9 +3,11 @@ from typing import TYPE_CHECKING, NamedTuple, TypedDict if TYPE_CHECKING: + from pathlib import Path + from yutto.bilibili_typing.codec import AudioCodec, VideoCodec from yutto.bilibili_typing.quality import AudioQuality, VideoQuality - from yutto.utils.danmaku import DanmakuData + from yutto.utils.danmaku import DanmakuData, DanmakuOptions from yutto.utils.metadata import ChapterInfoData, MetaData from yutto.utils.subtitle import SubtitleData @@ -219,14 +221,15 @@ class EpisodeData(TypedDict): danmaku: DanmakuData cover_data: bytes | None chapter_info_data: list[ChapterInfoData] - output_dir: str - tmp_dir: str + output_dir: Path + tmp_dir: Path filename: str class DownloaderOptions(TypedDict): require_video: bool require_chapter_info: bool + save_cover: bool video_quality: VideoQuality video_download_codec: VideoCodec video_save_codec: str @@ -242,6 +245,7 @@ class DownloaderOptions(TypedDict): num_workers: int metadata_format: dict[str, str] banned_mirrors_pattern: str | None + danmaku_options: DanmakuOptions class FavouriteMetaData(TypedDict): diff --git a/src/yutto/api/bangumi.py b/src/yutto/api/bangumi.py index 1ebd9009c..fabef3254 100644 --- a/src/yutto/api/bangumi.py +++ b/src/yutto/api/bangumi.py @@ -18,7 +18,7 @@ from yutto.bilibili_typing.codec import audio_codec_map, video_codec_map from yutto.exceptions import NoAccessPermissionError, UnSupportedTypeError from yutto.utils.console.logger import Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.funcutils import data_has_chained_keys from yutto.utils.metadata import MetaData from yutto.utils.time import get_time_stamp_by_now @@ -43,23 +43,23 @@ class BangumiList(TypedDict): pages: list[BangumiListItem] -async def get_season_id_by_media_id(client: AsyncClient, media_id: MediaId) -> SeasonId: +async def get_season_id_by_media_id(ctx: FetcherContext, client: AsyncClient, media_id: MediaId) -> SeasonId: media_api = f"https://api.bilibili.com/pgc/review/user?media_id={media_id}" - res_json = await Fetcher.fetch_json(client, media_api) + res_json = await Fetcher.fetch_json(ctx, client, media_api) assert res_json is not None return SeasonId(str(res_json["result"]["media"]["season_id"])) -async def get_season_id_by_episode_id(client: AsyncClient, episode_id: EpisodeId) -> SeasonId: +async def get_season_id_by_episode_id(ctx: FetcherContext, client: AsyncClient, episode_id: EpisodeId) -> SeasonId: episode_api = f"https://api.bilibili.com/pgc/view/web/season?ep_id={episode_id}" - res_json = await Fetcher.fetch_json(client, episode_api) + res_json = await Fetcher.fetch_json(ctx, client, episode_api) assert res_json is not None return SeasonId(str(res_json["result"]["season_id"])) -async def get_bangumi_list(client: AsyncClient, season_id: SeasonId) -> BangumiList: +async def get_bangumi_list(ctx: FetcherContext, client: AsyncClient, season_id: SeasonId) -> BangumiList: list_api = "http://api.bilibili.com/pgc/view/web/season?season_id={season_id}" - resp_json = await Fetcher.fetch_json(client, list_api.format(season_id=season_id)) + resp_json = await Fetcher.fetch_json(ctx, client, list_api.format(season_id=season_id)) if resp_json is None: raise NoAccessPermissionError(f"无法解析该番剧列表(season_id: {season_id})") if resp_json.get("result") is None: @@ -90,11 +90,11 @@ async def get_bangumi_list(client: AsyncClient, season_id: SeasonId) -> BangumiL async def get_bangumi_playurl( - client: AsyncClient, avid: AvId, cid: CId + ctx: FetcherContext, client: AsyncClient, avid: AvId, cid: CId ) -> tuple[list[VideoUrlMeta], list[AudioUrlMeta]]: play_api = "https://api.bilibili.com/pgc/player/web/v2/playurl?avid={aid}&bvid={bvid}&cid={cid}&qn=127&fnver=0&fnval=4048&fourk=1&support_multi_audio=true&from_client=BROWSER" - resp_json = await Fetcher.fetch_json(client, play_api.format(**avid.to_dict(), cid=cid)) + resp_json = await Fetcher.fetch_json(ctx, client, play_api.format(**avid.to_dict(), cid=cid)) if resp_json is None: raise NoAccessPermissionError(f"无法获取该视频链接({format_ids(avid, cid)})") if resp_json.get("result") is None or resp_json["result"].get("video_info") is None: @@ -133,10 +133,12 @@ async def get_bangumi_playurl( ) -async def get_bangumi_subtitles(client: AsyncClient, avid: AvId, cid: CId) -> list[MultiLangSubtitle]: +async def get_bangumi_subtitles( + ctx: FetcherContext, client: AsyncClient, avid: AvId, cid: CId +) -> list[MultiLangSubtitle]: subtitile_api = "https://api.bilibili.com/x/player/v2?cid={cid}&aid={aid}&bvid={bvid}" subtitile_url = subtitile_api.format(**avid.to_dict(), cid=cid) - subtitles_json_info = await Fetcher.fetch_json(client, subtitile_url) + subtitles_json_info = await Fetcher.fetch_json(ctx, client, subtitile_url) if subtitles_json_info is None: return [] if not data_has_chained_keys(subtitles_json_info, ["data", "subtitle", "subtitles"]): @@ -145,7 +147,7 @@ async def get_bangumi_subtitles(client: AsyncClient, avid: AvId, cid: CId) -> li subtitles_info = subtitles_json_info["data"]["subtitle"] results: list[MultiLangSubtitle] = [] for sub_info in subtitles_info["subtitles"]: - subtitle_text = await Fetcher.fetch_json(client, "https:" + sub_info["subtitle_url"]) + subtitle_text = await Fetcher.fetch_json(ctx, client, "https:" + sub_info["subtitle_url"]) if subtitle_text is None: continue results.append( diff --git a/src/yutto/api/cheese.py b/src/yutto/api/cheese.py index 7e4706cdb..012e9be35 100644 --- a/src/yutto/api/cheese.py +++ b/src/yutto/api/cheese.py @@ -16,7 +16,7 @@ from yutto.bilibili_typing.codec import audio_codec_map, video_codec_map from yutto.exceptions import NoAccessPermissionError, UnSupportedTypeError from yutto.utils.console.logger import Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.funcutils import data_has_chained_keys from yutto.utils.metadata import MetaData from yutto.utils.time import get_time_stamp_by_now @@ -39,16 +39,16 @@ class CheeseList(TypedDict): pages: list[CheeseListItem] -async def get_season_id_by_episode_id(client: AsyncClient, episode_id: EpisodeId) -> SeasonId: +async def get_season_id_by_episode_id(ctx: FetcherContext, client: AsyncClient, episode_id: EpisodeId) -> SeasonId: home_url = f"https://api.bilibili.com/pugv/view/web/season?ep_id={episode_id}" - res_json = await Fetcher.fetch_json(client, home_url) + res_json = await Fetcher.fetch_json(ctx, client, home_url) assert res_json is not None return SeasonId(str(res_json["data"]["season_id"])) -async def get_cheese_list(client: AsyncClient, season_id: SeasonId) -> CheeseList: +async def get_cheese_list(ctx: FetcherContext, client: AsyncClient, season_id: SeasonId) -> CheeseList: list_api = "https://api.bilibili.com/pugv/view/web/season?season_id={season_id}" - resp_json = await Fetcher.fetch_json(client, list_api.format(season_id=season_id)) + resp_json = await Fetcher.fetch_json(ctx, client, list_api.format(season_id=season_id)) if resp_json is None: raise NoAccessPermissionError(f"无法解析该课程列表(season_id: {season_id})") if resp_json.get("data") is None: @@ -72,13 +72,13 @@ async def get_cheese_list(client: AsyncClient, season_id: SeasonId) -> CheeseLis async def get_cheese_playurl( - client: AsyncClient, avid: AvId, episode_id: EpisodeId, cid: CId + ctx: FetcherContext, client: AsyncClient, avid: AvId, episode_id: EpisodeId, cid: CId ) -> tuple[list[VideoUrlMeta], list[AudioUrlMeta]]: play_api = ( "https://api.bilibili.com/pugv/player/web/playurl?avid={aid}&cid={" "cid}&qn=80&fnver=0&fnval=16&fourk=1&ep_id={episode_id}&from_client=BROWSER&drm_tech_type=2" ) - resp_json = await Fetcher.fetch_json(client, play_api.format(**avid.to_dict(), cid=cid, episode_id=episode_id)) + resp_json = await Fetcher.fetch_json(ctx, client, play_api.format(**avid.to_dict(), cid=cid, episode_id=episode_id)) if resp_json is None: raise NoAccessPermissionError(f"无法获取该视频链接({format_ids(avid, cid)})") if resp_json.get("data") is None: @@ -115,10 +115,12 @@ async def get_cheese_playurl( ) -async def get_cheese_subtitles(client: AsyncClient, avid: AvId, cid: CId) -> list[MultiLangSubtitle]: +async def get_cheese_subtitles( + ctx: FetcherContext, client: AsyncClient, avid: AvId, cid: CId +) -> list[MultiLangSubtitle]: subtitile_api = "https://api.bilibili.com/x/player/v2?cid={cid}&aid={aid}&bvid={bvid}" subtitile_url = subtitile_api.format(**avid.to_dict(), cid=cid) - subtitles_json_info = await Fetcher.fetch_json(client, subtitile_url) + subtitles_json_info = await Fetcher.fetch_json(ctx, client, subtitile_url) if subtitles_json_info is None: return [] if not data_has_chained_keys(subtitles_json_info, ["data", "subtitle", "subtitles"]): @@ -127,7 +129,7 @@ async def get_cheese_subtitles(client: AsyncClient, avid: AvId, cid: CId) -> lis subtitles_info = subtitles_json_info["data"]["subtitle"] results: list[MultiLangSubtitle] = [] for sub_info in subtitles_info["subtitles"]: - subtitle_text = await Fetcher.fetch_json(client, "https:" + sub_info["subtitle_url"]) + subtitle_text = await Fetcher.fetch_json(ctx, client, "https:" + sub_info["subtitle_url"]) if subtitle_text is None: continue results.append( diff --git a/src/yutto/api/collection.py b/src/yutto/api/collection.py index 7e0abbb81..84b30ae54 100644 --- a/src/yutto/api/collection.py +++ b/src/yutto/api/collection.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, TypedDict from yutto._typing import AvId, BvId, MId, SeriesId -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext if TYPE_CHECKING: from httpx import AsyncClient @@ -22,10 +22,12 @@ class CollectionDetails(TypedDict): pages: list[CollectionDetailsItem] -async def get_collection_details(client: AsyncClient, series_id: SeriesId, mid: MId) -> CollectionDetails: +async def get_collection_details( + ctx: FetcherContext, client: AsyncClient, series_id: SeriesId, mid: MId +) -> CollectionDetails: title, avids = await asyncio.gather( - _get_collection_title(client, series_id), - _get_collection_avids(client, series_id, mid), + _get_collection_title(ctx, client, series_id), + _get_collection_avids(ctx, client, series_id, mid), ) return CollectionDetails( title=title, @@ -40,8 +42,8 @@ async def get_collection_details(client: AsyncClient, series_id: SeriesId, mid: ) -async def _get_collection_avids(client: AsyncClient, series_id: SeriesId, mid: MId) -> list[AvId]: - api = "https://api.bilibili.com/x/polymer/space/seasons_archives_list?mid={mid}&season_id={series_id}&sort_reverse=false&page_num={pn}&page_size={ps}" +async def _get_collection_avids(ctx: FetcherContext, client: AsyncClient, series_id: SeriesId, mid: MId) -> list[AvId]: + api = "https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?mid={mid}&season_id={series_id}&sort_reverse=false&page_num={pn}&page_size={ps}" ps = 30 pn = 1 total = 1 @@ -49,7 +51,7 @@ async def _get_collection_avids(client: AsyncClient, series_id: SeriesId, mid: M while pn <= total: space_videos_url = api.format(series_id=series_id, ps=ps, pn=pn, mid=mid) - json_data = await Fetcher.fetch_json(client, space_videos_url) + json_data = await Fetcher.fetch_json(ctx, client, space_videos_url) assert json_data is not None total = math.ceil(json_data["data"]["page"]["total"] / ps) pn += 1 @@ -57,8 +59,8 @@ async def _get_collection_avids(client: AsyncClient, series_id: SeriesId, mid: M return all_avid -async def _get_collection_title(client: AsyncClient, series_id: SeriesId) -> str: +async def _get_collection_title(ctx: FetcherContext, client: AsyncClient, series_id: SeriesId) -> str: api = "https://api.bilibili.com/x/v1/medialist/info?type=8&biz_id={series_id}" - json_data = await Fetcher.fetch_json(client, api.format(series_id=series_id)) + json_data = await Fetcher.fetch_json(ctx, client, api.format(series_id=series_id)) assert json_data is not None return json_data["data"]["title"] diff --git a/src/yutto/api/danmaku.py b/src/yutto/api/danmaku.py index 34f3bc328..c9ef26bc1 100644 --- a/src/yutto/api/danmaku.py +++ b/src/yutto/api/danmaku.py @@ -6,7 +6,7 @@ from biliass import get_danmaku_meta_size from yutto.api.user_info import get_user_info -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext if TYPE_CHECKING: import httpx @@ -15,41 +15,44 @@ from yutto.utils.danmaku import DanmakuData, DanmakuSaveType -async def get_xml_danmaku(client: httpx.AsyncClient, cid: CId) -> str: +async def get_xml_danmaku(ctx: FetcherContext, client: httpx.AsyncClient, cid: CId) -> str: danmaku_api = "http://comment.bilibili.com/{cid}.xml" - results = await Fetcher.fetch_text(client, danmaku_api.format(cid=cid), encoding="utf-8") + results = await Fetcher.fetch_text(ctx, client, danmaku_api.format(cid=cid), encoding="utf-8") assert results is not None return results -async def get_protobuf_danmaku_segment(client: httpx.AsyncClient, cid: CId, segment_id: int = 1) -> bytes: +async def get_protobuf_danmaku_segment( + ctx: FetcherContext, client: httpx.AsyncClient, cid: CId, segment_id: int = 1 +) -> bytes: danmaku_api = "http://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid={cid}&segment_index={segment_id}" - results = await Fetcher.fetch_bin(client, danmaku_api.format(cid=cid, segment_id=segment_id)) + results = await Fetcher.fetch_bin(ctx, client, danmaku_api.format(cid=cid, segment_id=segment_id)) assert results is not None return results -async def get_protobuf_danmaku(client: httpx.AsyncClient, avid: AvId, cid: CId) -> list[bytes]: +async def get_protobuf_danmaku(ctx: FetcherContext, client: httpx.AsyncClient, avid: AvId, cid: CId) -> list[bytes]: danmaku_meta_api = "https://api.bilibili.com/x/v2/dm/web/view?type=1&oid={cid}&pid={aid}" aid = avid.as_aid() - meta_results = await Fetcher.fetch_bin(client, danmaku_meta_api.format(cid=cid, aid=aid.value)) + meta_results = await Fetcher.fetch_bin(ctx, client, danmaku_meta_api.format(cid=cid, aid=aid.value)) assert meta_results is not None size = get_danmaku_meta_size(meta_results) results = await asyncio.gather( - *[get_protobuf_danmaku_segment(client, cid, segment_id) for segment_id in range(1, size + 1)] + *[get_protobuf_danmaku_segment(ctx, client, cid, segment_id) for segment_id in range(1, size + 1)] ) return results async def get_danmaku( + ctx: FetcherContext, client: httpx.AsyncClient, cid: CId, avid: AvId, save_type: DanmakuSaveType, ) -> DanmakuData: # 在已经登录的情况下,使用 protobuf,因为未登录时 protobuf 弹幕会少非常多 - source_type = "xml" if save_type == "xml" or not (await get_user_info(client))["is_login"] else "protobuf" + source_type = "xml" if save_type == "xml" or not (await get_user_info(ctx, client))["is_login"] else "protobuf" danmaku_data: DanmakuData = { "source_type": source_type, "save_type": save_type, @@ -57,7 +60,7 @@ async def get_danmaku( } if source_type == "xml": - danmaku_data["data"].append(await get_xml_danmaku(client, cid)) + danmaku_data["data"].append(await get_xml_danmaku(ctx, client, cid)) else: - danmaku_data["data"].extend(await get_protobuf_danmaku(client, avid, cid)) + danmaku_data["data"].extend(await get_protobuf_danmaku(ctx, client, avid, cid)) return danmaku_data diff --git a/src/yutto/api/space.py b/src/yutto/api/space.py index 6de973e40..2f3c64851 100644 --- a/src/yutto/api/space.py +++ b/src/yutto/api/space.py @@ -7,14 +7,14 @@ from yutto.api.user_info import encode_wbi, get_wbi_img from yutto.exceptions import NotLoginError from yutto.utils.console.logger import Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext if TYPE_CHECKING: from httpx import AsyncClient # 个人空间·全部 -async def get_user_space_all_videos_avids(client: AsyncClient, mid: MId) -> list[AvId]: +async def get_user_space_all_videos_avids(ctx: FetcherContext, client: AsyncClient, mid: MId) -> list[AvId]: space_videos_api = "https://api.bilibili.com/x/space/wbi/arc/search" # ps 随机设置有时会出现错误,因此暂时固定在 30 # ps: int = random.randint(3, 6) * 10 @@ -22,7 +22,7 @@ async def get_user_space_all_videos_avids(client: AsyncClient, mid: MId) -> list pn = 1 total = 1 all_avid: list[AvId] = [] - wbi_img = await get_wbi_img(client) + wbi_img = await get_wbi_img(ctx, client) while pn <= total: params = { "mid": mid, @@ -32,7 +32,7 @@ async def get_user_space_all_videos_avids(client: AsyncClient, mid: MId) -> list "order": "pubdate", } params = encode_wbi(params, wbi_img) - json_data = await Fetcher.fetch_json(client, space_videos_api, params=params) + json_data = await Fetcher.fetch_json(ctx, client, space_videos_api, params=params) assert json_data is not None total = math.ceil(json_data["data"]["page"]["count"] / ps) pn += 1 @@ -41,13 +41,13 @@ async def get_user_space_all_videos_avids(client: AsyncClient, mid: MId) -> list # 个人空间·用户名 -async def get_user_name(client: AsyncClient, mid: MId) -> str: - wbi_img = await get_wbi_img(client) +async def get_user_name(ctx: FetcherContext, client: AsyncClient, mid: MId) -> str: + wbi_img = await get_wbi_img(ctx, client) params = {"mid": mid} params = encode_wbi(params, wbi_img) space_info_api = "https://api.bilibili.com/x/space/wbi/acc/info" - await Fetcher.touch_url(client, "https://www.bilibili.com") - user_info = await Fetcher.fetch_json(client, space_info_api, params=params) + await Fetcher.touch_url(ctx, client, "https://www.bilibili.com") + user_info = await Fetcher.fetch_json(ctx, client, space_info_api, params=params) assert user_info is not None if user_info["code"] == -404: Logger.warning(f"用户 {mid} 不存在,疑似注销或被封禁") @@ -58,26 +58,26 @@ async def get_user_name(client: AsyncClient, mid: MId) -> str: # 个人空间·收藏夹·信息 -async def get_favourite_info(client: AsyncClient, fid: FId) -> FavouriteMetaData: +async def get_favourite_info(ctx: FetcherContext, client: AsyncClient, fid: FId) -> FavouriteMetaData: api = "https://api.bilibili.com/x/v3/fav/folder/info?media_id={fid}" - json_data = await Fetcher.fetch_json(client, api.format(fid=fid)) + json_data = await Fetcher.fetch_json(ctx, client, api.format(fid=fid)) assert json_data is not None data = json_data["data"] return FavouriteMetaData(title=data["title"], fid=FId(str(data["id"]))) # 个人空间·收藏夹·avid -async def get_favourite_avids(client: AsyncClient, fid: FId) -> list[AvId]: +async def get_favourite_avids(ctx: FetcherContext, client: AsyncClient, fid: FId) -> list[AvId]: api = "https://api.bilibili.com/x/v3/fav/resource/ids?media_id={fid}" - json_data = await Fetcher.fetch_json(client, api.format(fid=fid)) + json_data = await Fetcher.fetch_json(ctx, client, api.format(fid=fid)) assert json_data is not None return [BvId(video_info["bvid"]) for video_info in json_data["data"]] # 个人空间·收藏夹·全部 -async def get_all_favourites(client: AsyncClient, mid: MId) -> list[FavouriteMetaData]: +async def get_all_favourites(ctx: FetcherContext, client: AsyncClient, mid: MId) -> list[FavouriteMetaData]: api = "https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid={mid}" - json_data = await Fetcher.fetch_json(client, api.format(mid=mid)) + json_data = await Fetcher.fetch_json(ctx, client, api.format(mid=mid)) assert json_data is not None if not json_data["data"]: return [] @@ -85,7 +85,7 @@ async def get_all_favourites(client: AsyncClient, mid: MId) -> list[FavouriteMet # 个人空间·视频列表·avid -async def get_medialist_avids(client: AsyncClient, series_id: SeriesId, mid: MId) -> list[AvId]: +async def get_medialist_avids(ctx: FetcherContext, client: AsyncClient, series_id: SeriesId, mid: MId) -> list[AvId]: api = "https://api.bilibili.com/x/series/archives?mid={mid}&series_id={series_id}&only_normal=true&pn={pn}&ps={ps}" ps = 30 pn = 1 @@ -94,7 +94,7 @@ async def get_medialist_avids(client: AsyncClient, series_id: SeriesId, mid: MId while pn <= total: url = api.format(series_id=series_id, mid=mid, ps=ps, pn=pn) - json_data = await Fetcher.fetch_json(client, url) + json_data = await Fetcher.fetch_json(ctx, client, url) assert json_data is not None total = math.ceil(json_data["data"]["page"]["total"] / ps) pn += 1 @@ -103,17 +103,17 @@ async def get_medialist_avids(client: AsyncClient, series_id: SeriesId, mid: MId # 个人空间·视频列表·标题 -async def get_medialist_title(client: AsyncClient, series_id: SeriesId) -> str: +async def get_medialist_title(ctx: FetcherContext, client: AsyncClient, series_id: SeriesId) -> str: api = "https://api.bilibili.com/x/v1/medialist/info?type=5&biz_id={series_id}" - json_data = await Fetcher.fetch_json(client, api.format(series_id=series_id)) + json_data = await Fetcher.fetch_json(ctx, client, api.format(series_id=series_id)) assert json_data is not None return json_data["data"]["title"] # 个人空间·稍后再看 -async def get_watch_later_avids(client: AsyncClient) -> list[AvId]: +async def get_watch_later_avids(ctx: FetcherContext, client: AsyncClient) -> list[AvId]: api = "https://api.bilibili.com/x/v2/history/toview/web" - json_data = await Fetcher.fetch_json(client, api) + json_data = await Fetcher.fetch_json(ctx, client, api) assert json_data is not None if json_data["code"] in [-101, -400]: raise NotLoginError("账号未登录,无法获取稍后再看列表哦~ Ծ‸Ծ") diff --git a/src/yutto/api/ugc_video.py b/src/yutto/api/ugc_video.py index 2bf89e94d..1fcd73df4 100644 --- a/src/yutto/api/ugc_video.py +++ b/src/yutto/api/ugc_video.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import re from typing import TYPE_CHECKING, Any, TypedDict, cast @@ -22,7 +21,7 @@ UnSupportedTypeError, ) from yutto.utils.console.logger import Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.funcutils.data_access import data_has_chained_keys from yutto.utils.metadata import Actor, ChapterInfoData, MetaData from yutto.utils.time import get_time_stamp_by_now @@ -68,10 +67,10 @@ class UgcVideoList(TypedDict): pages: list[UgcVideoListItem] -async def get_ugc_video_tag(client: AsyncClient, avid: AvId) -> list[str]: +async def get_ugc_video_tag(ctx: FetcherContext, client: AsyncClient, avid: AvId) -> list[str]: tags: list[str] = [] tag_api = "http://api.bilibili.com/x/tag/archive/tags?aid={aid}&bvid={bvid}" - res_json = await Fetcher.fetch_json(client, tag_api.format(**avid.to_dict())) + res_json = await Fetcher.fetch_json(ctx, client, tag_api.format(**avid.to_dict())) if res_json is None or res_json["code"] != 0: raise NotFoundError(f"无法获取视频 {avid} 标签") for tag in res_json["data"]: @@ -79,29 +78,33 @@ async def get_ugc_video_tag(client: AsyncClient, avid: AvId) -> list[str]: return tags -async def get_ugc_video_info(client: AsyncClient, avid: AvId) -> _UgcVideoInfo: +async def get_ugc_video_info(ctx: FetcherContext, client: AsyncClient, avid: AvId) -> _UgcVideoInfo: regex_ep = re.compile(r"https?://www\.bilibili\.com/bangumi/play/ep(?P\d+)") info_api = "http://api.bilibili.com/x/web-interface/view?aid={aid}&bvid={bvid}" - res_json = await Fetcher.fetch_json(client, info_api.format(**avid.to_dict())) + res_json = await Fetcher.fetch_json(ctx, client, info_api.format(**avid.to_dict())) if res_json is None: raise NotFoundError(f"无法获取该视频 {avid} 信息") res_json_data = res_json.get("data") if res_json["code"] == 62002: raise NotFoundError(f"无法下载该视频 {avid},原因:{res_json['message']}") + if res_json["code"] == 62012: + raise NoAccessPermissionError( + f"无法获取该视频 {avid} 信息,原因:{res_json['message']}(当前稿件up主设置为仅自见)" + ) if res_json["code"] == -404: raise NotFoundError(f"啊叻?视频 {avid} 不见了诶") assert res_json_data is not None, "响应数据无 data 域" if res_json_data.get("forward"): forward_avid = AId(str(res_json_data["forward"])) Logger.info(f"视频 {avid} 撞车了哦!正在跳转到原视频 {forward_avid}~") - return await get_ugc_video_info(client, forward_avid) + return await get_ugc_video_info(ctx, client, forward_avid) episode_id = EpisodeId("") if res_json_data.get("redirect_url") and (ep_match := regex_ep.match(res_json_data["redirect_url"])): episode_id = EpisodeId(ep_match.group("episode_id")) actors = _parse_actor_info(res_json_data) genres = _parse_genre_info(res_json_data) - tags: list[str] = await get_ugc_video_tag(client, avid) + tags: list[str] = await get_ugc_video_tag(ctx, client, avid) return { "avid": BvId(res_json_data["bvid"]), "aid": AId(str(res_json_data["aid"])), @@ -126,8 +129,8 @@ async def get_ugc_video_info(client: AsyncClient, avid: AvId) -> _UgcVideoInfo: } -async def get_ugc_video_list(client: AsyncClient, avid: AvId) -> UgcVideoList: - video_info = await get_ugc_video_info(client, avid) +async def get_ugc_video_list(ctx: FetcherContext, client: AsyncClient, avid: AvId) -> UgcVideoList: + video_info = await get_ugc_video_info(ctx, client, avid) if avid not in [video_info["aid"], video_info["bvid"]]: avid = video_info["avid"] video_title = video_info["title"] @@ -138,7 +141,7 @@ async def get_ugc_video_list(client: AsyncClient, avid: AvId) -> UgcVideoList: "pages": [], } list_api = "https://api.bilibili.com/x/player/pagelist?aid={aid}&bvid={bvid}&jsonp=jsonp" - res_json = await Fetcher.fetch_json(client, list_api.format(**avid.to_dict())) + res_json = await Fetcher.fetch_json(ctx, client, list_api.format(**avid.to_dict())) if res_json is None or res_json.get("data") is None: Logger.warning(f"啊叻?视频 {avid} 不见了诶") return result @@ -147,9 +150,9 @@ async def get_ugc_video_list(client: AsyncClient, avid: AvId) -> UgcVideoList: for i, (item, page_info) in enumerate(zip(cast(list[Any], res_json["data"]), video_info["pages"])): # TODO: 这里 part 出现了两次,需要都修改,后续去除其中一个冗余数据 if _is_meaningless_name(item["part"]): - item["part"] = f"{video_title}_P{i+1:02}" + item["part"] = f"{video_title}_P{i + 1:02}" if _is_meaningless_name(page_info["part"]): - page_info["part"] = f"{video_title}_P{i+1:02}" + page_info["part"] = f"{video_title}_P{i + 1:02}" result["pages"] = [ { @@ -165,12 +168,12 @@ async def get_ugc_video_list(client: AsyncClient, avid: AvId) -> UgcVideoList: async def get_ugc_video_playurl( - client: AsyncClient, avid: AvId, cid: CId + ctx: FetcherContext, client: AsyncClient, avid: AvId, cid: CId ) -> tuple[list[VideoUrlMeta], list[AudioUrlMeta]]: # 4048 = 16(useDash) | 64(useHDR) | 128(use4K) | 256(useDolby) | 512(useXXX) | 1024(use8K) | 2048(useAV1) play_api = "https://api.bilibili.com/x/player/playurl?avid={aid}&bvid={bvid}&cid={cid}&qn=127&type=&otype=json&fnver=0&fnval=4048&fourk=1" - resp_json = await Fetcher.fetch_json(client, play_api.format(**avid.to_dict(), cid=cid)) + resp_json = await Fetcher.fetch_json(ctx, client, play_api.format(**avid.to_dict(), cid=cid)) if resp_json is None: raise NoAccessPermissionError(f"无法获取该视频链接({format_ids(avid, cid)})") if resp_json.get("data") is None: @@ -238,33 +241,35 @@ async def get_ugc_video_playurl( return (videos, audios) -async def get_ugc_video_subtitles(client: AsyncClient, avid: AvId, cid: CId) -> list[MultiLangSubtitle]: - subtitile_api = "https://api.bilibili.com/x/player.so?aid={aid}&bvid={bvid}&id=cid:{cid}" +async def get_ugc_video_subtitles( + ctx: FetcherContext, client: AsyncClient, avid: AvId, cid: CId +) -> list[MultiLangSubtitle]: + subtitile_api = "https://api.bilibili.com/x/player/wbi/v2?aid={aid}&bvid={bvid}&cid={cid}" subtitile_url = subtitile_api.format(**avid.to_dict(), cid=cid) - res_text = await Fetcher.fetch_text(client, subtitile_url) - if res_text is None: + res_json = await Fetcher.fetch_json(ctx, client, subtitile_url) + assert res_json is not None, "无法获取该视频的字幕信息" + if not data_has_chained_keys(res_json, ["data", "subtitle", "subtitles"]): return [] - if subtitle_json_text_match := re.search(r"(.+)", res_text): - subtitle_json = json.loads(subtitle_json_text_match.group(1)) - results: list[MultiLangSubtitle] = [] - for sub_info in subtitle_json["subtitles"]: - subtitle_text = await Fetcher.fetch_json(client, "https:" + sub_info["subtitle_url"]) - if subtitle_text is None: - continue - results.append( - { - "lang": sub_info["lan_doc"], - "lines": subtitle_text["body"], - } - ) - return results - return [] + results: list[MultiLangSubtitle] = [] + for sub_info in res_json["data"]["subtitle"]["subtitles"]: + subtitle_text = await Fetcher.fetch_json(ctx, client, "https:" + sub_info["subtitle_url"]) + if subtitle_text is None: + continue + results.append( + { + "lang": sub_info["lan_doc"], + "lines": subtitle_text["body"], + } + ) + return results -async def get_ugc_video_chapters(client: AsyncClient, avid: AvId, cid: CId) -> list[ChapterInfoData]: +async def get_ugc_video_chapters( + ctx: FetcherContext, client: AsyncClient, avid: AvId, cid: CId +) -> list[ChapterInfoData]: chapter_api = "https://api.bilibili.com/x/player/v2?avid={aid}&bvid={bvid}&cid={cid}" chapter_url = chapter_api.format(**avid.to_dict(), cid=cid) - chapter_json_info = await Fetcher.fetch_json(client, chapter_url) + chapter_json_info = await Fetcher.fetch_json(ctx, client, chapter_url) if chapter_json_info is None: return [] if not data_has_chained_keys(chapter_json_info, ["data", "view_points"]): diff --git a/src/yutto/api/user_info.py b/src/yutto/api/user_info.py index d1ed3b93c..ed05b7af3 100644 --- a/src/yutto/api/user_info.py +++ b/src/yutto/api/user_info.py @@ -11,7 +11,7 @@ from yutto._typing import UserInfo from yutto.utils.asynclib import async_cache -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext if TYPE_CHECKING: from httpx import AsyncClient @@ -28,9 +28,9 @@ class WbiImg(TypedDict): @async_cache(lambda _: "user_info") -async def get_user_info(client: AsyncClient) -> UserInfo: +async def get_user_info(ctx: FetcherContext, client: AsyncClient) -> UserInfo: info_api = "https://api.bilibili.com/x/web-interface/nav" - res_json = await Fetcher.fetch_json(client, info_api) + res_json = await Fetcher.fetch_json(ctx, client, info_api) assert res_json is not None res_json_data = res_json.get("data") return UserInfo( @@ -39,12 +39,12 @@ async def get_user_info(client: AsyncClient) -> UserInfo: ) -async def get_wbi_img(client: AsyncClient) -> WbiImg: +async def get_wbi_img(ctx: FetcherContext, client: AsyncClient) -> WbiImg: global wbi_img_cache if wbi_img_cache is not None: return wbi_img_cache url = "https://api.bilibili.com/x/web-interface/nav" - res_json = await Fetcher.fetch_json(client, url) + res_json = await Fetcher.fetch_json(ctx, client, url) assert res_json is not None wbi_img: WbiImg = { "img_key": _get_key_from_url(res_json["data"]["wbi_img"]["img_url"]), diff --git a/src/yutto/cli/__init__.py b/src/yutto/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/yutto/cli/cli.py b/src/yutto/cli/cli.py new file mode 100644 index 000000000..19b8b24b1 --- /dev/null +++ b/src/yutto/cli/cli.py @@ -0,0 +1,366 @@ +from __future__ import annotations + +import argparse +from typing import TYPE_CHECKING, Any, Literal + +from yutto.__version__ import VERSION as yutto_version +from yutto.bilibili_typing.quality import ( + audio_quality_priority_default, + video_quality_priority_default, +) +from yutto.cli.settings import YuttoSettings, load_settings_file, search_for_settings_file +from yutto.processor.parser import alias_parser, path_from_cli +from yutto.utils.console.logger import Logger +from yutto.utils.funcutils.option import map_some + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from typing_extensions import TypeAlias + + +DownloadResourceType: TypeAlias = Literal["video", "audio", "subtitle", "metadata", "danmaku", "cover", "chapter_info"] +DOWNLOAD_RESOURCE_TYPES: list[DownloadResourceType] = [ + "video", + "audio", + "subtitle", + "metadata", + "danmaku", + "cover", + "chapter_info", +] + + +def parse_config_path() -> Path | None: + pre_parser = argparse.ArgumentParser(description="yutto pre parser", add_help=False) + pre_parser.add_argument("--config", type=path_from_cli, default=search_for_settings_file(), help="配置文件路径") + args, _ = pre_parser.parse_known_args() + return args.config + + +def cli() -> argparse.ArgumentParser: + settings_file = parse_config_path() + if settings_file is None: + settings = YuttoSettings() # pyright: ignore[reportCallIssue] + else: + Logger.info(f"发现配置文件 {settings_file},加载中……") + settings = load_settings_file(settings_file) + + parser = argparse.ArgumentParser(description="yutto 一个可爱且任性的 B 站视频下载器", prog="yutto") + parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {yutto_version}") + # 如果需要创建其他子命令可参考 + # https://stackoverflow.com/questions/29998417/create-parser-with-subcommands-in-argparse-customize-positional-arguments + parser.add_argument("url", help="视频主页 url 或 url 列表(需使用 file scheme)") + group_basic = parser.add_argument_group("basic", "基础参数") + group_basic.add_argument( + "-n", "--num-workers", type=int, default=settings.basic.num_workers, help="同时用于下载的最大 Worker 数" + ) + group_basic.add_argument( + "-q", + "--video-quality", + default=settings.basic.video_quality, + choices=video_quality_priority_default, + type=int, + help="视频清晰度等级(127:8K, 126:Dolby Vision, 125:HDR, 120:4K, 116:1080P60, 112:1080P+, 100:智能修复, 80:1080P, 74:720P60, 64:720P, 32:480P, 16:360P)", + ) + group_basic.add_argument( + "-aq", + "--audio-quality", + default=settings.basic.audio_quality, + choices=audio_quality_priority_default, + type=int, + help="音频码率等级(30251:Hi-Res, 30255:Dolby Audio, 30250:Dolby Atmos, 30280:320kbps, 30232:128kbps, 30216:64kbps)", + ) + group_basic.add_argument( + "--vcodec", + default=settings.basic.vcodec, + metavar="DOWNLOAD_VCODEC:SAVE_VCODEC", + help="视频编码格式(<下载格式>:<生成格式>)", + ) + group_basic.add_argument( + "--acodec", + default=settings.basic.acodec, + metavar="DOWNLOAD_ACODEC:SAVE_ACODEC", + help="音频编码格式(<下载格式>:<生成格式>)", + ) + group_basic.add_argument( + "--download-vcodec-priority", + default=settings.basic.download_vcodec_priority, + type=lambda codecs: codecs.split(",") if codecs != "auto" else None, + help="视频编码格式优先级,使用 `,` 分隔,如 `hevc,avc,av1`,默认为 `auto`,即根据 vcodec 中「下载编码」自动推断", + ) + group_basic.add_argument( + "--output-format", + default=settings.basic.output_format, + choices=["infer", "mp4", "mkv", "mov"], + help="输出格式(infer 为自动推断)", + ) + group_basic.add_argument( + "--output-format-audio-only", + default=settings.basic.output_format_audio_only, + choices=["infer", "m4a", "aac", "mp3", "flac", "mp4", "mkv", "mov"], + help="仅包含音频流时所使用的输出格式(infer 为自动推断)", + ) + group_basic.add_argument( + "-df", + "--danmaku-format", + default=settings.basic.danmaku_format, + choices=["xml", "ass", "protobuf"], + help="弹幕类型", + ) + group_basic.add_argument( + "-bs", + "--block-size", + default=settings.basic.block_size, + type=float, + help="分块下载时各块大小,单位为 MiB,默认为 0.5MiB", + ) + group_basic.add_argument( + "-w", "--overwrite", default=settings.basic.overwrite, action="store_true", help="强制覆盖已下载内容" + ) + group_basic.add_argument( + "-x", + "--proxy", + default=settings.basic.proxy, + help="设置代理(auto 为系统代理、no 为不使用代理、当然也可以设置代理值)", + ) + group_basic.add_argument( + "-d", + "--dir", + default=path_from_cli(settings.basic.dir), + type=path_from_cli, + help="下载目录,默认为运行目录", + ) + group_basic.add_argument( + "--tmp-dir", + default=map_some(path_from_cli, settings.basic.tmp_dir), + type=path_from_cli, + help="用来存放下载过程中临时文件的目录,默认为下载目录", + ) + group_basic.add_argument("-c", "--sessdata", default=settings.basic.sessdata, help="Cookies 中的 SESSDATA 字段") + group_basic.add_argument( + "-tp", "--subpath-template", default=settings.basic.subpath_template, help="多级目录的存储路径模板" + ) + group_basic.add_argument( + "-af", + "--alias-file", + dest="aliases", + type=alias_parser, + default=settings.basic.aliases, + help="设置 url 别名文件路径", + ) + group_basic.add_argument( + "--metadata-format-premiered", + default=settings.basic.metadata_format_premiered, + help="专用于 metadata 文件中 premiered 字段的日期格式", + ) + group_basic.add_argument( + "--download-interval", default=settings.basic.download_interval, type=int, help="设置下载间隔,单位为秒" + ) + group_basic.add_argument( + "--banned-mirrors-pattern", + default=settings.basic.banned_mirrors_pattern, + help="禁用下载链接的镜像源,使用正则匹配", + ) + group_basic.add_argument("--no-color", default=settings.basic.no_color, action="store_true", help="不使用颜色") + group_basic.add_argument( + "--no-progress", default=settings.basic.no_progress, action="store_true", help="不显示进度条" + ) + group_basic.add_argument("--debug", default=settings.basic.debug, action="store_true", help="启用 debug 模式") + group_basic.add_argument( + "--vip-strict", default=settings.basic.vip_strict, action="store_true", help="启用严格检查大会员生效" + ) + group_basic.add_argument( + "--login-strict", default=settings.basic.login_strict, action="store_true", help="启用严格检查登录状态" + ) + + # 资源选择 + group_resource = parser.add_argument_group("resource", "资源选择参数") + group_resource.add_argument( + "--video-only", + dest="require_audio", + action=create_select_required_action(deselect=["audio"]), + help="仅下载视频流", + ) + group_resource.add_argument( + "--audio-only", + dest="require_video", + action=create_select_required_action(deselect=["video"]), + help="仅下载音频流", + ) # 视频和音频是反选对方,而不是其余反选所有的 + group_resource.add_argument( + "--no-danmaku", + dest="require_danmaku", + action=create_select_required_action(deselect=["danmaku"]), + help="不生成弹幕文件", + ) + group_resource.add_argument( + "--danmaku-only", + dest="require_danmaku", + action=create_select_required_action(select=["danmaku"], deselect=invert_selection(["danmaku"])), + help="仅生成弹幕文件", + ) + group_resource.add_argument( + "--no-subtitle", + dest="require_subtitle", + action=create_select_required_action(deselect=["subtitle"]), + help="不生成字幕文件", + ) + group_resource.add_argument( + "--subtitle-only", + dest="require_subtitle", + action=create_select_required_action(select=["subtitle"], deselect=invert_selection(["subtitle"])), + help="仅生成字幕文件", + ) + group_resource.add_argument( + "--with-metadata", + dest="require_metadata", + action=create_select_required_action(select=["metadata"]), + help="生成元数据文件", + ) + group_resource.add_argument( + "--metadata-only", + dest="require_metadata", + action=create_select_required_action(select=["metadata"], deselect=invert_selection(["metadata"])), + help="仅生成元数据文件", + ) + group_resource.add_argument( + "--no-cover", + dest="require_cover", + action=create_select_required_action(deselect=["cover"]), + help="不生成封面", + ) + group_resource.add_argument( + "--cover-only", + dest="require_cover", + action=create_select_required_action(select=["cover"], deselect=invert_selection(["cover"])), + help="仅生成封面", + ) + group_resource.add_argument( + "--no-chapter-info", + dest="require_chapter_info", + action=create_select_required_action(deselect=["chapter_info"]), + help="不封装章节信息", + ) + group_resource.add_argument( + "--save-cover", + default=settings.resource.save_cover, + action="store_true", + help="生成视频流封面后单独保存封面文件", + ) + group_resource.set_defaults( + require_video=settings.resource.require_video, + require_audio=settings.resource.require_audio, + require_subtitle=settings.resource.require_subtitle, + require_metadata=settings.resource.require_metadata, + require_danmaku=settings.resource.require_danmaku, + require_cover=settings.resource.require_cover, + require_chapter_info=settings.resource.require_chapter_info, + ) + + # 弹幕设置 + group_danmaku = parser.add_argument_group("danmaku", "弹幕设置参数") + group_danmaku.add_argument("--danmaku-font-size", type=int, default=settings.danmaku.font_size, help="弹幕字体大小") + group_danmaku.add_argument("--danmaku-font", default=settings.danmaku.font, help="弹幕字体") + group_danmaku.add_argument("--danmaku-opacity", type=float, default=settings.danmaku.opacity, help="弹幕不透明度") + group_danmaku.add_argument( + "--danmaku-display-region-ratio", + help="弹幕显示区域与视频高度的比例", + type=float, + default=settings.danmaku.display_region_ratio, + ) + group_danmaku.add_argument("--danmaku-speed", help="弹幕速度", type=float, default=settings.danmaku.speed) + group_danmaku.add_argument( + "--danmaku-block-top", action="store_true", default=settings.danmaku.block_top, help="屏蔽顶部弹幕" + ) + group_danmaku.add_argument( + "--danmaku-block-bottom", default=settings.danmaku.block_bottom, action="store_true", help="屏蔽底部弹幕" + ) + group_danmaku.add_argument( + "--danmaku-block-scroll", default=settings.danmaku.block_scroll, action="store_true", help="屏蔽滚动弹幕" + ) + group_danmaku.add_argument( + "--danmaku-block-reverse", default=settings.danmaku.block_reverse, action="store_true", help="屏蔽逆向弹幕" + ) + group_danmaku.add_argument( + "--danmaku-block-fixed", + default=settings.danmaku.block_fixed, + action="store_true", + help="屏蔽固定弹幕(顶部、底部)", + ) + group_danmaku.add_argument( + "--danmaku-block-special", default=settings.danmaku.block_special, action="store_true", help="屏蔽高级弹幕" + ) + group_danmaku.add_argument( + "--danmaku-block-colorful", default=settings.danmaku.block_colorful, action="store_true", help="屏蔽彩色弹幕" + ) + group_danmaku.add_argument( + "--danmaku-block-keyword-patterns", + default=settings.danmaku.block_keyword_patterns, + type=lambda patterns: [pattern.strip() for pattern in patterns.split(",")], + help="屏蔽匹配关键词的弹幕,使用逗号分隔", + ) + + # 仅批量下载使用 + group_batch = parser.add_argument_group("batch", "批量下载参数") + group_batch.add_argument("-b", "--batch", action="store_true", help="批量下载") + group_batch.add_argument("-p", "--episodes", default="1~-1", help="选集") + group_batch.add_argument( + "-s", + "--with-section", + action="store_true", + default=settings.batch.with_section, + help="同时下载附加剧集(PV、预告以及特别篇等专区内容)", + ) + group_batch.add_argument( + "--batch-filter-start-time", + default=settings.batch.batch_filter_start_time, + help="只下载该时间之后(包含临界值)发布的稿件", + ) + group_batch.add_argument( + "--batch-filter-end-time", + default=settings.batch.batch_filter_end_time, + help="只下载该时间之前(不包含临界值)发布的稿件", + ) + + # 仅任务列表中使用 + group_batch_file = parser.add_argument_group("batch file", "批量下载文件参数") + group_batch_file.add_argument("--no-inherit", action="store_true", help="不继承父级参数") + + # 配置路径(占位用的,config 已经在 pre parser 里解析过了) + group_config = parser.add_argument_group("config", "配置文件参数") + group_config.add_argument("--config", help="配置文件路径") + + return parser + + +def create_select_required_action( + select: list[DownloadResourceType] | None = None, deselect: list[DownloadResourceType] | None = None +): + selected_items = select or [] + deselected_items = deselect or [] + + class SelectRequiredAction(argparse.Action): + def __init__(self, option_strings: str, dest: str, nargs: int | str | None = None, **kwargs: Any): + if nargs is not None: + raise ValueError("nargs not allowed") + super().__init__(option_strings, dest, nargs=0, **kwargs) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[str] | None, + option_string: str | None = None, + ): + for select_item in selected_items: + setattr(namespace, f"require_{select_item}", True) + for deselect_item in deselected_items: + setattr(namespace, f"require_{deselect_item}", False) + + return SelectRequiredAction + + +def invert_selection(select: list[DownloadResourceType]) -> list[DownloadResourceType]: + return [tp for tp in DOWNLOAD_RESOURCE_TYPES if tp not in select] diff --git a/src/yutto/cli/settings.py b/src/yutto/cli/settings.py new file mode 100644 index 000000000..d1538485e --- /dev/null +++ b/src/yutto/cli/settings.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import os +import platform +import sys +from pathlib import Path +from typing import Annotated, Any, Literal, Optional + +from pydantic import BaseModel, Field + +from yutto.bilibili_typing.quality import ( + AudioQuality, + VideoQuality, +) +from yutto.utils.console.logger import Logger +from yutto.utils.time import TIME_DATE_FMT + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # type: ignore + + +def xdg_config_home() -> Path: + if (env := os.environ.get("XDG_CONFIG_HOME")) and (path := Path(env)).is_absolute(): + return path + home = Path.home() + if platform.system() == "Windows": + return home / "AppData" / "Roaming" + return home / ".config" + + +class YuttoBasicSettings(BaseModel): + num_workers: Annotated[int, Field(8, gt=0)] + video_quality: Annotated[VideoQuality, Field(127)] + audio_quality: Annotated[AudioQuality, Field(30251)] + vcodec: Annotated[str, Field("avc:copy")] + acodec: Annotated[str, Field("mp4a:copy")] + download_vcodec_priority: Annotated[Optional[list[str]], Field(None)] # noqa: UP007 + output_format: Annotated[Literal["infer", "mp4", "mkv", "mov"], Field("infer")] + output_format_audio_only: Annotated[ + Literal["infer", "m4a", "aac", "mp3", "flac", "mp4", "mkv", "mov"], Field("infer") + ] + danmaku_format: Annotated[Literal["xml", "ass", "protobuf"], Field("ass")] + block_size: Annotated[float, Field(0.5)] + overwrite: Annotated[bool, Field(False)] + proxy: Annotated[str, Field("auto")] + dir: Annotated[str, Field("./")] + tmp_dir: Annotated[Optional[str], Field(None)] # noqa: UP007 + sessdata: Annotated[str, Field("")] + subpath_template: Annotated[str, Field("{auto}")] + aliases: Annotated[dict[str, str], Field({})] + metadata_format_premiered: Annotated[str, Field(TIME_DATE_FMT)] + download_interval: Annotated[int, Field(0)] + banned_mirrors_pattern: Annotated[Optional[str], Field(None)] # noqa: UP007 + no_color: Annotated[bool, Field(False)] + no_progress: Annotated[bool, Field(False)] + debug: Annotated[bool, Field(False)] + vip_strict: Annotated[bool, Field(False)] + login_strict: Annotated[bool, Field(False)] + + +class YuttoResourceSettings(BaseModel): + require_video: Annotated[bool, Field(True)] + require_audio: Annotated[bool, Field(True)] + require_subtitle: Annotated[bool, Field(True)] + require_metadata: Annotated[bool, Field(False)] + require_danmaku: Annotated[bool, Field(True)] + require_cover: Annotated[bool, Field(True)] + require_chapter_info: Annotated[bool, Field(True)] + save_cover: Annotated[bool, Field(False)] + + +class YuttoDanmakuSettings(BaseModel): + font_size: Annotated[Optional[int], Field(None)] # noqa: UP007 + font: Annotated[str, Field("SimHei")] + opacity: Annotated[float, Field(0.8)] + display_region_ratio: Annotated[float, Field(1.0)] + speed: Annotated[float, Field(1.0)] + block_top: Annotated[bool, Field(False)] + block_bottom: Annotated[bool, Field(False)] + block_scroll: Annotated[bool, Field(False)] + block_reverse: Annotated[bool, Field(False)] + block_fixed: Annotated[bool, Field(False)] + block_special: Annotated[bool, Field(False)] + block_colorful: Annotated[bool, Field(False)] + block_keyword_patterns: Annotated[list[str], Field([])] + + +class YuttoBatchSettings(BaseModel): + with_section: Annotated[bool, Field(False)] + batch_filter_start_time: Annotated[Optional[str], Field(None)] # noqa: UP007 + batch_filter_end_time: Annotated[Optional[str], Field(None)] # noqa: UP007 + + +class YuttoSettings(BaseModel): + basic: Annotated[YuttoBasicSettings, Field(YuttoBasicSettings())] # pyright: ignore[reportCallIssue] + resource: Annotated[YuttoResourceSettings, Field(YuttoResourceSettings())] # pyright: ignore[reportCallIssue] + danmaku: Annotated[YuttoDanmakuSettings, Field(YuttoDanmakuSettings())] # pyright: ignore[reportCallIssue] + batch: Annotated[YuttoBatchSettings, Field(YuttoBatchSettings())] # pyright: ignore[reportCallIssue] + + +def search_for_settings_file() -> Path | None: + settings_file = Path("yutto.toml") + # 此时还没有设置 debug,所以 Logger.debug 永远不会输出 + if not settings_file.exists(): + Logger.debug("Settings file not found in current directory.") + settings_file = xdg_config_home() / "yutto" / "yutto.toml" + if not settings_file.exists(): + Logger.debug(f"Settings file not found in XDG_CONFIG_HOME ({settings_file}).") + return None + Logger.debug(f"Settings file found at {settings_file}.") + return settings_file + + +def load_settings_file(settings_file: Path) -> YuttoSettings: + with settings_file.open("r") as f: + settings_raw: Any = tomllib.loads(f.read()) # pyright: ignore[reportUnknownMemberType] + return YuttoSettings.model_validate(settings_raw) + + +if __name__ == "__main__": + settings_file = search_for_settings_file() + assert settings_file is not None + settings = load_settings_file(settings_file) + print(settings.model_dump()) diff --git a/src/yutto/extractor/_abc.py b/src/yutto/extractor/_abc.py index c6e9da8e7..b465b41d0 100644 --- a/src/yutto/extractor/_abc.py +++ b/src/yutto/extractor/_abc.py @@ -10,6 +10,7 @@ from yutto._typing import EpisodeData from yutto.utils.asynclib import CoroutineWrapper + from yutto.utils.fetcher import FetcherContext T = TypeVar("T") @@ -26,32 +27,32 @@ def match(self, url: str) -> bool: @abstractmethod async def __call__( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: raise NotImplementedError class SingleExtractor(Extractor): async def __call__( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: - return [await self.extract(client, args)] + return [await self.extract(ctx, client, args)] @abstractmethod async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> CoroutineWrapper[EpisodeData | None] | None: raise NotImplementedError class BatchExtractor(Extractor): async def __call__( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: - return await self.extract(client, args) + return await self.extract(ctx, client, args) @abstractmethod async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: raise NotImplementedError diff --git a/src/yutto/extractor/bangumi.py b/src/yutto/extractor/bangumi.py index 521bc2f6d..1612710b6 100644 --- a/src/yutto/extractor/bangumi.py +++ b/src/yutto/extractor/bangumi.py @@ -23,6 +23,8 @@ import httpx + from yutto.utils.fetcher import FetcherContext + class BangumiExtractor(SingleExtractor): """番剧单话""" @@ -49,10 +51,10 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> CoroutineWrapper[EpisodeData | None] | None: - season_id = await get_season_id_by_episode_id(client, self.episode_id) - bangumi_list = await get_bangumi_list(client, season_id) + season_id = await get_season_id_by_episode_id(ctx, client, self.episode_id) + bangumi_list = await get_bangumi_list(ctx, client, season_id) Logger.custom(bangumi_list["title"], Badge("番剧", fore="black", back="cyan")) try: for bangumi_item in bangumi_list["pages"]: @@ -65,6 +67,7 @@ async def extract( return CoroutineWrapper( extract_bangumi_data( + ctx, client, bangumi_list_item, args, diff --git a/src/yutto/extractor/bangumi_batch.py b/src/yutto/extractor/bangumi_batch.py index 8a2e9c17b..fbb0afb5c 100644 --- a/src/yutto/extractor/bangumi_batch.py +++ b/src/yutto/extractor/bangumi_batch.py @@ -20,6 +20,8 @@ import httpx + from yutto.utils.fetcher import FetcherContext + class BangumiBatchExtractor(BatchExtractor): """番剧全集""" @@ -60,22 +62,22 @@ def match(self, url: str) -> bool: else: return False - async def _parse_ids(self, client: httpx.AsyncClient): + async def _parse_ids(self, ctx: FetcherContext, client: httpx.AsyncClient): if "episode_id" in self._match_result.groupdict().keys(): episode_id = EpisodeId(self._match_result.group("episode_id")) - self.season_id = await get_season_id_by_episode_id(client, episode_id) + self.season_id = await get_season_id_by_episode_id(ctx, client, episode_id) elif "season_id" in self._match_result.groupdict().keys(): self.season_id = SeasonId(self._match_result.group("season_id")) else: media_id = MediaId(self._match_result.group("media_id")) - self.season_id = await get_season_id_by_media_id(client, media_id) + self.season_id = await get_season_id_by_media_id(ctx, client, media_id) async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: - await self._parse_ids(client) + await self._parse_ids(ctx, client) - bangumi_list = await get_bangumi_list(client, self.season_id) + bangumi_list = await get_bangumi_list(ctx, client, self.season_id) Logger.custom(bangumi_list["title"], Badge("番剧", fore="black", back="cyan")) # 如果没有 with_section 则不需要专区内容 bangumi_list["pages"] = list( @@ -87,6 +89,7 @@ async def extract( return [ CoroutineWrapper( extract_bangumi_data( + ctx, client, bangumi_item, args, diff --git a/src/yutto/extractor/cheese.py b/src/yutto/extractor/cheese.py index d4633f8bc..be9a2a695 100644 --- a/src/yutto/extractor/cheese.py +++ b/src/yutto/extractor/cheese.py @@ -23,6 +23,8 @@ import httpx + from yutto.utils.fetcher import FetcherContext + class CheeseExtractor(SingleExtractor): """单课时""" @@ -49,10 +51,10 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> CoroutineWrapper[EpisodeData | None] | None: - season_id = await get_season_id_by_episode_id(client, self.episode_id) - cheese_list = await get_cheese_list(client, season_id) + season_id = await get_season_id_by_episode_id(ctx, client, self.episode_id) + cheese_list = await get_cheese_list(ctx, client, season_id) Logger.custom(cheese_list["title"], Badge("课程", fore="black", back="cyan")) try: for cheese_item in cheese_list["pages"]: @@ -65,6 +67,7 @@ async def extract( return CoroutineWrapper( extract_cheese_data( + ctx, client, self.episode_id, cheese_list_item, diff --git a/src/yutto/extractor/cheese_batch.py b/src/yutto/extractor/cheese_batch.py index daf9f9d99..54732b123 100644 --- a/src/yutto/extractor/cheese_batch.py +++ b/src/yutto/extractor/cheese_batch.py @@ -16,6 +16,8 @@ import httpx + from yutto.utils.fetcher import FetcherContext + class CheeseBatchExtractor(BatchExtractor): """课程全集""" @@ -48,19 +50,19 @@ def match(self, url: str) -> bool: else: return False - async def _parse_ids(self, client: httpx.AsyncClient): + async def _parse_ids(self, ctx: FetcherContext, client: httpx.AsyncClient): if "episode_id" in self._match_result.groupdict().keys(): episode_id = EpisodeId(self._match_result.group("episode_id")) - self.season_id = await get_season_id_by_episode_id(client, episode_id) + self.season_id = await get_season_id_by_episode_id(ctx, client, episode_id) else: self.season_id = SeasonId(self._match_result.group("season_id")) async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: - await self._parse_ids(client) + await self._parse_ids(ctx, client) - cheese_list = await get_cheese_list(client, self.season_id) + cheese_list = await get_cheese_list(ctx, client, self.season_id) Logger.custom(cheese_list["title"], Badge("课程", fore="black", back="cyan")) # 选集过滤 episodes = parse_episodes_selection(args.episodes, len(cheese_list["pages"])) @@ -68,6 +70,7 @@ async def extract( return [ CoroutineWrapper( extract_cheese_data( + ctx, client, cheese_item["episode_id"], cheese_item, diff --git a/src/yutto/extractor/collection.py b/src/yutto/extractor/collection.py index d26003dcb..a8d5d856f 100644 --- a/src/yutto/extractor/collection.py +++ b/src/yutto/extractor/collection.py @@ -14,7 +14,7 @@ from yutto.processor.selector import parse_episodes_selection from yutto.utils.asynclib import CoroutineWrapper from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.filter import Filter if TYPE_CHECKING: @@ -26,24 +26,25 @@ class CollectionExtractor(BatchExtractor): """视频合集""" - REGEX_COLLECTIOM = re.compile( - r"https?://space\.bilibili\.com/(?P\d+)/channel/collectiondetail\?sid=(?P\d+)" - ) - REGEX_COLLECTION_MEDIA_LIST = re.compile( - r"https?://www\.bilibili\.com/medialist/play/(?P\d+)\?business=space_collection&business_id=(?P\d+)" + REGEX_COLLECTION_LISTS = re.compile( + r"https?://space\.bilibili\.com/(?P\d+)/lists/(?P\d+)\?type=season" ) - REGEX_COLLECTION_FAV_PAGE = re.compile( + # 订阅合集后,在个人空间的收藏夹页面 + REGEX_COLLECTION_FAV_PAGE: re.Pattern[str] = re.compile( r"https?://space\.bilibili\.com/(?P\d+)/favlist\?fid=(?P\d+)&ftype=collect" ) + REGEX_COLLECTIOM_LEGACY = re.compile( + r"https?://space\.bilibili\.com/(?P\d+)/channel/collectiondetail\?sid=(?P\d+)" + ) mid: MId series_id: SeriesId def match(self, url: str) -> bool: if ( - (match_obj := self.REGEX_COLLECTION_MEDIA_LIST.match(url)) - or (match_obj := self.REGEX_COLLECTIOM.match(url)) + (match_obj := self.REGEX_COLLECTION_LISTS.match(url)) or (match_obj := self.REGEX_COLLECTION_FAV_PAGE.match(url)) + or (match_obj := self.REGEX_COLLECTIOM_LEGACY.match(url)) ): self.mid = MId(match_obj.group("mid")) self.series_id = SeriesId(match_obj.group("series_id")) @@ -52,11 +53,11 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: username, collection_details = await asyncio.gather( - get_user_name(client, self.mid), - get_collection_details(client, self.series_id, self.mid), + get_user_name(ctx, client, self.mid), + get_collection_details(ctx, client, self.series_id, self.mid), ) collection_title = collection_details["title"] Logger.custom(collection_title, Badge("视频合集", fore="black", back="cyan")) @@ -70,11 +71,11 @@ async def extract( for item in collection_details["pages"]: try: avid = item["avid"] - ugc_video_list = await get_ugc_video_list(client, avid) + ugc_video_list = await get_ugc_video_list(ctx, client, avid) if not Filter.verify_timer(ugc_video_list["pubdate"]): Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") continue - await Fetcher.touch_url(client, avid.to_url()) + await Fetcher.touch_url(ctx, client, avid.to_url()) if len(ugc_video_list["pages"]) != 1: Logger.error(f"视频合集 {collection_title} 中的视频 {item['avid']} 包含多个视频!") for ugc_video_item in ugc_video_list["pages"]: @@ -92,6 +93,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/extractor/common.py b/src/yutto/extractor/common.py index 4016e23a5..1ae4bf09c 100644 --- a/src/yutto/extractor/common.py +++ b/src/yutto/extractor/common.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING from yutto._typing import AvId, EpisodeData, EpisodeId, format_ids @@ -30,16 +29,18 @@ ) from yutto.utils.console.logger import Logger from yutto.utils.danmaku import EmptyDanmakuData -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.metadata import attach_chapter_info if TYPE_CHECKING: import argparse + from pathlib import Path import httpx async def extract_bangumi_data( + ctx: FetcherContext, client: httpx.AsyncClient, bangumi_info: BangumiListItem, args: argparse.Namespace, @@ -54,16 +55,20 @@ async def extract_bangumi_data( if bangumi_info["is_preview"]: Logger.warning(f"视频({format_ids(avid, cid)})是预览视频(疑似未登录或非大会员用户)") videos, audios = ( - await get_bangumi_playurl(client, avid, cid) if args.require_video or args.require_audio else ([], []) + await get_bangumi_playurl(ctx, client, avid, cid) if args.require_video or args.require_audio else ([], []) ) - subtitles = await get_bangumi_subtitles(client, avid, cid) if args.require_subtitle else [] + subtitles = await get_bangumi_subtitles(ctx, client, avid, cid) if args.require_subtitle else [] danmaku = ( - await get_danmaku(client, cid, avid, args.danmaku_format) if args.require_danmaku else EmptyDanmakuData + await get_danmaku(ctx, client, cid, avid, args.danmaku_format) if args.require_danmaku else EmptyDanmakuData ) metadata = bangumi_info["metadata"] if args.require_metadata else None - cover_data = await Fetcher.fetch_bin(client, bangumi_info["metadata"]["thumb"]) if args.require_cover else None + cover_data = ( + await Fetcher.fetch_bin(ctx, client, bangumi_info["metadata"]["thumb"]) if args.require_cover else None + ) subpath_variables_base: PathTemplateVariableDict = { "id": id, + "aid": str(avid.as_aid()), + "bvid": str(avid.as_bvid()), "name": name, "title": UNKNOWN, "username": UNKNOWN, @@ -74,8 +79,8 @@ async def extract_bangumi_data( } subpath_variables_base.update(subpath_variables) subpath = resolve_path_template(args.subpath_template, auto_subpath_template, subpath_variables_base) - file_path = Path(args.dir, subpath) - output_dir, filename = str(file_path.parent), file_path.name + file_path: Path = args.dir / subpath + output_dir, filename = file_path.parent, file_path.name return EpisodeData( videos=videos, audios=audios, @@ -94,6 +99,7 @@ async def extract_bangumi_data( async def extract_cheese_data( + ctx: FetcherContext, client: httpx.AsyncClient, episode_id: EpisodeId, cheese_info: CheeseListItem, @@ -107,18 +113,22 @@ async def extract_cheese_data( name = cheese_info["name"] id = cheese_info["id"] videos, audios = ( - await get_cheese_playurl(client, avid, episode_id, cid) + await get_cheese_playurl(ctx, client, avid, episode_id, cid) if args.require_video or args.require_audio else ([], []) ) - subtitles = await get_cheese_subtitles(client, avid, cid) if args.require_subtitle else [] + subtitles = await get_cheese_subtitles(ctx, client, avid, cid) if args.require_subtitle else [] danmaku = ( - await get_danmaku(client, cid, avid, args.danmaku_format) if args.require_danmaku else EmptyDanmakuData + await get_danmaku(ctx, client, cid, avid, args.danmaku_format) if args.require_danmaku else EmptyDanmakuData ) metadata = cheese_info["metadata"] if args.require_metadata else None - cover_data = await Fetcher.fetch_bin(client, cheese_info["metadata"]["thumb"]) if args.require_cover else None + cover_data = ( + await Fetcher.fetch_bin(ctx, client, cheese_info["metadata"]["thumb"]) if args.require_cover else None + ) subpath_variables_base: PathTemplateVariableDict = { "id": id, + "aid": str(avid.as_aid()), + "bvid": str(avid.as_bvid()), "name": name, "title": UNKNOWN, "username": UNKNOWN, @@ -129,8 +139,8 @@ async def extract_cheese_data( } subpath_variables_base.update(subpath_variables) subpath = resolve_path_template(args.subpath_template, auto_subpath_template, subpath_variables_base) - file_path = Path(args.dir, subpath) - output_dir, filename = str(file_path.parent), file_path.name + file_path: Path = args.dir / subpath + output_dir, filename = file_path.parent, file_path.name return EpisodeData( videos=videos, audios=audios, @@ -149,6 +159,7 @@ async def extract_cheese_data( async def extract_ugc_video_data( + ctx: FetcherContext, client: httpx.AsyncClient, avid: AvId, ugc_video_info: UgcVideoListItem, @@ -161,29 +172,36 @@ async def extract_ugc_video_data( name = ugc_video_info["name"] id = ugc_video_info["id"] videos, audios = ( - await get_ugc_video_playurl(client, avid, cid) if args.require_video or args.require_audio else ([], []) + await get_ugc_video_playurl(ctx, client, avid, cid) + if args.require_video or args.require_audio + else ([], []) ) - subtitles = await get_ugc_video_subtitles(client, avid, cid) if args.require_subtitle else [] - chapter_info_data = await get_ugc_video_chapters(client, avid, cid) if args.require_chapter_info else [] + subtitles = await get_ugc_video_subtitles(ctx, client, avid, cid) if args.require_subtitle else [] + chapter_info_data = await get_ugc_video_chapters(ctx, client, avid, cid) if args.require_chapter_info else [] danmaku = ( - await get_danmaku(client, cid, avid, args.danmaku_format) if args.require_danmaku else EmptyDanmakuData + await get_danmaku(ctx, client, cid, avid, args.danmaku_format) if args.require_danmaku else EmptyDanmakuData ) metadata = ugc_video_info["metadata"] if args.require_metadata else None if metadata and chapter_info_data: attach_chapter_info(metadata, chapter_info_data) cover_data = ( - await Fetcher.fetch_bin(client, ugc_video_info["metadata"]["thumb"]) if args.require_cover else None + await Fetcher.fetch_bin(ctx, client, ugc_video_info["metadata"]["thumb"]) if args.require_cover else None ) owner_uid: str = ( ugc_video_info["metadata"]["actor"][0]["profile"].split("/")[-1] if ugc_video_info["metadata"]["actor"] else UNKNOWN ) + username: str = ( + ugc_video_info["metadata"]["actor"][0]["name"] if ugc_video_info["metadata"]["actor"] else UNKNOWN + ) subpath_variables_base: PathTemplateVariableDict = { "id": id, + "aid": str(avid.as_aid()), + "bvid": str(avid.as_bvid()), "name": name, "title": UNKNOWN, - "username": UNKNOWN, + "username": username, "series_title": UNKNOWN, "pubdate": UNKNOWN, "download_date": ugc_video_info["metadata"]["dateadded"], @@ -191,8 +209,8 @@ async def extract_ugc_video_data( } subpath_variables_base.update(subpath_variables) subpath = resolve_path_template(args.subpath_template, auto_subpath_template, subpath_variables_base) - file_path = Path(args.dir, subpath) - output_dir, filename = str(file_path.parent), file_path.name + file_path: Path = args.dir / subpath + output_dir, filename = file_path.parent, file_path.name return EpisodeData( videos=videos, audios=audios, diff --git a/src/yutto/extractor/favourites.py b/src/yutto/extractor/favourites.py index dbcdc05d9..0262c0018 100644 --- a/src/yutto/extractor/favourites.py +++ b/src/yutto/extractor/favourites.py @@ -12,7 +12,7 @@ from yutto.extractor.common import extract_ugc_video_data from yutto.utils.asynclib import CoroutineWrapper from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.filter import Filter if TYPE_CHECKING: @@ -38,25 +38,25 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: username, favourite_info = await asyncio.gather( - get_user_name(client, self.mid), - get_favourite_info(client, self.fid), + get_user_name(ctx, client, self.mid), + get_favourite_info(ctx, client, self.fid), ) Logger.custom(favourite_info["title"], Badge("收藏夹", fore="black", back="cyan")) ugc_video_info_list: list[tuple[UgcVideoListItem, str, int]] = [] - for avid in await get_favourite_avids(client, self.fid): + for avid in await get_favourite_avids(ctx, client, self.fid): try: - ugc_video_list = await get_ugc_video_list(client, avid) + ugc_video_list = await get_ugc_video_list(ctx, client, avid) # 在使用 SESSDATA 时,如果不去事先 touch 一下视频链接的话,是无法获取 episode_data 的 # 至于为什么前面那俩(投稿视频页和番剧页)不需要额外 touch,因为在 get_redirected_url 阶段连接过了呀 if not Filter.verify_timer(ugc_video_list["pubdate"]): Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") continue - await Fetcher.touch_url(client, avid.to_url()) + await Fetcher.touch_url(ctx, client, avid.to_url()) for ugc_video_item in ugc_video_list["pages"]: ugc_video_info_list.append( ( @@ -72,6 +72,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/extractor/series.py b/src/yutto/extractor/series.py index 9b81fc482..daab8147f 100644 --- a/src/yutto/extractor/series.py +++ b/src/yutto/extractor/series.py @@ -12,7 +12,7 @@ from yutto.extractor.common import extract_ugc_video_data from yutto.utils.asynclib import CoroutineWrapper from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.filter import Filter if TYPE_CHECKING: @@ -24,18 +24,21 @@ class SeriesExtractor(BatchExtractor): """视频列表""" - REGEX_SERIES = re.compile( + REGEX_SERIES_LISTS = re.compile(r"https?://space\.bilibili\.com/(?P\d+)/lists/(?P\d+)\?type=series") + REGEX_SERIES_LEGACY: re.Pattern[str] = re.compile( r"https?://space\.bilibili\.com/(?P\d+)/channel/seriesdetail\?sid=(?P\d+)" ) - REGEX_SERIES_MEDIA_LIST = re.compile( - r"https?://www\.bilibili\.com/medialist/play/(?P\d+)\?business=space_series&business_id=(?P\d+)" - ) + REGEX_SERIES_PLAYLIST = re.compile(r"https?://www\.bilibili\.com/list/(?P\d+)\?sid=(?P\d+)") mid: MId series_id: SeriesId def match(self, url: str) -> bool: - if (match_obj := self.REGEX_SERIES_MEDIA_LIST.match(url)) or (match_obj := self.REGEX_SERIES.match(url)): + if ( + (match_obj := self.REGEX_SERIES_LISTS.match(url)) + or (match_obj := self.REGEX_SERIES_LEGACY.match(url)) + or (match_obj := self.REGEX_SERIES_PLAYLIST.match(url)) + ): self.mid = MId(match_obj.group("mid")) self.series_id = SeriesId(match_obj.group("series_id")) return True @@ -43,21 +46,21 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: username, series_title = await asyncio.gather( - get_user_name(client, self.mid), get_medialist_title(client, self.series_id) + get_user_name(ctx, client, self.mid), get_medialist_title(ctx, client, self.series_id) ) Logger.custom(series_title, Badge("视频列表", fore="black", back="cyan")) ugc_video_info_list: list[tuple[UgcVideoListItem, str, int]] = [] - for avid in await get_medialist_avids(client, self.series_id, self.mid): + for avid in await get_medialist_avids(ctx, client, self.series_id, self.mid): try: - ugc_video_list = await get_ugc_video_list(client, avid) + ugc_video_list = await get_ugc_video_list(ctx, client, avid) if not Filter.verify_timer(ugc_video_list["pubdate"]): Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") continue - await Fetcher.touch_url(client, avid.to_url()) + await Fetcher.touch_url(ctx, client, avid.to_url()) for ugc_video_item in ugc_video_list["pages"]: ugc_video_info_list.append( ( @@ -73,6 +76,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/extractor/ugc_video.py b/src/yutto/extractor/ugc_video.py index e36d5166b..07591829a 100644 --- a/src/yutto/extractor/ugc_video.py +++ b/src/yutto/extractor/ugc_video.py @@ -21,6 +21,8 @@ import httpx + from yutto.utils.fetcher import FetcherContext + class UgcVideoExtractor(SingleExtractor): """投稿视频单视频""" @@ -71,14 +73,15 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> CoroutineWrapper[EpisodeData | None] | None: try: - ugc_video_list = await get_ugc_video_list(client, self.avid) + ugc_video_list = await get_ugc_video_list(ctx, client, self.avid) self.avid = ugc_video_list["avid"] # 当视频撞车时,使用新的 avid 替代原有 avid,见 #96 Logger.custom(ugc_video_list["title"], Badge("投稿视频", fore="black", back="cyan")) return CoroutineWrapper( extract_ugc_video_data( + ctx, client, self.avid, ugc_video_list["pages"][self.page - 1], diff --git a/src/yutto/extractor/ugc_video_batch.py b/src/yutto/extractor/ugc_video_batch.py index 0d130b778..7f855b4bc 100644 --- a/src/yutto/extractor/ugc_video_batch.py +++ b/src/yutto/extractor/ugc_video_batch.py @@ -17,6 +17,8 @@ import httpx + from yutto.utils.fetcher import FetcherContext + class UgcVideoBatchExtractor(BatchExtractor): """投稿视频批下载""" @@ -63,10 +65,10 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: try: - ugc_video_list = await get_ugc_video_list(client, self.avid) + ugc_video_list = await get_ugc_video_list(ctx, client, self.avid) Logger.custom(ugc_video_list["title"], Badge("投稿视频", fore="black", back="cyan")) except NotFoundError as e: # 由于获取 info 时候也会因为视频不存在而报错,因此这里需要捕捉下 @@ -80,6 +82,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/extractor/user_all_favourites.py b/src/yutto/extractor/user_all_favourites.py index 227c98964..c210a320d 100644 --- a/src/yutto/extractor/user_all_favourites.py +++ b/src/yutto/extractor/user_all_favourites.py @@ -11,7 +11,7 @@ from yutto.extractor.common import extract_ugc_video_data from yutto.utils.asynclib import CoroutineWrapper from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.filter import Filter if TYPE_CHECKING: @@ -35,23 +35,23 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: - username = await get_user_name(client, self.mid) + username = await get_user_name(ctx, client, self.mid) Logger.custom(username, Badge("用户收藏夹", fore="black", back="cyan")) ugc_video_info_list: list[tuple[UgcVideoListItem, str, int, str]] = [] - for fav in await get_all_favourites(client, self.mid): + for fav in await get_all_favourites(ctx, client, self.mid): series_title = fav["title"] fid = fav["fid"] - for avid in await get_favourite_avids(client, fid): + for avid in await get_favourite_avids(ctx, client, fid): try: - ugc_video_list = await get_ugc_video_list(client, avid) + ugc_video_list = await get_ugc_video_list(ctx, client, avid) if not Filter.verify_timer(ugc_video_list["pubdate"]): Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") continue - await Fetcher.touch_url(client, avid.to_url()) + await Fetcher.touch_url(ctx, client, avid.to_url()) for ugc_video_item in ugc_video_list["pages"]: ugc_video_info_list.append( ( @@ -68,6 +68,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/extractor/user_all_ugc_videos.py b/src/yutto/extractor/user_all_ugc_videos.py index 822250bf5..9364197c1 100644 --- a/src/yutto/extractor/user_all_ugc_videos.py +++ b/src/yutto/extractor/user_all_ugc_videos.py @@ -11,7 +11,7 @@ from yutto.extractor.common import extract_ugc_video_data from yutto.utils.asynclib import CoroutineWrapper from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.filter import Filter if TYPE_CHECKING: @@ -35,19 +35,19 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: - username = await get_user_name(client, self.mid) + username = await get_user_name(ctx, client, self.mid) Logger.custom(username, Badge("UP 主投稿视频", fore="black", back="cyan")) ugc_video_info_list: list[tuple[UgcVideoListItem, str, int]] = [] - for avid in await get_user_space_all_videos_avids(client, self.mid): + for avid in await get_user_space_all_videos_avids(ctx, client, self.mid): try: - ugc_video_list = await get_ugc_video_list(client, avid) + ugc_video_list = await get_ugc_video_list(ctx, client, avid) if not Filter.verify_timer(ugc_video_list["pubdate"]): Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") continue - await Fetcher.touch_url(client, avid.to_url()) + await Fetcher.touch_url(ctx, client, avid.to_url()) for ugc_video_item in ugc_video_list["pages"]: ugc_video_info_list.append( ( @@ -63,6 +63,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/extractor/user_watch_later.py b/src/yutto/extractor/user_watch_later.py index 66fbe10ed..af9f6256f 100644 --- a/src/yutto/extractor/user_watch_later.py +++ b/src/yutto/extractor/user_watch_later.py @@ -10,7 +10,7 @@ from yutto.extractor.common import extract_ugc_video_data from yutto.utils.asynclib import CoroutineWrapper from yutto.utils.console.logger import Badge, Logger -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.filter import Filter if TYPE_CHECKING: @@ -33,25 +33,25 @@ def match(self, url: str) -> bool: return False async def extract( - self, client: httpx.AsyncClient, args: argparse.Namespace + self, ctx: FetcherContext, client: httpx.AsyncClient, args: argparse.Namespace ) -> list[CoroutineWrapper[EpisodeData | None] | None]: Logger.custom("当前用户", Badge("稍后再看", fore="black", back="cyan")) ugc_video_info_list: list[tuple[UgcVideoListItem, str, int, str]] = [] try: - avid_list = await get_watch_later_avids(client) + avid_list = await get_watch_later_avids(ctx, client) except NotLoginError as e: Logger.error(e.message) return [] for avid in avid_list: try: - ugc_video_list = await get_ugc_video_list(client, avid) + ugc_video_list = await get_ugc_video_list(ctx, client, avid) if not Filter.verify_timer(ugc_video_list["pubdate"]): Logger.debug(f"因为发布时间为 {ugc_video_list['pubdate']},跳过 {ugc_video_list['title']}") continue - await Fetcher.touch_url(client, avid.to_url()) + await Fetcher.touch_url(ctx, client, avid.to_url()) for ugc_video_item in ugc_video_list["pages"]: ugc_video_info_list.append( ( @@ -68,6 +68,7 @@ async def extract( return [ CoroutineWrapper( extract_ugc_video_data( + ctx, client, ugc_video_item["avid"], ugc_video_item, diff --git a/src/yutto/processor/downloader.py b/src/yutto/processor/downloader.py index 66420c96d..a6c5a2d5a 100644 --- a/src/yutto/processor/downloader.py +++ b/src/yutto/processor/downloader.py @@ -4,17 +4,16 @@ import os import re from enum import Enum -from pathlib import Path from typing import TYPE_CHECKING, Callable from yutto.bilibili_typing.quality import audio_quality_map, video_quality_map from yutto.processor.progressbar import show_progress from yutto.processor.selector import select_audio, select_video -from yutto.utils.asynclib import CoroutineWrapper +from yutto.utils.asynclib import CoroutineWrapper, first_successful_with_check from yutto.utils.console.colorful import colored_string from yutto.utils.console.logger import Badge, Logger from yutto.utils.danmaku import write_danmaku -from yutto.utils.fetcher import Fetcher +from yutto.utils.fetcher import Fetcher, FetcherContext from yutto.utils.ffmpeg import FFmpeg, FFmpegCommandBuilder from yutto.utils.file_buffer import AsyncFileBuffer from yutto.utils.funcutils import filter_none_value, xmerge @@ -22,6 +21,8 @@ from yutto.utils.subtitle import write_subtitle if TYPE_CHECKING: + from pathlib import Path + import httpx from yutto._typing import AudioUrlMeta, DownloaderOptions, EpisodeData, VideoUrlMeta @@ -105,6 +106,7 @@ def mirrors_filter(mirrors: list[str]) -> list[str]: async def download_video_and_audio( + ctx: FetcherContext, client: httpx.AsyncClient, video: VideoUrlMeta | None, video_path: Path, @@ -118,13 +120,16 @@ async def download_video_and_audio( sizes: list[int | None] = [None, None] coroutines_list: list[list[CoroutineWrapper[None]]] = [] mirrors_filter = create_mirrors_filter(options["banned_mirrors_pattern"]) - Fetcher.set_semaphore(options["num_workers"]) + ctx.set_download_semaphore(options["num_workers"]) if video is not None: vbuf = await AsyncFileBuffer(video_path, overwrite=options["overwrite"]) - vsize = await Fetcher.get_size(client, video["url"]) + vsize = await first_successful_with_check( + [Fetcher.get_size(ctx, client, url) for url in [video["url"], *mirrors_filter(video["mirrors"])]] + ) video_coroutines = [ CoroutineWrapper( Fetcher.download_file_with_offset( + ctx, client, video["url"], mirrors_filter(video["mirrors"]), @@ -140,10 +145,13 @@ async def download_video_and_audio( if audio is not None: abuf = await AsyncFileBuffer(audio_path, overwrite=options["overwrite"]) - asize = await Fetcher.get_size(client, audio["url"]) + asize = await first_successful_with_check( + [Fetcher.get_size(ctx, client, url) for url in [audio["url"], *mirrors_filter(audio["mirrors"])]] + ) audio_coroutines = [ CoroutineWrapper( Fetcher.download_file_with_offset( + ctx, client, audio["url"], mirrors_filter(audio["mirrors"]), @@ -243,10 +251,10 @@ def merge_video_and_audio( video_path.unlink() if audio is not None: audio_path.unlink() - if cover_data is not None: - cover_path.unlink() if chapter_info_data: chapter_info_path.unlink() + if cover_data is not None and not options["save_cover"]: + cover_path.unlink() class DownloadState(Enum): @@ -255,6 +263,7 @@ class DownloadState(Enum): async def start_downloader( + ctx: FetcherContext, client: httpx.AsyncClient, episode_data: EpisodeData, options: DownloaderOptions, @@ -268,18 +277,19 @@ async def start_downloader( metadata = episode_data["metadata"] cover_data = episode_data["cover_data"] chapter_info_data = episode_data["chapter_info_data"] - output_dir = Path(episode_data["output_dir"]) - tmp_dir = Path(episode_data["tmp_dir"]) + output_dir = episode_data["output_dir"] + tmp_dir = episode_data["tmp_dir"] filename = episode_data["filename"] require_video = options["require_video"] require_audio = options["require_audio"] metadata_format = options["metadata_format"] + danmaku_options = options["danmaku_options"] Logger.info(f"开始处理视频 {filename}") tmp_dir.mkdir(parents=True, exist_ok=True) video_path = tmp_dir.joinpath(filename + "_video.m4s") audio_path = tmp_dir.joinpath(filename + "_audio.m4s") - cover_path = tmp_dir.joinpath(filename + "_cover.jpg") + cover_path = tmp_dir.joinpath(filename + "-poster.jpg") chapter_info_path = tmp_dir.joinpath(filename + "_chapter_info.ini") video = select_video( @@ -307,7 +317,7 @@ async def start_downloader( elif will_download_audio and audio["codec"] == "flac": # pyright: ignore [reportOptionalSubscript] output_format = ".flac" else: - output_format = ".aac" + output_format = ".m4a" else: if options["output_format"] != "infer": output_format = "." + options["output_format"] @@ -332,6 +342,7 @@ async def start_downloader( str(output_path), video["height"] if video is not None else 1080, # 未下载视频时自动按照 1920x1080 处理 video["width"] if video is not None else 1920, + danmaku_options, ) Logger.custom( "{} 弹幕已生成".format(danmaku["save_type"]).upper(), badge=Badge("弹幕", fore="black", back="cyan") @@ -342,6 +353,12 @@ async def start_downloader( write_metadata(metadata, output_path, metadata_format) Logger.custom("NFO 媒体描述文件已生成", badge=Badge("描述文件", fore="black", back="cyan")) + # 保存封面 + if cover_data is not None: + cover_path.write_bytes(cover_data) + if options["save_cover"] or (not will_download_video and not will_download_audio): + Logger.custom("封面已生成", badge=Badge("封面", fore="black", back="cyan")) + if output_path.exists(): if not options["overwrite"]: Logger.info(f"文件 {filename} 已存在") @@ -361,12 +378,8 @@ async def start_downloader( if chapter_info_data: write_chapter_info(filename, chapter_info_data, chapter_info_path) - # 保存封面 - if cover_data is not None: - cover_path.write_bytes(cover_data) - # 下载视频 / 音频 - await download_video_and_audio(client, video, video_path, audio, audio_path, options) + await download_video_and_audio(ctx, client, video, video_path, audio, audio_path, options) # 合并视频 / 音频 merge_video_and_audio( diff --git a/src/yutto/processor/parser.py b/src/yutto/processor/parser.py index cede0595d..aa75772de 100644 --- a/src/yutto/processor/parser.py +++ b/src/yutto/processor/parser.py @@ -4,11 +4,15 @@ import urllib import urllib.request from pathlib import Path -from typing import TextIO from yutto.utils.console.logger import Logger +def path_from_cli(path: str) -> Path: + """从命令行参数获取路径,支持 ~,以便配置中使用 ~""" + return Path(path).expanduser() + + def is_comment(line: str) -> bool: """判断文件某行是否为注释""" if line.startswith("#"): @@ -16,24 +20,22 @@ def is_comment(line: str) -> bool: return False -def alias_parser(f_alias: TextIO | None) -> dict[str, str]: - if f_alias is None: - return {} - f_alias.seek(0) +def alias_parser(file_path: str) -> dict[str, str]: result: dict[str, str] = {} re_alias_splitter = re.compile(r"[\s=]") - for line in f_alias: - line = line.strip() - if not line or is_comment(line): - continue - alias, url = re_alias_splitter.split(line, maxsplit=1) - result[alias] = url + with path_from_cli(file_path).open("r") as f_alias: + for line in f_alias: + line = line.strip() + if not line or is_comment(line): + continue + alias, url = re_alias_splitter.split(line, maxsplit=1) + result[alias] = url return result def file_scheme_parser(url: str) -> list[str]: file_url: str = urllib.parse.urlparse(url).path # type: ignore - file_path = Path(urllib.request.url2pathname(file_url)) # type: ignore + file_path = path_from_cli(urllib.request.url2pathname(file_url)) # type: ignore Logger.info(f"解析下载列表 {file_path} 中...") result: list[str] = [] with file_path.open("r", encoding="utf-8") as f: diff --git a/src/yutto/processor/path_resolver.py b/src/yutto/processor/path_resolver.py index efc62718e..1d3e8b5ea 100644 --- a/src/yutto/processor/path_resolver.py +++ b/src/yutto/processor/path_resolver.py @@ -9,7 +9,7 @@ from yutto.utils.time import get_time_str_by_stamp PathTemplateVariable = Literal[ - "title", "id", "name", "username", "series_title", "pubdate", "download_date", "owner_uid" + "title", "id", "aid", "bvid", "name", "username", "series_title", "pubdate", "download_date", "owner_uid" ] PathTemplateVariableDict = dict[PathTemplateVariable, Union[int, str]] UNKNOWN: str = "unknown_variable" diff --git a/src/yutto/utils/asynclib.py b/src/yutto/utils/asynclib.py index ce1d15432..da57d65df 100644 --- a/src/yutto/utils/asynclib.py +++ b/src/yutto/utils/asynclib.py @@ -12,7 +12,7 @@ from yutto.utils.console.logger import Logger if TYPE_CHECKING: - from collections.abc import Callable, Coroutine, Generator + from collections.abc import Callable, Coroutine, Generator, Iterable RetT = TypeVar("RetT") P = ParamSpec("P") @@ -66,3 +66,24 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> RetT: return wrapper return decorator + + +async def first_successful(coros: Iterable[Coroutine[Any, Any, RetT]]) -> list[RetT]: + tasks = [asyncio.create_task(coro) for coro in coros] + + results: list[RetT] = [] + while not results: + done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + results = [task.result() for task in done if task.exception() is None] + for task in tasks: + task.cancel() + return results + + +async def first_successful_with_check(coros: Iterable[Coroutine[Any, Any, RetT]]) -> RetT: + results = await first_successful(coros) + if not results: + raise Exception("All coroutines failed") + if len(set(results)) != 1: + raise Exception("Multiple coroutines returned different results") + return results[0] diff --git a/src/yutto/utils/danmaku.py b/src/yutto/utils/danmaku.py index 5f9f0c8f7..85a593be9 100644 --- a/src/yutto/utils/danmaku.py +++ b/src/yutto/utils/danmaku.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Literal, TypedDict, Union -from biliass import convert_to_ass +from biliass import BlockOptions, convert_to_ass DanmakuSourceType = Literal["xml", "protobuf"] DanmakuSaveType = Literal["xml", "ass", "protobuf"] @@ -13,6 +13,15 @@ DanmakuSourceDataType = Union[DanmakuSourceDataXml, DanmakuSourceDataProtobuf] +class DanmakuOptions(TypedDict): + font_size: int | None + font: str + opacity: float + display_region_ratio: float + speed: float + block_options: BlockOptions + + class DanmakuData(TypedDict): source_type: DanmakuSourceType | None save_type: DanmakuSaveType | None @@ -38,6 +47,7 @@ def write_ass_danmaku( filepath: Path, height: int, width: int, + options: DanmakuOptions, ): with filepath.open( "w", @@ -50,19 +60,25 @@ def write_ass_danmaku( width, height, input_format=input_format, - reserve_blank=0, - font_face="SimHei", - font_size=width / 40, - text_opacity=0.8, - duration_marquee=15.0, - duration_still=10.0, - comment_filter=None, - is_reduce_comments=False, + display_region_ratio=options["display_region_ratio"], + font_face=options["font"], + font_size=options["font_size"] if options["font_size"] is not None else width / 40, + text_opacity=options["opacity"], + duration_marquee=8.0 / options["speed"], + duration_still=5.0 / options["speed"], + block_options=options["block_options"], + reduce_comments=True, ) ) -def write_danmaku(danmaku: DanmakuData, video_path: str | Path, height: int, width: int) -> str | None: +def write_danmaku( + danmaku: DanmakuData, + video_path: str | Path, + height: int, + width: int, + options: DanmakuOptions, +) -> str | None: video_path = Path(video_path) video_name = video_path.stem if danmaku["source_type"] == "xml": @@ -73,7 +89,7 @@ def write_danmaku(danmaku: DanmakuData, video_path: str | Path, height: int, wid write_xml_danmaku(xml_danmaku[0], file_path) elif danmaku["save_type"] == "ass": file_path = video_path.with_suffix(".ass") - write_ass_danmaku(xml_danmaku, "xml", file_path, height, width) + write_ass_danmaku(xml_danmaku, "xml", file_path, height, width, options) else: return None elif danmaku["source_type"] == "protobuf": @@ -81,7 +97,7 @@ def write_danmaku(danmaku: DanmakuData, video_path: str | Path, height: int, wid assert isinstance(protobuf_danmaku[0], bytes) if danmaku["save_type"] == "ass": file_path = video_path.with_suffix(".ass") - write_ass_danmaku(protobuf_danmaku, "protobuf", file_path, height, width) + write_ass_danmaku(protobuf_danmaku, "protobuf", file_path, height, width, options) elif danmaku["save_type"] == "protobuf": if len(protobuf_danmaku) == 1: file_path = video_path.with_suffix(".pb") diff --git a/src/yutto/utils/fetcher.py b/src/yutto/utils/fetcher.py index a02866155..088501c1c 100644 --- a/src/yutto/utils/fetcher.py +++ b/src/yutto/utils/fetcher.py @@ -2,14 +2,20 @@ import asyncio import random +from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, Callable, TypeVar from urllib.parse import quote, unquote +# Temporary fix for h2 stubs not found error by using `type: ignore`, +# it may be fixed in the next release. The key PR https://github.com/python-hyper/h2/pull/1289 +# has been merged in the master branch +import h2.exceptions # type: ignore import httpx from httpx import AsyncClient from typing_extensions import ParamSpec from yutto.exceptions import MaxRetryError +from yutto.utils.asynclib import async_cache from yutto.utils.console.logger import Logger if TYPE_CHECKING: @@ -58,55 +64,87 @@ async def connect_n_times(*args: InputT.args, **kwargs: InputT.kwargs) -> RetT: DEFAULT_PROXY = None DEFAULT_TRUST_ENV = True DEFAULT_HEADERS: dict[str, str] = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36", "Referer": "https://www.bilibili.com", } DEFAULT_COOKIES = httpx.Cookies() -class Fetcher: - proxy: str | None = DEFAULT_PROXY - trust_env: bool = DEFAULT_TRUST_ENV - headers: dict[str, str] = DEFAULT_HEADERS - cookies: httpx.Cookies = DEFAULT_COOKIES - # 初始使用较小的信号量用于抓取信息,下载时会重新设置一个较大的值 - semaphore: asyncio.Semaphore = asyncio.Semaphore(8) - _touch_set: set[str] = set() - - @classmethod - def set_proxy(cls, proxy: str): +class FetcherContext: + proxy: str | None + trust_env: bool + headers: dict[str, str] + cookies: httpx.Cookies + fetch_semaphore: asyncio.Semaphore | None + download_semaphore: asyncio.Semaphore | None + + def __init__( + self, + *, + proxy: str | None = DEFAULT_PROXY, + trust_env: bool = DEFAULT_TRUST_ENV, + headers: dict[str, str] = DEFAULT_HEADERS, + cookies: httpx.Cookies = DEFAULT_COOKIES, + ): + self.proxy = proxy + self.trust_env = trust_env + self.headers = headers + self.cookies = cookies + self.fetch_semaphore = None + self.download_semaphore = None + + def set_fetch_semaphore(self, fetch_workers: int): + self.fetch_semaphore = asyncio.Semaphore(fetch_workers) + + def set_download_semaphore(self, download_workers: int): + self.download_semaphore = asyncio.Semaphore(download_workers) + + def set_sessdata(self, sessdata: str): + self.cookies = httpx.Cookies() + # 先解码后编码是防止获取到的 SESSDATA 是已经解码后的(包含「,」) + # 而番剧无法使用解码后的 SESSDATA + self.cookies.set("SESSDATA", quote(unquote(sessdata))) + + def set_proxy(self, proxy: str): if proxy == "auto": - Fetcher.proxy = None - Fetcher.trust_env = True + self.proxy = None + self.trust_env = True elif proxy == "no": - Fetcher.proxy = None - Fetcher.trust_env = False + self.proxy = None + self.trust_env = False else: - Fetcher.proxy = proxy - Fetcher.trust_env = False + self.proxy = proxy + self.trust_env = False - @classmethod - def set_sessdata(cls, sessdata: str): - Fetcher.cookies = httpx.Cookies() - # 先解码后编码是防止获取到的 SESSDATA 是已经解码后的(包含「,」) - # 而番剧无法使用解码后的 SESSDATA - Fetcher.cookies.set("SESSDATA", quote(unquote(sessdata))) + @asynccontextmanager + async def fetch_guard(self): + if self.fetch_semaphore is None: + yield + return + async with self.fetch_semaphore: + yield + + @asynccontextmanager + async def download_guard(self): + if self.download_semaphore is None: + yield + return + async with self.download_semaphore: + yield - @classmethod - def set_semaphore(cls, num_workers: int): - Fetcher.semaphore = asyncio.Semaphore(num_workers) - @classmethod +class Fetcher: + @staticmethod @MaxRetry(2) async def fetch_text( - cls, + ctx: FetcherContext, client: AsyncClient, url: str, *, params: Mapping[str, str] | None = None, encoding: str | None = None, # TODO(SigureMo): Support this ) -> str | None: - async with cls.semaphore: + async with ctx.fetch_guard(): Logger.debug(f"Fetch text: {url}") Logger.status.next_tick() resp = await client.get(url, params=params) @@ -114,16 +152,16 @@ async def fetch_text( return None return resp.text - @classmethod + @staticmethod @MaxRetry(2) async def fetch_bin( - cls, + ctx: FetcherContext, client: AsyncClient, url: str, *, params: Mapping[str, str] | None = None, ) -> bytes | None: - async with cls.semaphore: + async with ctx.fetch_guard(): Logger.debug(f"Fetch bin: {url}") Logger.status.next_tick() resp = await client.get(url, params=params) @@ -131,16 +169,16 @@ async def fetch_bin( return None return resp.read() - @classmethod + @staticmethod @MaxRetry(2) async def fetch_json( - cls, + ctx: FetcherContext, client: AsyncClient, url: str, *, params: Mapping[str, str] | None = None, ) -> Any | None: - async with cls.semaphore: + async with ctx.fetch_guard(): Logger.debug(f"Fetch json: {url}") Logger.status.next_tick() resp = await client.get(url, params=params) @@ -148,13 +186,13 @@ async def fetch_json( return None return resp.json() - @classmethod + @staticmethod @MaxRetry(2) - async def get_redirected_url(cls, client: AsyncClient, url: str) -> str: + async def get_redirected_url(ctx: FetcherContext, client: AsyncClient, url: str) -> str: # 关于为什么要前往重定向 url,是因为 B 站的 url 类型实在是太多了,比如有 b23.tv 的短链接 # 为 SEO 的搜索引擎链接、甚至有的 av、BV 链接实际上是番剧页面,一一列举实在太麻烦,而且最后一种 # 情况需要在 av、BV 解析一部分信息后才能知道是否是番剧页面,处理起来非常麻烦(bilili 就是这么做的) - async with cls.semaphore: + async with ctx.fetch_guard(): resp = await client.get(url) redirected_url = str(resp.url) if redirected_url == url: @@ -164,10 +202,10 @@ async def get_redirected_url(cls, client: AsyncClient, url: str) -> str: Logger.status.next_tick() return redirected_url - @classmethod + @staticmethod @MaxRetry(2) - async def get_size(cls, client: AsyncClient, url: str) -> int | None: - async with cls.semaphore: + async def get_size(ctx: FetcherContext, client: AsyncClient, url: str) -> int | None: + async with ctx.fetch_guard(): headers = client.headers.copy() headers["Range"] = "bytes=0-1" resp = await client.get( @@ -181,20 +219,18 @@ async def get_size(cls, client: AsyncClient, url: str) -> int | None: else: return None - @classmethod + @staticmethod @MaxRetry(2) - async def touch_url(cls, client: AsyncClient, url: str): - # 因为保持同一个 session,同样的页面没必要重复 touch - if url in cls._touch_set: - return - cls._touch_set.add(url) - async with cls.semaphore: + # 对于相同 session,同样的页面没必要重复 touch + @async_cache(lambda args: f"client_id={id(args.arguments['client'])}, url={args.arguments['url']}") + async def touch_url(ctx: FetcherContext, client: AsyncClient, url: str): + async with ctx.fetch_guard(): Logger.debug(f"Touch url: {url}") await client.get(url) - @classmethod + @staticmethod async def download_file_with_offset( - cls, + ctx: FetcherContext, client: AsyncClient, url: str, mirrors: list[str], @@ -202,7 +238,7 @@ async def download_file_with_offset( offset: int, size: int | None, ) -> None: - async with cls.semaphore: + async with ctx.download_guard(): Logger.debug(f"Start download (offset {offset}, number of mirrors {len(mirrors)}) {url}") done = False headers = client.headers.copy() @@ -233,11 +269,16 @@ async def download_file_with_offset( except httpx.TimeoutException: Logger.warning(f"文件 {file_buffer.file_path} 下载超时,尝试重新连接...") Logger.debug(f"超时链接:{url}") - except httpx.HTTPError as e: + except (httpx.HTTPError, h2.exceptions.H2Error) as e: await asyncio.sleep(0.5) error_type = e.__class__.__name__ Logger.warning(f"文件 {file_buffer.file_path} 下载出错({error_type}),尝试重新连接...") Logger.debug(f"超时链接:{url}") + except ValueError as e: + # 由于 httpx 经常出现此问题,暂时捕获该问题 + if "semaphore released too many times" not in str(e): + raise e + Logger.warning(f"文件 {file_buffer.file_path} 下载出错({e}),尝试重新连接...") def create_client( diff --git a/src/yutto/utils/file_buffer.py b/src/yutto/utils/file_buffer.py index 2bb42d133..f01a13cd9 100644 --- a/src/yutto/utils/file_buffer.py +++ b/src/yutto/utils/file_buffer.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING import aiofiles -from typing_extensions import Self from yutto.utils.console.logger import Logger from yutto.utils.funcutils import aobject @@ -14,6 +13,8 @@ if TYPE_CHECKING: from types import TracebackType + from typing_extensions import Self + @dataclass(order=True) class BufferChunk: diff --git a/src/yutto/utils/funcutils/option.py b/src/yutto/utils/funcutils/option.py new file mode 100644 index 000000000..07e160cd1 --- /dev/null +++ b/src/yutto/utils/funcutils/option.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any, Callable, Generic, NoReturn, Protocol, TypeVar + +T = TypeVar("T") +U = TypeVar("U") + + +class Option(Protocol, Generic[T]): + def __init__(self): ... + + def is_some(self) -> bool: ... + + def is_none(self) -> bool: ... + + def map(self, fn: Callable[[T], Any]) -> Option[Any]: ... + + def unwrap(self) -> T: ... + + def unwrap_or(self, default: T) -> T: + return self.unwrap() if self.is_some() else default + + @staticmethod + def from_optional(value: U | None) -> Option[U]: + return Some(value) if value is not None else None_() + + def to_optional(self) -> T | None: + return self.unwrap() if self.is_some() else None + + def __bool__(self) -> bool: + return self.is_some() + + +class Some(Option[T]): + value: T + + def __init__(self, value: T): + self.value = value + + def is_some(self) -> bool: + return True + + def is_none(self) -> bool: + return False + + def map(self, fn: Callable[[T], U]) -> Option[U]: + return Some(fn(self.value)) + + def unwrap(self) -> T: + return self.value + + +class None_(Option[Any]): + def __init__(self): ... + + def is_some(self) -> bool: + return False + + def is_none(self) -> bool: + return True + + def map(self, fn: Callable[[Any], Any]) -> Option[Any]: + return None_() + + def unwrap(self) -> NoReturn: + raise ValueError("Cannot unwrap None_ object") + + +def map_some(fn: Callable[[T], U], value: T | None) -> U | None: + return Option.from_optional(value).map(fn).to_optional() diff --git a/src/yutto/validator.py b/src/yutto/validator.py index 83a215ae9..9068a920d 100644 --- a/src/yutto/validator.py +++ b/src/yutto/validator.py @@ -15,7 +15,7 @@ from yutto.utils.asynclib import initial_async_policy from yutto.utils.console.colorful import set_no_color from yutto.utils.console.logger import Badge, Logger, set_logger_debug -from yutto.utils.fetcher import Fetcher, create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.ffmpeg import FFmpeg from yutto.utils.filter import Filter @@ -25,7 +25,7 @@ from yutto._typing import UserInfo -def initial_validation(args: argparse.Namespace): +def initial_validation(ctx: FetcherContext, args: argparse.Namespace): """初始化检查,仅执行一次""" if not args.no_progress: @@ -48,14 +48,14 @@ def initial_validation(args: argparse.Namespace): if args.proxy not in ["no", "auto"] and not re.match(r"https?://", args.proxy): Logger.error(f"proxy 参数值({args.proxy})错误啦!") sys.exit(ErrorCode.WRONG_ARGUMENT_ERROR.value) - Fetcher.set_proxy(args.proxy) + ctx.set_proxy(args.proxy) # 大会员身份校验 if not args.sessdata: - Logger.info("未提供 SESSDATA,无法下载会员专享剧集哟~") + Logger.info("未提供 SESSDATA,无法下载高清视频、字幕等资源哦~") else: - Fetcher.set_sessdata(args.sessdata) - if asyncio.run(validate_user_info({"vip_status": True, "is_login": True})): + ctx.set_sessdata(args.sessdata) + if asyncio.run(validate_user_info(ctx, {"vip_status": True, "is_login": True})): Logger.custom("成功以大会员身份登录~", badge=Badge("大会员", fore="white", back="magenta", style=["bold"])) else: Logger.warning("以非大会员身份登录,注意无法下载会员专享剧集喔~") @@ -73,8 +73,8 @@ def validate_basic_arguments(args: argparse.Namespace): ffmpeg = FFmpeg() download_vcodec_priority: list[VideoCodec] = video_codec_priority_default - if args.download_vcodec_priority != "auto": - user_download_vcodec_priority = args.download_vcodec_priority.split(",") + if args.download_vcodec_priority is not None: + user_download_vcodec_priority = args.download_vcodec_priority if not user_download_vcodec_priority: Logger.error("download_vcodec_priority 参数值为空哦") sys.exit(ErrorCode.WRONG_ARGUMENT_ERROR.value) @@ -90,7 +90,7 @@ def validate_basic_arguments(args: argparse.Namespace): if len(download_vcodec_priority) < len(video_codec_priority_default): Logger.warning( "download_vcodec_priority({})不包含所有下载视频编码({}),不包含部分将永远不会选择哦".format( - args.download_vcodec_priority, ", ".join(video_codec_priority_default) + ", ".join(args.download_vcodec_priority), ", ".join(video_codec_priority_default) ) ) @@ -107,7 +107,7 @@ def validate_basic_arguments(args: argparse.Namespace): ) ) sys.exit(ErrorCode.WRONG_ARGUMENT_ERROR.value) - if args.download_vcodec_priority != "auto" and download_vcodec_priority[0] != video_download_codec: + if args.download_vcodec_priority is not None and download_vcodec_priority[0] != video_download_codec: Logger.warning( f"download_vcodec 参数值({video_download_codec})不是优先级最高的编码({download_vcodec_priority[0]}),可能会导致下载失败哦" ) @@ -140,6 +140,11 @@ def validate_basic_arguments(args: argparse.Namespace): ) sys.exit(ErrorCode.WRONG_ARGUMENT_ERROR.value) + # cover 检查 + if not args.require_cover and args.save_cover: + Logger.warning("没有下载封面的情况下是无法保留封面的哦~") + sys.exit(ErrorCode.WRONG_ARGUMENT_ERROR.value) + def validate_batch_arguments(args: argparse.Namespace): """检查批量下载相关选项""" @@ -150,17 +155,17 @@ def validate_batch_arguments(args: argparse.Namespace): sys.exit(ErrorCode.WRONG_ARGUMENT_ERROR.value) -async def validate_user_info(check_option: UserInfo) -> bool: +async def validate_user_info(ctx: FetcherContext, check_option: UserInfo) -> bool: """UserInfo 结构和用户输入是匹配的,如果要校验则置 True 即可,估计不会有要校验为 False 的情况吧~~""" async with create_client( - cookies=Fetcher.cookies, - trust_env=Fetcher.trust_env, - proxy=Fetcher.proxy, + cookies=ctx.cookies, + trust_env=ctx.trust_env, + proxy=ctx.proxy, ) as client: if check_option["is_login"] or check_option["vip_status"]: # 需要校验 # 这么写 if 是为了少一个 get_user_info 请求 - user_info = await get_user_info(client) + user_info = await get_user_info(ctx, client) if check_option["is_login"] and not user_info["is_login"]: return False if check_option["vip_status"] and not user_info["vip_status"]: diff --git a/tests/conftest.py b/tests/conftest.py index a4251060d..92b554ac1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,6 @@ from pathlib import Path from typing import TYPE_CHECKING -from yutto.utils.fetcher import DEFAULT_HEADERS as DEFAULT_HEADERS - if TYPE_CHECKING: import pytest diff --git a/tests/test_api/test_bangumi.py b/tests/test_api/test_bangumi.py index 754d513bd..9d46692d5 100644 --- a/tests/test_api/test_bangumi.py +++ b/tests/test_api/test_bangumi.py @@ -10,7 +10,7 @@ get_season_id_by_episode_id, get_season_id_by_media_id, ) -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @@ -19,8 +19,9 @@ async def test_get_season_id_by_media_id(): media_id = MediaId("28223066") season_id_excepted = SeasonId("28770") + ctx = FetcherContext() async with create_client() as client: - season_id = await get_season_id_by_media_id(client, media_id) + season_id = await get_season_id_by_media_id(ctx, client, media_id) assert season_id == season_id_excepted @@ -29,8 +30,9 @@ async def test_get_season_id_by_media_id(): @pytest.mark.parametrize("episode_id", [EpisodeId("314477"), EpisodeId("300998")]) async def test_get_season_id_by_episode_id(episode_id: EpisodeId): season_id_excepted = SeasonId("28770") + ctx = FetcherContext() async with create_client() as client: - season_id = await get_season_id_by_episode_id(client, episode_id) + season_id = await get_season_id_by_episode_id(ctx, client, episode_id) assert season_id == season_id_excepted @@ -38,8 +40,9 @@ async def test_get_season_id_by_episode_id(episode_id: EpisodeId): @as_sync async def test_get_bangumi_title(): season_id = SeasonId("28770") + ctx = FetcherContext() async with create_client() as client: - title = (await get_bangumi_list(client, season_id))["title"] + title = (await get_bangumi_list(ctx, client, season_id))["title"] assert title == "我的三体之章北海传" @@ -47,8 +50,9 @@ async def test_get_bangumi_title(): @as_sync async def test_get_bangumi_list(): season_id = SeasonId("28770") + ctx = FetcherContext() async with create_client() as client: - bangumi_list = (await get_bangumi_list(client, season_id))["pages"] + bangumi_list = (await get_bangumi_list(ctx, client, season_id))["pages"] assert bangumi_list[0]["id"] == 1 assert bangumi_list[0]["name"] == "第1话" assert bangumi_list[0]["cid"] == CId("144541892") @@ -68,8 +72,9 @@ async def test_get_bangumi_list(): async def test_get_bangumi_playurl(): avid = BvId("BV1q7411v7Vd") cid = CId("144541892") + ctx = FetcherContext() async with create_client() as client: - playlist = await get_bangumi_playurl(client, avid, cid) + playlist = await get_bangumi_playurl(ctx, client, avid, cid) assert len(playlist[0]) > 0 assert len(playlist[1]) > 0 diff --git a/tests/test_api/test_cheese.py b/tests/test_api/test_cheese.py index 2a16008db..ccf31bcce 100644 --- a/tests/test_api/test_cheese.py +++ b/tests/test_api/test_cheese.py @@ -8,7 +8,7 @@ get_cheese_playurl, get_season_id_by_episode_id, ) -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @@ -17,8 +17,9 @@ @pytest.mark.parametrize("episode_id", [EpisodeId("6945"), EpisodeId("6902")]) async def test_get_season_id_by_episode_id(episode_id: EpisodeId): season_id_excepted = SeasonId("298") + ctx = FetcherContext() async with create_client() as client: - season_id = await get_season_id_by_episode_id(client, episode_id) + season_id = await get_season_id_by_episode_id(ctx, client, episode_id) assert season_id == season_id_excepted @@ -26,8 +27,9 @@ async def test_get_season_id_by_episode_id(episode_id: EpisodeId): @as_sync async def test_get_cheese_title(): season_id = SeasonId("298") + ctx = FetcherContext() async with create_client() as client: - cheese_list = await get_cheese_list(client, season_id) + cheese_list = await get_cheese_list(ctx, client, season_id) title = cheese_list["title"] assert title == "林超:给年轻人的跨学科通识课" @@ -36,8 +38,9 @@ async def test_get_cheese_title(): @as_sync async def test_get_cheese_list(): season_id = SeasonId("298") + ctx = FetcherContext() async with create_client() as client: - cheese_list = (await get_cheese_list(client, season_id))["pages"] + cheese_list = (await get_cheese_list(ctx, client, season_id))["pages"] assert cheese_list[0]["id"] == 1 assert cheese_list[0]["name"] == "【先导片】给年轻人的跨学科通识课" assert cheese_list[0]["cid"] == CId("344779477") @@ -54,9 +57,10 @@ async def test_get_cheese_playurl(): avid = AId("545852212") episode_id = EpisodeId("6902") cid = CId("344779477") + ctx = FetcherContext() async with create_client() as client: playlist: tuple[list[VideoUrlMeta], list[AudioUrlMeta]] = await get_cheese_playurl( - client, avid, episode_id, cid + ctx, client, avid, episode_id, cid ) assert len(playlist[0]) > 0 assert len(playlist[1]) > 0 diff --git a/tests/test_api/test_collection.py b/tests/test_api/test_collection.py index a3d943711..bfbbca6b1 100644 --- a/tests/test_api/test_collection.py +++ b/tests/test_api/test_collection.py @@ -4,7 +4,7 @@ from yutto._typing import BvId, MId, SeriesId from yutto.api.collection import get_collection_details -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @@ -14,8 +14,9 @@ async def test_get_collection_details(): # 测试页面:https://space.bilibili.com/6762654/channel/collectiondetail?sid=39879&ctype=0 series_id = SeriesId("39879") mid = MId("6762654") + ctx = FetcherContext() async with create_client() as client: - collection_details = await get_collection_details(client, series_id=series_id, mid=mid) + collection_details = await get_collection_details(ctx, client, series_id=series_id, mid=mid) title = collection_details["title"] avids = [page["avid"] for page in collection_details["pages"]] assert title == "傻开心整活" diff --git a/tests/test_api/test_danmaku.py b/tests/test_api/test_danmaku.py index fd582031a..ca798f91f 100644 --- a/tests/test_api/test_danmaku.py +++ b/tests/test_api/test_danmaku.py @@ -4,7 +4,7 @@ from yutto._typing import AvId, CId from yutto.api.danmaku import get_danmaku, get_protobuf_danmaku_segment, get_xml_danmaku -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @@ -12,8 +12,9 @@ @as_sync async def test_xml_danmaku(): cid = CId("144541892") + ctx = FetcherContext() async with create_client() as client: - danmaku = await get_xml_danmaku(client, cid=cid) + danmaku = await get_xml_danmaku(ctx, client, cid=cid) assert len(danmaku) > 0 @@ -21,8 +22,9 @@ async def test_xml_danmaku(): @as_sync async def test_protobuf_danmaku(): cid = CId("144541892") + ctx = FetcherContext() async with create_client() as client: - danmaku = await get_protobuf_danmaku_segment(client, cid=cid, segment_id=1) + danmaku = await get_protobuf_danmaku_segment(ctx, client, cid=cid, segment_id=1) assert len(danmaku) > 0 @@ -31,8 +33,9 @@ async def test_protobuf_danmaku(): async def test_danmaku(): cid = CId("144541892") avid = AvId("BV1q7411v7Vd") + ctx = FetcherContext() async with create_client() as client: - danmaku = await get_danmaku(client, cid=cid, avid=avid, save_type="ass") + danmaku = await get_danmaku(ctx, client, cid=cid, avid=avid, save_type="ass") assert len(danmaku["data"]) > 0 assert danmaku["source_type"] == "xml" assert danmaku["save_type"] == "ass" diff --git a/tests/test_api/test_space.py b/tests/test_api/test_space.py index 9d20f5d46..b1f5687d7 100644 --- a/tests/test_api/test_space.py +++ b/tests/test_api/test_space.py @@ -12,7 +12,7 @@ get_user_name, get_user_space_all_videos_avids, ) -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @@ -21,8 +21,9 @@ @as_sync async def test_get_user_space_all_videos_avids(): mid = MId("100969474") + ctx = FetcherContext() async with create_client() as client: - all_avid = await get_user_space_all_videos_avids(client, mid=mid) + all_avid = await get_user_space_all_videos_avids(ctx, client, mid=mid) assert len(all_avid) > 0 assert AId("371660125") in all_avid or BvId("BV1vZ4y1M7mQ") in all_avid @@ -32,8 +33,9 @@ async def test_get_user_space_all_videos_avids(): @as_sync async def test_get_user_name(): mid = MId("100969474") + ctx = FetcherContext() async with create_client() as client: - username = await get_user_name(client, mid=mid) + username = await get_user_name(ctx, client, mid=mid) assert username == "时雨千陌" @@ -41,8 +43,9 @@ async def test_get_user_name(): @as_sync async def test_get_favourite_info(): fid = FId("1306978874") + ctx = FetcherContext() async with create_client() as client: - fav_info = await get_favourite_info(client, fid=fid) + fav_info = await get_favourite_info(ctx, client, fid=fid) assert fav_info["fid"] == fid assert fav_info["title"] == "Test" @@ -51,8 +54,9 @@ async def test_get_favourite_info(): @as_sync async def test_get_favourite_avids(): fid = FId("1306978874") + ctx = FetcherContext() async with create_client() as client: - avids = await get_favourite_avids(client, fid=fid) + avids = await get_favourite_avids(ctx, client, fid=fid) assert AId("456782499") in avids or BvId("BV1o541187Wh") in avids @@ -60,8 +64,9 @@ async def test_get_favourite_avids(): @as_sync async def test_all_favourites(): mid = MId("100969474") + ctx = FetcherContext() async with create_client() as client: - fav_list = await get_all_favourites(client, mid=mid) + fav_list = await get_all_favourites(ctx, client, mid=mid) assert {"fid": FId("1306978874"), "title": "Test"} in fav_list @@ -70,8 +75,9 @@ async def test_all_favourites(): async def test_get_medialist_avids(): series_id = SeriesId("1947439") mid = MId("100969474") + ctx = FetcherContext() async with create_client() as client: - avids = await get_medialist_avids(client, series_id=series_id, mid=mid) + avids = await get_medialist_avids(ctx, client, series_id=series_id, mid=mid) assert avids == [BvId("BV1Y441167U2"), BvId("BV1vZ4y1M7mQ")] @@ -79,6 +85,7 @@ async def test_get_medialist_avids(): @as_sync async def test_get_medialist_title(): series_id = SeriesId("1947439") + ctx = FetcherContext() async with create_client() as client: - title = await get_medialist_title(client, series_id=series_id) + title = await get_medialist_title(ctx, client, series_id=series_id) assert title == "一个小视频列表~" diff --git a/tests/test_api/test_ugc_video.py b/tests/test_api/test_ugc_video.py index fc8c0469e..fa0f030e7 100644 --- a/tests/test_api/test_ugc_video.py +++ b/tests/test_api/test_ugc_video.py @@ -9,7 +9,7 @@ get_ugc_video_playurl, get_ugc_video_subtitles, ) -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @@ -21,8 +21,9 @@ async def test_get_ugc_video_info(): aid = AId("84271171") avid = bvid episode_id = EpisodeId("300998") + ctx = FetcherContext() async with create_client() as client: - video_info = await get_ugc_video_info(client, avid=avid) + video_info = await get_ugc_video_info(ctx, client, avid=avid) assert video_info["avid"] == aid or video_info["avid"] == bvid assert video_info["aid"] == aid assert video_info["bvid"] == bvid @@ -36,8 +37,9 @@ async def test_get_ugc_video_info(): @as_sync async def test_get_ugc_video_title(): avid = BvId("BV1vZ4y1M7mQ") + ctx = FetcherContext() async with create_client() as client: - title = (await get_ugc_video_list(client, avid))["title"] + title = (await get_ugc_video_list(ctx, client, avid))["title"] assert title == "用 bilili 下载 B 站视频" @@ -45,8 +47,9 @@ async def test_get_ugc_video_title(): @as_sync async def test_get_ugc_video_list(): avid = BvId("BV1vZ4y1M7mQ") + ctx = FetcherContext() async with create_client() as client: - ugc_video_list = (await get_ugc_video_list(client, avid))["pages"] + ugc_video_list = (await get_ugc_video_list(ctx, client, avid))["pages"] assert ugc_video_list[0]["id"] == 1 assert ugc_video_list[0]["name"] == "bilili 特性以及使用方法简单介绍" assert ugc_video_list[0]["cid"] == CId("222190584") @@ -68,18 +71,23 @@ async def test_get_ugc_video_list(): async def test_get_ugc_video_playurl(): avid = BvId("BV1vZ4y1M7mQ") cid = CId("222190584") + ctx = FetcherContext() async with create_client() as client: - playlist = await get_ugc_video_playurl(client, avid, cid) + playlist = await get_ugc_video_playurl(ctx, client, avid, cid) assert len(playlist[0]) > 0 assert len(playlist[1]) > 0 +# The latest subtitle API needs login, so this test is skipped. +# We need to find a way to test theses APIs. +@pytest.mark.skip @pytest.mark.api @as_sync async def test_get_ugc_video_subtitles(): avid = BvId("BV1Ra411A7kN") cid = CId("253246252") + ctx = FetcherContext() async with create_client() as client: - subtitles = await get_ugc_video_subtitles(client, avid=avid, cid=cid) + subtitles = await get_ugc_video_subtitles(ctx, client, avid=avid, cid=cid) assert len(subtitles) > 0 assert len(subtitles[0]["lines"]) > 0 diff --git a/tests/test_api/test_user_info.py b/tests/test_api/test_user_info.py index 5d94b148f..57abb84d7 100644 --- a/tests/test_api/test_user_info.py +++ b/tests/test_api/test_user_info.py @@ -3,14 +3,15 @@ import pytest from yutto.api.user_info import get_user_info -from yutto.utils.fetcher import create_client +from yutto.utils.fetcher import FetcherContext, create_client from yutto.utils.funcutils import as_sync @pytest.mark.api @as_sync async def test_get_user_info(): + ctx = FetcherContext() async with create_client() as client: - user_info = await get_user_info(client) + user_info = await get_user_info(ctx, client) assert not user_info["vip_status"] assert not user_info["is_login"] diff --git a/tests/test_biliass/test_corpus b/tests/test_biliass/test_corpus index f2665016b..9eaeb1c22 160000 --- a/tests/test_biliass/test_corpus +++ b/tests/test_biliass/test_corpus @@ -1 +1 @@ -Subproject commit f2665016bb268a400cc8f869e8f0d598d1652920 +Subproject commit 9eaeb1c2235d042bb4562e56d4707851ec108cca diff --git a/tests/test_biliass/test_protobuf.py b/tests/test_biliass/test_protobuf.py deleted file mode 100644 index 3bf0d4b0f..000000000 --- a/tests/test_biliass/test_protobuf.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import httpx -import pytest -from biliass import convert_to_ass - -from ..conftest import DEFAULT_HEADERS, TEST_DIR - -if TYPE_CHECKING: - from pathlib import Path - - -def gen_protobuf(base_dir: Path): - filename = "test.pb" - filepath = base_dir / filename - cid = "18678311" - resp = httpx.get( - f"http://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid={cid}&segment_index={1}", - headers=DEFAULT_HEADERS, - ) - with filepath.open("wb") as f: - f.write(resp.content) - - -@pytest.mark.biliass -def test_protobuf(): - gen_protobuf(TEST_DIR) - with TEST_DIR.joinpath("test.pb").open("rb") as f: - convert_to_ass(f.read(), 1920, 1080, input_format="protobuf") diff --git a/tests/test_biliass/test_xml.py b/tests/test_biliass/test_xml.py deleted file mode 100644 index 1bdc11a90..000000000 --- a/tests/test_biliass/test_xml.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import httpx -import pytest -from biliass import convert_to_ass - -from ..conftest import DEFAULT_HEADERS, TEST_DIR - -if TYPE_CHECKING: - from pathlib import Path - - -def gen_xml_v1(base_dir: Path): - filename = "test_v1.xml" - filepath = base_dir / filename - cid = "18678311" - resp = httpx.get( - f"http://comment.bilibili.com/{cid}.xml", - headers=DEFAULT_HEADERS, - follow_redirects=True, - ) - resp.encoding = "utf-8" - with filepath.open("w", encoding="utf-8") as f: - f.write(resp.text) - - -@pytest.mark.biliass -def test_xml_v1_text(): - gen_xml_v1(TEST_DIR) - with TEST_DIR.joinpath("test_v1.xml").open("r") as f: - convert_to_ass(f.read(), 1920, 1080) - - -@pytest.mark.biliass -def test_xml_v1_bytes(): - gen_xml_v1(TEST_DIR) - with TEST_DIR.joinpath("test_v1.xml").open("rb") as f: - convert_to_ass(f.read(), 1920, 1080) diff --git a/tests/test_processor/test_downloader.py b/tests/test_processor/test_downloader.py index 204679e7e..0db51cd88 100644 --- a/tests/test_processor/test_downloader.py +++ b/tests/test_processor/test_downloader.py @@ -7,7 +7,7 @@ from yutto.processor.downloader import slice_blocks from yutto.utils.asynclib import CoroutineWrapper -from yutto.utils.fetcher import Fetcher, create_client +from yutto.utils.fetcher import Fetcher, FetcherContext, create_client from yutto.utils.file_buffer import AsyncFileBuffer from yutto.utils.funcutils import as_sync @@ -22,14 +22,15 @@ async def test_150_kB_downloader(): # 因为 file-examples-com 挂掉了(GitHub 账号都消失了,因此暂时使用一个别处的 mirror) url = "https://github.com/nhegde610/samples-files/raw/main/file_example_MP4_480_1_5MG.mp4" file_path = TEST_DIR / "test_150_kB.pdf" + ctx = FetcherContext() async with await AsyncFileBuffer(file_path, overwrite=False) as buffer: async with create_client( timeout=httpx.Timeout(7, connect=3), ) as client: - Fetcher.set_semaphore(4) - size = await Fetcher.get_size(client, url) + ctx.set_download_semaphore(4) + size = await Fetcher.get_size(ctx, client, url) coroutines = [ - CoroutineWrapper(Fetcher.download_file_with_offset(client, url, [], buffer, offset, block_size)) + CoroutineWrapper(Fetcher.download_file_with_offset(ctx, client, url, [], buffer, offset, block_size)) for offset, block_size in slice_blocks(buffer.written_size, size, 1 * 1024 * 1024) ] @@ -46,13 +47,14 @@ async def test_150_kB_no_slice_downloader(): # url = "https://file-examples-com.github.io/uploads/2017/04/file_example_MP4_480_1_5MG.mp4" url = "https://github.com/nhegde610/samples-files/raw/main/file_example_MP4_480_1_5MG.mp4" file_path = TEST_DIR / "test_150_kB_no_slice.pdf" + ctx = FetcherContext() async with await AsyncFileBuffer(file_path, overwrite=False) as buffer: async with create_client( timeout=httpx.Timeout(7, connect=3), ) as client: - Fetcher.set_semaphore(4) - size = await Fetcher.get_size(client, url) - coroutines = [CoroutineWrapper(Fetcher.download_file_with_offset(client, url, [], buffer, 0, size))] + ctx.set_download_semaphore(4) + size = await Fetcher.get_size(ctx, client, url) + coroutines = [CoroutineWrapper(Fetcher.download_file_with_offset(ctx, client, url, [], buffer, 0, size))] print("开始下载……") await asyncio.gather(*coroutines) diff --git a/uv.lock b/uv.lock index 3e3147da6..947887528 100644 --- a/uv.lock +++ b/uv.lock @@ -16,33 +16,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "anyio" -version = "4.6.0" +version = "4.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 }, + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] [[package]] name = "biliass" -version = "2.0.0" source = { editable = "packages/biliass" } [[package]] name = "certifi" -version = "2024.8.30" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, ] [[package]] @@ -96,40 +173,39 @@ wheels = [ [[package]] name = "hpack" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/fda93fb4d957db19b0f6b370e79d586b3e8528b20252c729c476a2c02954/hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095", size = 49117 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/34/e8b383f35b77c402d28563d2b8f83159319b509bc5f760b15d60b0abf165/hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c", size = 32611 }, + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 }, ] [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, ] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, - { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [package.optional-dependencies] @@ -142,11 +218,11 @@ socks = [ [[package]] name = "hyperframe" -version = "6.0.1" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/4747bff0a17f7281abe73e955d60d80aae537a5d203f417fa1c2e7578ebb/hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914", size = 25008 } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/de/85a784bcc4a3779d1753a7ec2dee5de90e18c7bcf402e71b51fcf150b129/hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15", size = 12389 }, + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, ] [[package]] @@ -158,6 +234,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "importlib-metadata" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -167,6 +255,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -178,11 +287,11 @@ wheels = [ [[package]] name = "packaging" -version = "24.1" +version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] [[package]] @@ -194,22 +303,151 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475 }, + { url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112 }, + { url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780 }, + { url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943 }, + { url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492 }, + { url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714 }, + { url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163 }, + { url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217 }, + { url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899 }, + { url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726 }, + { url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219 }, + { url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, + { url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733 }, + { url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375 }, + { url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307 }, + { url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971 }, + { url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616 }, + { url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943 }, + { url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654 }, + { url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292 }, + { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + [[package]] name = "pyright" -version = "1.1.383" +version = "1.1.393" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/a9/4654d15f4125d8dca6318d7be36a3283a8b3039661291c59bbdd1e576dcf/pyright-1.1.383.tar.gz", hash = "sha256:1df7f12407f3710c9c6df938d98ec53f70053e6c6bbf71ce7bcb038d42f10070", size = 21971 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/c1/aede6c74e664ab103673e4f1b7fd3d058fef32276be5c43572f4067d4a8e/pyright-1.1.393.tar.gz", hash = "sha256:aeeb7ff4e0364775ef416a80111613f91a05c8e01e58ecfefc370ca0db7aed9c", size = 3790430 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/55/40a6559cea209b551c81dcd31cb351a6ffdb5876e7865ee242e269af72d8/pyright-1.1.383-py3-none-any.whl", hash = "sha256:d864d1182a313f45aaf99e9bfc7d2668eeabc99b29a556b5344894fd73cb1959", size = 18577 }, + { url = "https://files.pythonhosted.org/packages/92/47/f0dd0f8afce13d92e406421ecac6df0990daee84335fc36717678577d3e0/pyright-1.1.393-py3-none-any.whl", hash = "sha256:8320629bb7a44ca90944ba599390162bf59307f3d9fb6e27da3b7011b8c17ae5", size = 5646057 }, ] [[package]] name = "pytest" -version = "8.3.3" +version = "8.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -219,47 +457,86 @@ dependencies = [ { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "pytest-codspeed" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/98/16fe3895b1b8a6d537a89eecb120b97358df8f0002c6ecd11555d6304dc8/pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155", size = 18409 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/31/62b93ee025ca46016d01325f58997d32303752286bf929588c8796a25b13/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34", size = 26802 }, + { url = "https://files.pythonhosted.org/packages/89/60/2bc46bdf8c8ddb7e59cd9d480dc887d0ac6039f88c856d1ae3d29a4e648d/pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c", size = 25442 }, + { url = "https://files.pythonhosted.org/packages/31/56/1b65ba0ae1af7fd7ce14a66e7599833efe8bbd0fcecd3614db0017ca224a/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035", size = 26810 }, + { url = "https://files.pythonhosted.org/packages/23/e6/d1fafb09a1c4983372f562d9e158735229cb0b11603a61d4fad05463f977/pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58", size = 25442 }, + { url = "https://files.pythonhosted.org/packages/0b/8b/9e95472589d17bb68960f2a09cfa8f02c4d43c82de55b73302bbe0fa4350/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b", size = 27182 }, + { url = "https://files.pythonhosted.org/packages/2a/18/82aaed8095e84d829f30dda3ac49fce4e69685d769aae463614a8d864cdd/pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2", size = 25933 }, + { url = "https://files.pythonhosted.org/packages/e2/15/60b18d40da66e7aa2ce4c4c66d5a17de20a2ae4a89ac09a58baa7a5bc535/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f", size = 27180 }, + { url = "https://files.pythonhosted.org/packages/51/bd/6b164d4ae07d8bea5d02ad664a9762bdb63f83c0805a3c8fe7dc6ec38407/pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b", size = 25923 }, + { url = "https://files.pythonhosted.org/packages/90/bb/5d73c59d750264863c25fc202bcc37c5f8a390df640a4760eba54151753e/pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c", size = 26795 }, + { url = "https://files.pythonhosted.org/packages/65/17/d4bf207b63f1edc5b9c06ad77df565d186e0fd40f13459bb124304b54b1d/pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da", size = 25433 }, + { url = "https://files.pythonhosted.org/packages/f1/9b/952c70bd1fae9baa58077272e7f191f377c86d812263c21b361195e125e6/pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39", size = 15007 }, ] [[package]] name = "pytest-rerunfailures" -version = "14.0" +version = "15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/a4/6de45fe850759e94aa9a55cda807c76245af1941047294df26c851dfb4a9/pytest-rerunfailures-14.0.tar.gz", hash = "sha256:4a400bcbcd3c7a4ad151ab8afac123d90eca3abe27f98725dc4d9702887d2e92", size = 21350 } +sdist = { url = "https://files.pythonhosted.org/packages/26/47/ec4e12f45f4b9fac027a41ccaabb353ed4f23695aae860258ba11a84ed9b/pytest-rerunfailures-15.0.tar.gz", hash = "sha256:2d9ac7baf59f4c13ac730b47f6fa80e755d1ba0581da45ce30b72fb3542b4474", size = 21816 } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/e7/e75bd157331aecc190f5f8950d7ea3d2cf56c3c57fb44da70e60b221133f/pytest_rerunfailures-14.0-py3-none-any.whl", hash = "sha256:4197bdd2eaeffdbf50b5ea6e7236f47ff0e44d1def8dae08e409f536d84e7b32", size = 12709 }, + { url = "https://files.pythonhosted.org/packages/89/37/54e5ffc7c0cebee7cf30a3ac5915faa7e7abf8bdfdf3228c277f7c192489/pytest_rerunfailures-15.0-py3-none-any.whl", hash = "sha256:dd150c4795c229ef44320adc9a0c0532c51b78bb7a6843a8c53556b9a611df1a", size = 13017 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, ] [[package]] name = "ruff" -version = "0.6.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/f9/4ce3e765a72ab8fe0f80f48508ea38b4196daab3da14d803c21349b2d367/ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18", size = 3084543 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/07/42ee57e8b76ca585297a663a552b4f6d6a99372ca47fdc2276ef72cc0f2f/ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2", size = 10404327 }, - { url = "https://files.pythonhosted.org/packages/eb/51/d42571ff8156d65086acb72d39aa64cb24181db53b497d0ed6293f43f07a/ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c", size = 10018797 }, - { url = "https://files.pythonhosted.org/packages/c1/d7/fa5514a60b03976af972b67fe345deb0335dc96b9f9a9fa4df9890472427/ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5", size = 9691303 }, - { url = "https://files.pythonhosted.org/packages/d6/c4/d812a74976927e51d0782a47539069657ac78535779bfa4d061c4fc8d89d/ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f", size = 10719452 }, - { url = "https://files.pythonhosted.org/packages/ec/b6/aa700c4ae6db9b3ee660e23f3c7db596e2b16a3034b797704fba33ddbc96/ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb", size = 10161353 }, - { url = "https://files.pythonhosted.org/packages/ea/39/0b10075ffcd52ff3a581b9b69eac53579deb230aad300ce8f9d0b58e77bc/ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f", size = 10980630 }, - { url = "https://files.pythonhosted.org/packages/c1/af/9eb9efc98334f62652e2f9318f137b2667187851911fac3b395365a83708/ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0", size = 11768996 }, - { url = "https://files.pythonhosted.org/packages/e0/59/8b1369cf7878358952b1c0a1559b4d6b5c824c003d09b0db26d26c9d094f/ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87", size = 11317469 }, - { url = "https://files.pythonhosted.org/packages/b9/6d/e252e9b11bbca4114c386ee41ad559d0dac13246201d77ea1223c6fea17f/ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098", size = 12467185 }, - { url = "https://files.pythonhosted.org/packages/48/44/7caa223af7d4ea0f0b2bd34acca65a7694a58317714675a2478815ab3f45/ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0", size = 10887766 }, - { url = "https://files.pythonhosted.org/packages/81/ed/394aff3a785f171869158b9d5be61eec9ffb823c3ad5d2bdf2e5f13cb029/ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750", size = 10711609 }, - { url = "https://files.pythonhosted.org/packages/47/31/f31d04c842e54699eab7e3b864538fea26e6c94b71806cd10aa49f13e1c1/ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce", size = 10237621 }, - { url = "https://files.pythonhosted.org/packages/20/95/a764e84acf11d425f2f23b8b78b4fd715e9c20be4aac157c6414ca859a67/ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa", size = 10558329 }, - { url = "https://files.pythonhosted.org/packages/2a/76/d4e38846ac9f6dd62dce858a54583911361b5339dcf8f84419241efac93a/ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44", size = 10954102 }, - { url = "https://files.pythonhosted.org/packages/e7/36/f18c678da6c69f8d022480f3e8ddce6e4a52e07602c1d212056fbd234f8f/ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a", size = 8511090 }, - { url = "https://files.pythonhosted.org/packages/4c/c4/0ca7d8ffa358b109db7d7d045a1a076fd8e5d9cbeae022242d3c060931da/ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263", size = 9350079 }, - { url = "https://files.pythonhosted.org/packages/d9/bd/a8b0c64945a92eaeeb8d0283f27a726a776a1c9d12734d990c5fc7a1278c/ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", size = 8669595 }, +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/17/529e78f49fc6f8076f50d985edd9a2cf011d1dbadb1cdeacc1d12afc1d26/ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7", size = 3599458 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/f8/3fafb7804d82e0699a122101b5bee5f0d6e17c3a806dcbc527bb7d3f5b7a/ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706", size = 11668400 }, + { url = "https://files.pythonhosted.org/packages/2e/a6/2efa772d335da48a70ab2c6bb41a096c8517ca43c086ea672d51079e3d1f/ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf", size = 11628395 }, + { url = "https://files.pythonhosted.org/packages/dc/d7/cd822437561082f1c9d7225cc0d0fbb4bad117ad7ac3c41cd5d7f0fa948c/ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b", size = 11090052 }, + { url = "https://files.pythonhosted.org/packages/9e/67/3660d58e893d470abb9a13f679223368ff1684a4ef40f254a0157f51b448/ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137", size = 11882221 }, + { url = "https://files.pythonhosted.org/packages/79/d1/757559995c8ba5f14dfec4459ef2dd3fcea82ac43bc4e7c7bf47484180c0/ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e", size = 11424862 }, + { url = "https://files.pythonhosted.org/packages/c0/96/7915a7c6877bb734caa6a2af424045baf6419f685632469643dbd8eb2958/ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec", size = 12626735 }, + { url = "https://files.pythonhosted.org/packages/0e/cc/dadb9b35473d7cb17c7ffe4737b4377aeec519a446ee8514123ff4a26091/ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b", size = 13255976 }, + { url = "https://files.pythonhosted.org/packages/5f/c3/ad2dd59d3cabbc12df308cced780f9c14367f0321e7800ca0fe52849da4c/ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a", size = 12752262 }, + { url = "https://files.pythonhosted.org/packages/c7/17/5f1971e54bd71604da6788efd84d66d789362b1105e17e5ccc53bba0289b/ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214", size = 14401648 }, + { url = "https://files.pythonhosted.org/packages/30/24/6200b13ea611b83260501b6955b764bb320e23b2b75884c60ee7d3f0b68e/ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231", size = 12414702 }, + { url = "https://files.pythonhosted.org/packages/34/cb/f5d50d0c4ecdcc7670e348bd0b11878154bc4617f3fdd1e8ad5297c0d0ba/ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b", size = 11859608 }, + { url = "https://files.pythonhosted.org/packages/d6/f4/9c8499ae8426da48363bbb78d081b817b0f64a9305f9b7f87eab2a8fb2c1/ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6", size = 11485702 }, + { url = "https://files.pythonhosted.org/packages/18/59/30490e483e804ccaa8147dd78c52e44ff96e1c30b5a95d69a63163cdb15b/ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c", size = 12067782 }, + { url = "https://files.pythonhosted.org/packages/3d/8c/893fa9551760b2f8eb2a351b603e96f15af167ceaf27e27ad873570bc04c/ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0", size = 12483087 }, + { url = "https://files.pythonhosted.org/packages/23/15/f6751c07c21ca10e3f4a51ea495ca975ad936d780c347d9808bcedbd7182/ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402", size = 9852302 }, + { url = "https://files.pythonhosted.org/packages/12/41/2d2d2c6a72e62566f730e49254f602dfed23019c33b5b21ea8f8917315a1/ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e", size = 10850051 }, + { url = "https://files.pythonhosted.org/packages/c6/e6/3d6ec3bc3d254e7f005c543a661a41c3e788976d0e52a1ada195bd664344/ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41", size = 10078251 }, ] [[package]] @@ -282,23 +559,53 @@ wheels = [ [[package]] name = "syrupy" -version = "4.7.1" +version = "4.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/ac/105c151335bf71ddf7f3c77118438cad77d4cf092559a6b429bca1bb436b/syrupy-4.7.1.tar.gz", hash = "sha256:f9d4485f3f27d0e5df6ed299cac6fa32eb40a441915d988e82be5a4bdda335c8", size = 49117 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/32/8b56491ed50ae103c2db14885c29fe765170bdf044fe5868548113da35ef/syrupy-4.8.1.tar.gz", hash = "sha256:8da8c0311e6d92de0b15767768c6ab98982b7b4a4c67083c08fbac3fbad4d44c", size = 50192 } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/af9adb7a0e4420dcf249653f589cd27152fa6daab5cfd84e6d665dcd7df5/syrupy-4.7.1-py3-none-any.whl", hash = "sha256:be002267a512a4bedddfae2e026c93df1ea928ae10baadc09640516923376d41", size = 49135 }, + { url = "https://files.pythonhosted.org/packages/80/47/5e8f44ec0f287b08e8c1f3fc63fe1fbe182f07bf606eec903d7827b95e51/syrupy-4.8.1-py3-none-any.whl", hash = "sha256:274f97cbaf44175f5e478a2f3a53559d31f41c66c6bf28131695f94ac893ea00", size = 50326 }, ] [[package]] name = "tomli" -version = "2.0.2" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] [[package]] @@ -312,24 +619,24 @@ wheels = [ [[package]] name = "typos" -version = "1.25.0" +version = "1.29.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/b1/cfd4c5e3148d1605f8b96fa460c0f2ca8e863024dc08c404a83d5ae7847d/typos-1.25.0.tar.gz", hash = "sha256:feb1b50edcddeacbb2244f971b5dd1cb6fba4eb9734a44f5dacb1676ab49aef1", size = 1112554 } +sdist = { url = "https://files.pythonhosted.org/packages/25/8d/d36d0ff090606b41b3472049734b2c15d8a1a95f9a7e997df26b54e14444/typos-1.29.5.tar.gz", hash = "sha256:313bb0636159f976ba9039901be7fe8e763b02aba3d10e26c2e59a3f02da36b2", size = 1484309 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/75/965b27d93f568625aa6cab1200fd0960f84edc1e16f07e09b15390601f1b/typos-1.25.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:564d3efa4764963a30e28b9716d49dfdd0679c2b75356fc10d89888e6ef489bb", size = 2984804 }, - { url = "https://files.pythonhosted.org/packages/9f/80/b15ae895843c36f212341236de1559374fd83bbd705340fd7a28b4f25c70/typos-1.25.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a5d0d6770e4e0519c859e6025ac289eecd34a1b6dad7ec8706c537e71e47148", size = 2891222 }, - { url = "https://files.pythonhosted.org/packages/85/29/044d0c3c31a4763ad1c18e642c151f4955548849eef7e6b681fffae3e7f3/typos-1.25.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f067f00997fba7d2abca030c716c0e1fe2ed7f291d8ed2e46e93f8c940638f99", size = 4882267 }, - { url = "https://files.pythonhosted.org/packages/8f/ee/9b9fba5c8f2ab218ed62500a95fb0df946f11de33cb38fe49b5cf38dec7b/typos-1.25.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:482f53383ab8d9d76e6dec648a2f909c0d71eee1910fcdd552da90452cd867d1", size = 3334849 }, - { url = "https://files.pythonhosted.org/packages/91/0d/67e3e4323144eca8abe65876ad34e412db22c294ef6cc8016109167d96fc/typos-1.25.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc080979397f4196485d6e53651ff5966def175fe0ea5034b2e5d3a1b8800947", size = 4052757 }, - { url = "https://files.pythonhosted.org/packages/95/d7/7e95d3b4666db9d6a28578cafcf0f5b61ea1162f30ed1cb3ab63827afbc3/typos-1.25.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb0dd971f83a98a989741e5beb3b839d2794c7abe7cf669b4a62771288871df", size = 3883042 }, - { url = "https://files.pythonhosted.org/packages/1b/eb/5d7278568e3609a09a25f599174ead8a95d369309af556689d28d466839a/typos-1.25.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:17d69b48a3aba8378ba35ed9d7d0e7190972b231e69d025acd96275a1d7b5daa", size = 4066442 }, - { url = "https://files.pythonhosted.org/packages/28/79/b5984fa96702886ed434e55274d899bb947caad1459a58d5eddf46433619/typos-1.25.0-py3-none-win32.whl", hash = "sha256:d0df91543ff5aef010a8c9aa24a0252b0173b6930979dcb7e7c65fa33ab3381b", size = 2410316 }, - { url = "https://files.pythonhosted.org/packages/f8/d1/61c729e064a3a48565d88f0b79f4c592100da1609f002638352878913fae/typos-1.25.0-py3-none-win_amd64.whl", hash = "sha256:b561ba341515f33f9d5d9798d0e4195bf10737ec605a4d0d6b0f26980123d2c7", size = 2540105 }, + { url = "https://files.pythonhosted.org/packages/22/bf/3b41bae5b096365ecc4d8fbc0eaf09df2f81965678ca1aadf878c46293c7/typos-1.29.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:489613574767922ce5db771887d58711890e30d13707e6be1419acbc71a526d0", size = 3112847 }, + { url = "https://files.pythonhosted.org/packages/d9/d0/c274a853d40ff3f81d58da2e382fe0ec06e52be117cec9c6d2982c83cd2b/typos-1.29.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a958af074cc618f123fd3248c57d228823ca0d7bdb247bd5498c357b60ac194", size = 2989343 }, + { url = "https://files.pythonhosted.org/packages/cb/f3/1ff4cf284d276a89fbe6797bd72f8411cfe4c713dee0bcaf3377aac3cc51/typos-1.29.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:728cf9d051eed593e22ee52692c369011bb2c594f0ceb79ad317043c0b06fdde", size = 4285699 }, + { url = "https://files.pythonhosted.org/packages/fa/c5/4b4b7ad7ae59ed177898d2db31012098261dbb9acf50f6a68a6351a61e45/typos-1.29.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b672bdb041d8d1eb9a512446768d057bfac17ab498500903248d971d4e9a2db9", size = 3426871 }, + { url = "https://files.pythonhosted.org/packages/20/d6/c7afa17b1bd7bb9ebd707d903860eee49721a5dd0daacd02344dc6517ee2/typos-1.29.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77182dfece380f095458ac867d0f35c2aab038c55d773d434f35d049a29401b5", size = 4143657 }, + { url = "https://files.pythonhosted.org/packages/73/79/a19cdc08e1686dc8d576e217296c8df16e2c30b25393d25fc5cd5a06aed8/typos-1.29.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4181b651b7659d386bfa3bfdb0f2d865f27d2f3805b21b7db92141cdd72e030e", size = 3353559 }, + { url = "https://files.pythonhosted.org/packages/6b/35/fef92b59dc68120c870ba0eb71fe9fd4215b841dd7349b8a47f69659ca9d/typos-1.29.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2f7ed324feb5694d64defdbb532bd66731e45fbb8553c81c0d5dd8e9eb8543be", size = 4177205 }, + { url = "https://files.pythonhosted.org/packages/92/55/66a881a449268d562b57e8ab8617f3a3824549c1cbd2723aab8fb3ff5893/typos-1.29.5-py3-none-win32.whl", hash = "sha256:dc382d6afb9b01f25df63e845df96fee232b958b14083de7a5ee721e1ebf1a1c", size = 2740654 }, + { url = "https://files.pythonhosted.org/packages/53/e0/7c33a98dc2cceebbb51224ef2408c6be653c96bced5858a699c211324055/typos-1.29.5-py3-none-win_amd64.whl", hash = "sha256:ee7081dca0d0e046121b67a7a5113e2d7cad069a13e489682730cd07fc1020a8", size = 2890452 }, ] [[package]] name = "yutto" -version = "2.0.0rc1" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, @@ -337,6 +644,8 @@ dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "dict2xml" }, { name = "httpx", extra = ["http2", "socks"] }, + { name = "pydantic" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] @@ -344,6 +653,7 @@ dependencies = [ dev = [ { name = "pyright" }, { name = "pytest" }, + { name = "pytest-codspeed" }, { name = "pytest-rerunfailures" }, { name = "ruff" }, { name = "syrupy" }, @@ -356,16 +666,28 @@ requires-dist = [ { name = "biliass", editable = "packages/biliass" }, { name = "colorama", marker = "sys_platform == 'win32'", specifier = ">=0.4.6" }, { name = "dict2xml", specifier = ">=1.7.6" }, - { name = "httpx", extras = ["http2", "socks"], specifier = ">=0.27.0" }, + { name = "httpx", extras = ["http2", "socks"], specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.10.6" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.2" }, { name = "typing-extensions", specifier = ">=4.12.2" }, ] [package.metadata.requires-dev] dev = [ - { name = "pyright", specifier = ">=1.1.381" }, - { name = "pytest", specifier = ">=8.3.2" }, - { name = "pytest-rerunfailures", specifier = ">=14.0" }, - { name = "ruff", specifier = ">=0.6.7" }, - { name = "syrupy", specifier = ">=4.7.1" }, - { name = "typos", specifier = ">=1.24.6" }, + { name = "pyright", specifier = ">=1.1.393" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-codspeed", specifier = ">=3.1.2" }, + { name = "pytest-rerunfailures", specifier = ">=15.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "syrupy", specifier = ">=4.8.1" }, + { name = "typos", specifier = ">=1.29.5" }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, ]