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