From 970dfe8b37d69c13c74fd1d7f9b35325c57ef44f Mon Sep 17 00:00:00 2001 From: Noah Date: Mon, 14 Sep 2026 16:19:53 +0000 Subject: [PATCH] feat(upload): support first-class tid_v2 partition id (#1699) Add optional tid_v2 across Studio, config/DB, CLI, Web UI, stream-gears, and Python uploaders so new Bilibili partition IDs can be set alongside legacy tid. Fixes #1662; related to #1562. --- app/(app)/upload-manager/add/page.tsx | 1 + app/(app)/upload-manager/edit/page.tsx | 1 + app/lib/api-streamer.ts | 1 + app/ui/TemplateFields.tsx | 7 ++ biliup/plugins/bili_webup.py | 16 +++- biliup/plugins/bili_webup_sync.py | 8 +- crates/biliup-cli/migrations/5_add_tid_v2.sql | 2 + crates/biliup-cli/src/lib.rs | 38 ++++++++ crates/biliup-cli/src/server/common/upload.rs | 35 +++++++ crates/biliup-cli/src/server/config.rs | 23 +++++ .../infrastructure/models/upload_streamer.rs | 3 + crates/biliup/src/uploader/bilibili.rs | 92 +++++++++++++++++++ crates/stream-gears/src/lib.rs | 4 +- crates/stream-gears/src/uploader.rs | 3 + docs/content/docs/guide/introduction.md | 3 +- public/config.yaml | 1 + 16 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 crates/biliup-cli/migrations/5_add_tid_v2.sql diff --git a/app/(app)/upload-manager/add/page.tsx b/app/(app)/upload-manager/add/page.tsx index 1fd38fa..37749ab 100644 --- a/app/(app)/upload-manager/add/page.tsx +++ b/app/(app)/upload-manager/add/page.tsx @@ -60,6 +60,7 @@ export default function Add() { id: values.id, copyright_source: values.copyright_source ?? '', tid: values.tid[1], + tid_v2: values.tid_v2 ?? null, cover_path: values.cover_path ?? '', title: values.title ?? '', description: values.description ?? '', diff --git a/app/(app)/upload-manager/edit/page.tsx b/app/(app)/upload-manager/edit/page.tsx index bad107e..bef98a7 100644 --- a/app/(app)/upload-manager/edit/page.tsx +++ b/app/(app)/upload-manager/edit/page.tsx @@ -76,6 +76,7 @@ const Edit = () => { id: values?.id, copyright_source: values?.copyright_source ?? '', tid: values?.tid[1], + tid_v2: values?.tid_v2 ?? null, cover_path: values?.cover_path ?? '', title: values?.title ?? '', description: values?.description ?? '', diff --git a/app/lib/api-streamer.ts b/app/lib/api-streamer.ts index 9a31d40..e5dd2b0 100644 --- a/app/lib/api-streamer.ts +++ b/app/lib/api-streamer.ts @@ -71,6 +71,7 @@ export interface StudioEntity { copyright: number; copyright_source: string; tid: number; + tid_v2?: number | null; cover_path: string; title: string; description: string; diff --git a/app/ui/TemplateFields.tsx b/app/ui/TemplateFields.tsx index 858f677..ba83445 100644 --- a/app/ui/TemplateFields.tsx +++ b/app/ui/TemplateFields.tsx @@ -249,6 +249,13 @@ const TemplateFields: React.FC> dropdownStyle={{ maxWidth: 670 }} rules={[{ required: true }]} /> + bool { streamer.title.is_some() || streamer.tid.is_some() + || streamer.tid_v2.is_some() || streamer.copyright.is_some() || streamer.copyright_source.is_some() || streamer.cover_path.is_some() @@ -399,3 +401,39 @@ mod server_exposure_tests { ); } } + + +#[cfg(test)] +mod tid_v2_config_tests { + use super::{has_upload_config, to_upload_streamer_insert}; + use crate::server::config::StreamerConfig; + + #[test] + fn upload_insert_propagates_tid_v2() { + let streamer = StreamerConfig { + tid: Some(95), + tid_v2: Some(2102), + tags: Some(vec!["tag".into()]), + ..Default::default() + }; + assert!(has_upload_config(&streamer)); + let insert = to_upload_streamer_insert("demo", &streamer, None) + .unwrap() + .expect("should create insert"); + assert_eq!(insert.tid, Some(95)); + assert_eq!(insert.tid_v2, Some(2102)); + } + + #[test] + fn upload_insert_tid_only_keeps_tid_v2_none() { + let streamer = StreamerConfig { + tid: Some(171), + ..Default::default() + }; + let insert = to_upload_streamer_insert("demo", &streamer, None) + .unwrap() + .expect("should create insert"); + assert_eq!(insert.tid, Some(171)); + assert!(insert.tid_v2.is_none()); + } +} diff --git a/crates/biliup-cli/src/server/common/upload.rs b/crates/biliup-cli/src/server/common/upload.rs index 42db3bc..199be26 100644 --- a/crates/biliup-cli/src/server/common/upload.rs +++ b/crates/biliup-cli/src/server/common/upload.rs @@ -419,6 +419,7 @@ pub(crate) async fn build_studio( )) .tag(upload_config.tags.join(",")) .maybe_tid(upload_config.tid) + .maybe_tid_v2(upload_config.tid_v2) .title(recorder.format_title()) .videos(videos) .dolby(upload_config.dolby.unwrap_or_default()) @@ -613,6 +614,40 @@ mod tests { assert_eq!(scheduled_publish_ts(None, 1_700_000_000), None); } + #[test] + fn studio_submit_payload_includes_tid_v2_when_set() { + // submit_by_app / submit_by_web both POST `.json(studio)`; verify body shape. + let studio: Studio = serde_json::from_value(serde_json::json!({ + "tid": 95, + "tid_v2": 2102, + "title": "payload", + "copyright": 1, + "up_selection_reply": false, + "up_close_reply": false, + "up_close_danmu": false + })) + .unwrap(); + let body = serde_json::to_value(&studio).unwrap(); + assert_eq!(body["tid"], 95); + assert_eq!(body["tid_v2"], 2102); + } + + #[test] + fn studio_submit_payload_omits_tid_v2_for_tid_only() { + let studio: Studio = serde_json::from_value(serde_json::json!({ + "tid": 171, + "title": "payload", + "copyright": 1, + "up_selection_reply": false, + "up_close_reply": false, + "up_close_danmu": false + })) + .unwrap(); + let body = serde_json::to_value(&studio).unwrap(); + assert_eq!(body["tid"], 171); + assert!(body.get("tid_v2").is_none()); + } + #[test] fn aid_from_submit_reads_numeric_aid() { let ret: ResponseData = serde_json::from_value(serde_json::json!({ diff --git a/crates/biliup-cli/src/server/config.rs b/crates/biliup-cli/src/server/config.rs index b2ec6e8..3bf007c 100644 --- a/crates/biliup-cli/src/server/config.rs +++ b/crates/biliup-cli/src/server/config.rs @@ -291,6 +291,10 @@ pub struct StreamerConfig { #[serde(default)] pub tid: Option, + /// 新版分区ID (tid_v2) + #[serde(default)] + pub tid_v2: Option, + /// 版权类型 #[serde(default)] pub copyright: Option, @@ -667,6 +671,25 @@ mod tests { assert_eq!(config.file_size, None); } + #[test] + fn streamer_config_tid_v2_round_trip() { + let raw = r#"{"url":["https://example.com"],"tid":95,"tid_v2":2102,"tags":["a"]}"#; + let cfg: StreamerConfig = serde_json::from_str(raw).unwrap(); + assert_eq!(cfg.tid, Some(95)); + assert_eq!(cfg.tid_v2, Some(2102)); + let back = serde_json::to_value(&cfg).unwrap(); + assert_eq!(back["tid"], 95); + assert_eq!(back["tid_v2"], 2102); + } + + #[test] + fn streamer_config_tid_only_leaves_tid_v2_unset() { + let cfg: StreamerConfig = + serde_json::from_str(r#"{"url":["https://example.com"],"tid":171}"#).unwrap(); + assert_eq!(cfg.tid, Some(171)); + assert!(cfg.tid_v2.is_none()); + } + #[test] fn deserialize_sync_save_dir() { let config: Config = diff --git a/crates/biliup-cli/src/server/infrastructure/models/upload_streamer.rs b/crates/biliup-cli/src/server/infrastructure/models/upload_streamer.rs index 8d19abf..cd05237 100644 --- a/crates/biliup-cli/src/server/infrastructure/models/upload_streamer.rs +++ b/crates/biliup-cli/src/server/infrastructure/models/upload_streamer.rs @@ -15,6 +15,8 @@ pub struct UploadStreamer { pub title: Option, /// 分区ID pub tid: Option, + /// 新版分区ID (tid_v2) + pub tid_v2: Option, /// 版权类型(1-自制,2-转载) pub copyright: Option, /// 转载来源 @@ -65,6 +67,7 @@ pub struct InsertUploadStreamer { pub template_name: String, pub title: Option, pub tid: Option, + pub tid_v2: Option, pub copyright: Option, pub copyright_source: Option, pub cover_path: Option, diff --git a/crates/biliup/src/uploader/bilibili.rs b/crates/biliup/src/uploader/bilibili.rs index 43debb3..67bc88a 100644 --- a/crates/biliup/src/uploader/bilibili.rs +++ b/crates/biliup/src/uploader/bilibili.rs @@ -32,6 +32,11 @@ pub struct Studio { #[builder(default = 171)] pub tid: u16, + /// 新版投稿分区 tid_v2(可选;不设置时不提交该字段) + #[cfg_attr(feature = "cli", clap(long))] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tid_v2: Option, + /// 视频封面 #[cfg_attr(feature = "cli", clap(long, default_value_t))] #[serde(default)] @@ -1111,3 +1116,90 @@ mod archive_tests { ); } } + +#[cfg(test)] +mod studio_tid_v2_tests { + use super::Studio; + + fn base_studio_json() -> serde_json::Value { + serde_json::json!({ + "copyright": 1, + "source": "", + "tid": 95, + "cover": "", + "title": "t", + "desc": "", + "dynamic": "", + "tag": "", + "dolby": 0, + "lossless_music": 0, + "no_reprint": 0, + "charging_pay": 0, + "up_selection_reply": false, + "up_close_reply": false, + "up_close_danmu": false + }) + } + + #[test] + fn studio_json_omits_tid_v2_when_unset() { + let studio: Studio = serde_json::from_value(base_studio_json()).unwrap(); + assert!(studio.tid_v2.is_none()); + let value = serde_json::to_value(&studio).unwrap(); + assert_eq!(value["tid"], 95); + assert!( + value.get("tid_v2").is_none(), + "unset tid_v2 must not appear in JSON: {value}" + ); + } + + #[test] + fn studio_json_includes_tid_v2_when_set() { + let mut raw = base_studio_json(); + raw["tid_v2"] = serde_json::json!(2102); + let studio: Studio = serde_json::from_value(raw).unwrap(); + assert_eq!(studio.tid_v2, Some(2102)); + let value = serde_json::to_value(&studio).unwrap(); + assert_eq!(value["tid"], 95); + assert_eq!(value["tid_v2"], 2102); + } + + #[test] + fn studio_tid_v2_does_not_conflict_with_extra_fields() { + let mut raw = base_studio_json(); + raw["tid_v2"] = serde_json::json!(2102); + raw["watermark"] = serde_json::json!({"state": 0}); + let studio: Studio = serde_json::from_value(raw).unwrap(); + assert_eq!(studio.tid_v2, Some(2102)); + let value = serde_json::to_value(&studio).unwrap(); + assert_eq!(value["tid_v2"], 2102); + assert_eq!(value["watermark"]["state"], 0); + assert_eq!( + studio + .extra_fields + .as_ref() + .unwrap() + .get("watermark") + .unwrap()["state"], + 0 + ); + + // Prefer first-class field when present; unrelated flatten keys still work. + let via_extra: Studio = serde_json::from_value(serde_json::json!({ + "tid": 95, + "title": "t", + "watermark": {"state": 0} + })) + .unwrap(); + assert!(via_extra.tid_v2.is_none()); + assert_eq!( + via_extra + .extra_fields + .as_ref() + .unwrap() + .get("watermark") + .unwrap()["state"], + 0 + ); + } +} diff --git a/crates/stream-gears/src/lib.rs b/crates/stream-gears/src/lib.rs index 52af885..e99d745 100644 --- a/crates/stream-gears/src/lib.rs +++ b/crates/stream-gears/src/lib.rs @@ -303,13 +303,14 @@ fn login_by_web_qrcode( #[allow(clippy::too_many_arguments)] #[pyfunction] -#[pyo3(signature = (video_path, cookie_file, title, tid=171, tag="".to_string(), copyright=2, source="".to_string(), desc="".to_string(), dynamic="".to_string(), cover="".to_string(), dolby=0, lossless_music=0, no_reprint=0, charging_pay=0, up_close_reply=false, up_selection_reply=false, up_close_danmu=false, limit=3, desc_v2=vec![], dtime=None, line=None, extra_fields="".to_string(), submit=None, proxy=None))] +#[pyo3(signature = (video_path, cookie_file, title, tid=171, tid_v2=None, tag="".to_string(), copyright=2, source="".to_string(), desc="".to_string(), dynamic="".to_string(), cover="".to_string(), dolby=0, lossless_music=0, no_reprint=0, charging_pay=0, up_close_reply=false, up_selection_reply=false, up_close_danmu=false, limit=3, desc_v2=vec![], dtime=None, line=None, extra_fields="".to_string(), submit=None, proxy=None))] fn upload( py: Python<'_>, video_path: Vec, cookie_file: PathBuf, title: String, tid: u16, + tid_v2: Option, tag: String, copyright: u8, source: String, @@ -365,6 +366,7 @@ fn upload( .limit(limit) .title(title) .tid(tid) + .maybe_tid_v2(tid_v2) .tag(tag) .copyright(copyright) .source(source) diff --git a/crates/stream-gears/src/uploader.rs b/crates/stream-gears/src/uploader.rs index df5c717..91ed575 100644 --- a/crates/stream-gears/src/uploader.rs +++ b/crates/stream-gears/src/uploader.rs @@ -74,6 +74,7 @@ pub struct StudioPre { limit: usize, title: String, tid: u16, + tid_v2: Option, tag: String, copyright: u8, source: String, @@ -111,6 +112,7 @@ pub async fn upload( limit, title, tid, + tid_v2, tag, copyright, source, @@ -156,6 +158,7 @@ pub async fn upload( .source(source) .tag(tag) .tid(tid) + .maybe_tid_v2(tid_v2) .title(title) .videos(videos) .dolby(dolby) diff --git a/docs/content/docs/guide/introduction.md b/docs/content/docs/guide/introduction.md index 41cdf3a..a8e2926 100644 --- a/docs/content/docs/guide/introduction.md +++ b/docs/content/docs/guide/introduction.md @@ -126,7 +126,7 @@ sudo docker exec -it imageId /bin/bash ## yaml配置文件示例 可选项见[完整配置文件](https://github.com/biliup/biliup/tree/master/public/config.yaml), -tid投稿分区见[Wiki](https://github.com/biliup/biliup/wiki) +tid投稿分区见[Wiki](https://github.com/biliup/biliup/wiki);可选 `tid_v2` 指定新版分区 ID(与旧版 `tid` 可同时设置) ```yaml streamers: xxx直播录像: @@ -148,6 +148,7 @@ video.desc = '视频简介' video.source = '添加转载地址说明' # 设置视频分区,默认为122 野生技能协会 video.tid = 171 +# video.tid_v2 = 2102 # 可选:新版分区 ID video.set_tag(['星际争霸2', '电子竞技']) video.dynamic = '动态内容' lines = 'AUTO' diff --git a/public/config.yaml b/public/config.yaml index 73832fa..6c4598c 100644 --- a/public/config.yaml +++ b/public/config.yaml @@ -195,6 +195,7 @@ streamers: - https://www.panda.tv/1160340 #填写多个url时只能同时下载一个,如需多个同时下载请单独配置 title: "{title}第一视角%Y-%m-%d{streamer}{url}" ### 自定义标题的时间格式, {title}代表当场直播间标题 {streamer}代表在本config里面设置的主播名称 {url}代表设置的该主播的第一条直播间链接 tid: 171 ### 投稿分区码,171为电子竞技分区 + #tid_v2: 2102 ### 新版投稿分区码 tid_v2(可选;例如摄影摄像二级分区) copyright: 2 ### 1为自制 #cover_path: /cover/up.jpg ###封面 description: |- ### 支持strftime, {title}, {streamer}, {url}占位符。注:此处请注意缩进,确保所有文字的缩进都与"视频简介"相同或者更多。