diff --git a/Cargo.lock b/Cargo.lock index 16477da..8370382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,6 +432,7 @@ name = "biliup" version = "1.2.8" dependencies = [ "async-trait", + "axum", "base64", "bon", "byteorder", diff --git a/crates/biliup/Cargo.toml b/crates/biliup/Cargo.toml index e1cdda9..cc96510 100644 --- a/crates/biliup/Cargo.toml +++ b/crates/biliup/Cargo.toml @@ -46,6 +46,7 @@ libsm = "0.6.0" tracing-subscriber = { workspace = true, features = ["env-filter"] } # 添加特性(特性是累加的) } tempfile = "3.14" http = "1" +axum = "0.8" [features] cli = ["clap"] diff --git a/crates/biliup/src/uploader.rs b/crates/biliup/src/uploader.rs index ecfe68b..42c659f 100644 --- a/crates/biliup/src/uploader.rs +++ b/crates/biliup/src/uploader.rs @@ -14,6 +14,7 @@ use std::task::{Context, Poll}; pub mod bilibili; pub mod credential; pub mod line; +pub mod season; pub mod util; #[derive(Deserialize, Serialize, Debug, Clone)] diff --git a/crates/biliup/src/uploader/bilibili.rs b/crates/biliup/src/uploader/bilibili.rs index 67bc88a..9cb3475 100644 --- a/crates/biliup/src/uploader/bilibili.rs +++ b/crates/biliup/src/uploader/bilibili.rs @@ -834,7 +834,7 @@ impl BiliBili { Err(Kind::Custom(result.message)) } - fn get_csrf(&self) -> Result<&str> { + pub(crate) fn get_csrf(&self) -> Result<&str> { let csrf = self .login_info .cookie_info diff --git a/crates/biliup/src/uploader/season.rs b/crates/biliup/src/uploader/season.rs new file mode 100644 index 0000000..b14bd58 --- /dev/null +++ b/crates/biliup/src/uploader/season.rs @@ -0,0 +1,815 @@ +//! 合集(season)管理:列合集、查小节、公共稿件信息、加入 / 移出小节、排序。 +//! +//! 接口与参数对齐 `biliup/plugins/bili_webup.py` 里 `list_seasons` 到 +//! `sort_season_episodes` 的实现。响应结构体只声明用得到的字段,其余字段收进 +//! `extra`,B 站新增字段不会导致解析失败。 + +use crate::error::{Kind, Result}; +use crate::uploader::bilibili::BiliBili; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value, json}; +use std::time::Duration; + +const MEMBER_HOST: &str = "https://member.bilibili.com"; +const API_HOST: &str = "https://api.bilibili.com"; +const TIMEOUT: Duration = Duration::from_secs(10); +const SORT_TIMEOUT: Duration = Duration::from_secs(15); + +fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +/// `list_seasons` 的返回:一页合集。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SeasonPage { + #[serde(default, deserialize_with = "null_as_default")] + pub seasons: Vec, + #[serde(default)] + pub total: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SeasonEntry { + pub season: SeasonInfo, + #[serde(default, deserialize_with = "null_as_default")] + pub sections: SectionList, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SeasonInfo { + pub id: u64, + #[serde(default)] + pub title: String, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SectionList { + #[serde(default, deserialize_with = "null_as_default")] + pub sections: Vec, + #[serde(flatten)] + pub extra: Map, +} + +/// 合集下的小节;每个合集至少有一个默认小节(通常叫「正片」)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SectionInfo { + pub id: u64, + #[serde(default)] + pub title: String, + #[serde(default, rename = "seasonId")] + pub season_id: Option, + #[serde(flatten)] + pub extra: Map, +} + +/// `get_season_section` 的返回:小节信息和其中的稿件。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SectionDetail { + pub section: SectionInfo, + #[serde(default, deserialize_with = "null_as_default")] + pub episodes: Vec, + #[serde(flatten)] + pub extra: Map, +} + +/// 小节里的一条稿件。`id` 是合集内部的 episode id,不是 aid。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Episode { + pub id: u64, + pub aid: u64, + #[serde(default)] + pub cid: u64, + #[serde(default)] + pub title: String, + #[serde(flatten)] + pub extra: Map, +} + +/// `api.bilibili.com/x/web-interface/view` 的返回,任何公开稿件都能查。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArchiveView { + pub aid: u64, + #[serde(default)] + pub bvid: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub cid: u64, + #[serde(default, deserialize_with = "null_as_default")] + pub pages: Vec, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ViewPage { + pub cid: u64, + #[serde(default)] + pub page: u32, + #[serde(default)] + pub part: String, + #[serde(flatten)] + pub extra: Map, +} + +impl ArchiveView { + /// 第一个分 P 的 cid,加入合集时用它。 + pub fn first_cid(&self) -> u64 { + self.pages.first().map_or(self.cid, |page| page.cid) + } +} + +/// 加入小节时每条稿件的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EpisodeAdd { + pub aid: u64, + pub cid: u64, + pub title: String, + pub charging_pay: u8, +} + +impl From<&ArchiveView> for EpisodeAdd { + fn from(view: &ArchiveView) -> Self { + Self { + aid: view.aid, + cid: view.first_cid(), + title: view.title.clone(), + charging_pay: 0, + } + } +} + +/// 排序时每条稿件的新位置;`sort` 从 1 开始。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct EpisodeSort { + pub id: u64, + pub sort: u32, +} + +struct SeasonApi<'a> { + bili: &'a BiliBili, + member: &'a str, + api: &'a str, +} + +impl BiliBili { + fn season_api(&self) -> SeasonApi<'_> { + SeasonApi { + bili: self, + member: MEMBER_HOST, + api: API_HOST, + } + } + + /// 列出自己的合集,`pn` 从 1 开始。 + pub async fn seasons(&self, pn: u32, ps: u32) -> Result { + self.season_api().seasons(pn, ps).await + } + + /// 查询小节及其中的稿件;`sort` 目前 B 站只认 `"desc"`。 + pub async fn season_section( + &self, + section_id: u64, + sort: Option<&str>, + ) -> Result { + self.season_api().season_section(section_id, sort).await + } + + /// 查询公开稿件信息(标题、cid 等),不要求是自己的稿件。 + pub async fn archive_view(&self, aid: u64) -> Result { + self.season_api().archive_view(aid).await + } + + /// 把稿件加入小节,可一次加入多条。 + pub async fn add_to_season(&self, section_id: u64, episodes: &[EpisodeAdd]) -> Result<()> { + self.season_api().add_to_season(section_id, episodes).await + } + + /// 从合集移出一条稿件;参数是 [`Episode::id`],不是 aid。 + pub async fn remove_from_season(&self, episode_id: u64) -> Result<()> { + self.season_api().remove_from_season(episode_id).await + } + + /// 重排小节里的稿件。`sorts` 必须覆盖小节里的全部稿件, + /// `section_id`、`season_id`、`section_title` 缺一不可,否则 B 站返回 -400。 + pub async fn sort_season_episodes( + &self, + section_id: u64, + season_id: u64, + sorts: &[EpisodeSort], + section_title: &str, + ) -> Result<()> { + self.season_api() + .sort_season_episodes(section_id, season_id, sorts, section_title) + .await + } +} + +impl SeasonApi<'_> { + async fn seasons(&self, pn: u32, ps: u32) -> Result { + let request = self + .bili + .client + .get(format!("{}/x2/creative/web/seasons", self.member)) + .query(&[ + ("pn", pn.to_string()), + ("ps", ps.to_string()), + ("order", "mtime".into()), + ("sort", "desc".into()), + ("draft", "1".into()), + ]); + let data = call(request, TIMEOUT, "list seasons").await?; + Ok(serde_json::from_value(data)?) + } + + async fn season_section(&self, section_id: u64, sort: Option<&str>) -> Result { + let mut query = vec![("id", section_id.to_string())]; + if let Some(sort) = sort.filter(|sort| !sort.is_empty()) { + query.push(("sort", sort.to_string())); + } + let request = self + .bili + .client + .get(format!("{}/x2/creative/web/season/section", self.member)) + .query(&query); + let data = call(request, TIMEOUT, "get season section").await?; + Ok(serde_json::from_value(data)?) + } + + async fn archive_view(&self, aid: u64) -> Result { + let request = self + .bili + .client + .get(format!("{}/x/web-interface/view", self.api)) + .query(&[("aid", aid)]); + let data = call(request, TIMEOUT, "get archive view").await?; + Ok(serde_json::from_value(data)?) + } + + async fn add_to_season(&self, section_id: u64, episodes: &[EpisodeAdd]) -> Result<()> { + let csrf = self.bili.get_csrf()?; + let request = self + .bili + .client + .post(format!( + "{}/x2/creative/web/season/section/episodes/add", + self.member + )) + .query(&[("csrf", csrf)]) + .json(&json!({ + "sectionId": section_id, + "episodes": episodes, + "csrf": csrf, + })); + call(request, TIMEOUT, "add to season").await?; + Ok(()) + } + + async fn remove_from_season(&self, episode_id: u64) -> Result<()> { + let csrf = self.bili.get_csrf()?; + let request = self + .bili + .client + .post(format!( + "{}/x2/creative/web/season/section/episode/del", + self.member + )) + .form(&[("id", episode_id.to_string()), ("csrf", csrf.to_string())]); + call(request, TIMEOUT, "remove from season").await?; + Ok(()) + } + + async fn sort_season_episodes( + &self, + section_id: u64, + season_id: u64, + sorts: &[EpisodeSort], + section_title: &str, + ) -> Result<()> { + let csrf = self.bili.get_csrf()?; + let request = self + .bili + .client + .post(format!( + "{}/x2/creative/web/season/section/edit", + self.member + )) + .query(&[("csrf", csrf)]) + .json(&json!({ + "section": { + "id": section_id, + "type": 1, + "seasonId": season_id, + "title": section_title, + }, + "sorts": sorts, + "captcha_token": "", + })); + call(request, SORT_TIMEOUT, "sort season episodes").await?; + Ok(()) + } +} + +/// 发出请求并检查 B 站的 `code`,成功时返回 `data`(可能是 `null`)。 +async fn call(request: reqwest::RequestBuilder, timeout: Duration, action: &str) -> Result { + let mut body: Value = request + .timeout(timeout) + .send() + .await? + .error_for_status()? + .json() + .await?; + match body["code"].as_i64() { + Some(0) => Ok(body["data"].take()), + Some(code) => Err(Kind::Custom(format!( + "{action} failed: code {code}, message {}", + body["message"].as_str().unwrap_or_default() + ))), + None => Err(Kind::Custom(format!( + "{action} failed: response has no code" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::Router; + use axum::body::Bytes; + use axum::extract::State; + use axum::http::{HeaderMap, Method, Uri, header}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + const CSRF: &str = "test-csrf"; + + #[derive(Debug, Clone)] + struct Captured { + method: Method, + path: String, + query: HashMap, + content_type: String, + body: Bytes, + } + + impl Captured { + fn json(&self) -> Value { + serde_json::from_slice(&self.body).unwrap() + } + + fn form(&self) -> HashMap { + serde_urlencoded::from_bytes(&self.body).unwrap() + } + } + + #[derive(Clone)] + struct FakeState { + response: Value, + captured: Arc>>, + } + + struct FakeServer { + base: String, + captured: Arc>>, + } + + impl FakeServer { + async fn start(response: Value) -> Self { + async fn handler( + State(state): State, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, + ) -> axum::Json { + let query = uri + .query() + .map(|q| serde_urlencoded::from_str(q).unwrap()) + .unwrap_or_default(); + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + state.captured.lock().unwrap().push(Captured { + method, + path: uri.path().to_string(), + query, + content_type, + body, + }); + axum::Json(state.response.clone()) + } + + let captured = Arc::new(Mutex::new(Vec::new())); + let app = Router::new().fallback(handler).with_state(FakeState { + response, + captured: captured.clone(), + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { base, captured } + } + + fn only_request(&self) -> Captured { + let captured = self.captured.lock().unwrap(); + assert_eq!(captured.len(), 1, "expected exactly one request"); + captured[0].clone() + } + } + + fn bili() -> BiliBili { + BiliBili { + client: reqwest::Client::new(), + login_info: serde_json::from_value(json!({ + "cookie_info": {"cookies": [ + {"name": "SESSDATA", "value": "sess"}, + {"name": "bili_jct", "value": CSRF} + ]}, + "sso": [], + "token_info": { + "access_token": "", + "expires_in": 0, + "mid": 0, + "refresh_token": "" + }, + "platform": null + })) + .unwrap(), + } + } + + fn api<'a>(bili: &'a BiliBili, server: &'a FakeServer) -> SeasonApi<'a> { + SeasonApi { + bili, + member: &server.base, + api: &server.base, + } + } + + fn ok(data: Value) -> Value { + json!({"code": 0, "message": "0", "ttl": 1, "data": data}) + } + + fn failure() -> Value { + json!({"code": -101, "message": "账号未登录", "ttl": 1, "data": null}) + } + + fn assert_failure(result: Result, action: &str) { + let message = result.unwrap_err().to_string(); + assert!(message.contains(action), "{message}"); + assert!(message.contains("code -101"), "{message}"); + } + + #[tokio::test] + async fn seasons_request_shape_and_parsing() { + let server = FakeServer::start(ok(json!({ + "seasons": [{ + "season": {"id": 7320255, "title": "我的合集", "isEnd": 0}, + "sections": {"sections": [ + {"id": 8081933, "title": "正片", "seasonId": 7320255, "epCount": 2} + ]}, + "part_episodes": null + }], + "total": 1, + "play_type": 1 + }))) + .await; + let bili = bili(); + + let page = api(&bili, &server).seasons(2, 10).await.unwrap(); + + let req = server.only_request(); + assert_eq!(req.method, Method::GET); + assert_eq!(req.path, "/x2/creative/web/seasons"); + let expected: HashMap = [ + ("pn", "2"), + ("ps", "10"), + ("order", "mtime"), + ("sort", "desc"), + ("draft", "1"), + ] + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + assert_eq!(req.query, expected); + + assert_eq!(page.total, Some(1)); + assert_eq!(page.extra["play_type"], 1); + let entry = &page.seasons[0]; + assert_eq!(entry.season.id, 7320255); + assert_eq!(entry.season.title, "我的合集"); + assert_eq!(entry.season.extra["isEnd"], 0); + let section = &entry.sections.sections[0]; + assert_eq!(section.id, 8081933); + assert_eq!(section.season_id, Some(7320255)); + assert_eq!(section.extra["epCount"], 2); + } + + #[tokio::test] + async fn seasons_accepts_null_list() { + let server = FakeServer::start(ok(json!({"seasons": null, "total": 0}))).await; + let bili = bili(); + let page = api(&bili, &server).seasons(1, 30).await.unwrap(); + assert!(page.seasons.is_empty()); + } + + #[tokio::test] + async fn seasons_reports_api_error() { + let server = FakeServer::start(failure()).await; + let bili = bili(); + assert_failure(api(&bili, &server).seasons(1, 30).await, "list seasons"); + } + + #[tokio::test] + async fn season_section_request_shape_and_parsing() { + let server = FakeServer::start(ok(json!({ + "section": {"id": 8081933, "title": "正片", "seasonId": 7320255, "type": 1}, + "episodes": [ + {"id": 176218279, "aid": 12345, "cid": 67890, "title": "第一集", "order": 1} + ] + }))) + .await; + let bili = bili(); + + let detail = api(&bili, &server) + .season_section(8081933, Some("desc")) + .await + .unwrap(); + + let req = server.only_request(); + assert_eq!(req.method, Method::GET); + assert_eq!(req.path, "/x2/creative/web/season/section"); + assert_eq!(req.query.len(), 2); + assert_eq!(req.query["id"], "8081933"); + assert_eq!(req.query["sort"], "desc"); + + assert_eq!(detail.section.title, "正片"); + assert_eq!(detail.section.season_id, Some(7320255)); + let episode = &detail.episodes[0]; + assert_eq!(episode.id, 176218279); + assert_eq!(episode.aid, 12345); + assert_eq!(episode.cid, 67890); + assert_eq!(episode.extra["order"], 1); + } + + #[tokio::test] + async fn season_section_omits_empty_sort_and_accepts_null_episodes() { + let server = FakeServer::start(ok(json!({ + "section": {"id": 1, "title": "正片"}, + "episodes": null + }))) + .await; + let bili = bili(); + + let detail = api(&bili, &server) + .season_section(1, Some("")) + .await + .unwrap(); + + let req = server.only_request(); + assert_eq!(req.query.len(), 1); + assert_eq!(req.query["id"], "1"); + assert!(detail.episodes.is_empty()); + } + + #[tokio::test] + async fn season_section_reports_api_error() { + let server = FakeServer::start(failure()).await; + let bili = bili(); + assert_failure( + api(&bili, &server).season_section(1, None).await, + "get season section", + ); + } + + #[tokio::test] + async fn archive_view_request_shape_and_parsing() { + let server = FakeServer::start(ok(json!({ + "aid": 12345, + "bvid": "BV1xx411c7mD", + "title": "测试稿件", + "cid": 67890, + "pages": [{"cid": 67890, "page": 1, "part": "P1", "duration": 60}], + "owner": {"mid": 1} + }))) + .await; + let bili = bili(); + + let view = api(&bili, &server).archive_view(12345).await.unwrap(); + + let req = server.only_request(); + assert_eq!(req.method, Method::GET); + assert_eq!(req.path, "/x/web-interface/view"); + assert_eq!(req.query.len(), 1); + assert_eq!(req.query["aid"], "12345"); + + assert_eq!(view.bvid, "BV1xx411c7mD"); + assert_eq!(view.first_cid(), 67890); + assert_eq!(view.pages[0].extra["duration"], 60); + assert_eq!(view.extra["owner"]["mid"], 1); + assert_eq!( + EpisodeAdd::from(&view), + EpisodeAdd { + aid: 12345, + cid: 67890, + title: "测试稿件".into(), + charging_pay: 0, + } + ); + } + + #[tokio::test] + async fn archive_view_reports_api_error() { + let server = FakeServer::start(failure()).await; + let bili = bili(); + assert_failure( + api(&bili, &server).archive_view(1).await, + "get archive view", + ); + } + + #[tokio::test] + async fn add_to_season_request_shape() { + let server = FakeServer::start(ok(Value::Null)).await; + let bili = bili(); + let episodes = [EpisodeAdd { + aid: 12345, + cid: 67890, + title: "测试稿件".into(), + charging_pay: 0, + }]; + + api(&bili, &server) + .add_to_season(8081933, &episodes) + .await + .unwrap(); + + let req = server.only_request(); + assert_eq!(req.method, Method::POST); + assert_eq!(req.path, "/x2/creative/web/season/section/episodes/add"); + assert_eq!(req.query.len(), 1); + assert_eq!(req.query["csrf"], CSRF); + assert!(req.content_type.starts_with("application/json")); + assert_eq!( + req.json(), + json!({ + "sectionId": 8081933, + "episodes": [ + {"aid": 12345, "cid": 67890, "title": "测试稿件", "charging_pay": 0} + ], + "csrf": CSRF, + }) + ); + } + + #[tokio::test] + async fn add_to_season_reports_api_error() { + let server = FakeServer::start(failure()).await; + let bili = bili(); + assert_failure( + api(&bili, &server).add_to_season(1, &[]).await, + "add to season", + ); + } + + #[tokio::test] + async fn remove_from_season_request_shape() { + let server = FakeServer::start(ok(Value::Null)).await; + let bili = bili(); + + api(&bili, &server) + .remove_from_season(176218279) + .await + .unwrap(); + + let req = server.only_request(); + assert_eq!(req.method, Method::POST); + assert_eq!(req.path, "/x2/creative/web/season/section/episode/del"); + assert!(req.query.is_empty()); + assert_eq!(req.content_type, "application/x-www-form-urlencoded"); + let form = req.form(); + assert_eq!(form.len(), 2); + assert_eq!(form["id"], "176218279"); + assert_eq!(form["csrf"], CSRF); + } + + #[tokio::test] + async fn remove_from_season_reports_api_error() { + let server = FakeServer::start(failure()).await; + let bili = bili(); + assert_failure( + api(&bili, &server).remove_from_season(1).await, + "remove from season", + ); + } + + #[tokio::test] + async fn sort_season_episodes_request_shape() { + let server = FakeServer::start(ok(Value::Null)).await; + let bili = bili(); + let sorts = [ + EpisodeSort { id: 2, sort: 1 }, + EpisodeSort { id: 1, sort: 2 }, + ]; + + api(&bili, &server) + .sort_season_episodes(8081933, 7320255, &sorts, "正片") + .await + .unwrap(); + + let req = server.only_request(); + assert_eq!(req.method, Method::POST); + assert_eq!(req.path, "/x2/creative/web/season/section/edit"); + assert_eq!(req.query.len(), 1); + assert_eq!(req.query["csrf"], CSRF); + assert!(req.content_type.starts_with("application/json")); + assert_eq!( + req.json(), + json!({ + "section": {"id": 8081933, "type": 1, "seasonId": 7320255, "title": "正片"}, + "sorts": [{"id": 2, "sort": 1}, {"id": 1, "sort": 2}], + "captcha_token": "", + }) + ); + } + + #[tokio::test] + async fn sort_season_episodes_reports_api_error() { + let server = FakeServer::start(failure()).await; + let bili = bili(); + assert_failure( + api(&bili, &server) + .sort_season_episodes(1, 2, &[], "正片") + .await, + "sort season episodes", + ); + } + + #[tokio::test] + async fn write_calls_require_csrf_cookie() { + let server = FakeServer::start(ok(Value::Null)).await; + let mut bili = bili(); + bili.login_info.cookie_info = json!({"cookies": []}); + + let api = api(&bili, &server); + assert!(api.add_to_season(1, &[]).await.is_err()); + assert!(api.remove_from_season(1).await.is_err()); + assert!(api.sort_season_episodes(1, 2, &[], "正片").await.is_err()); + assert!(server.captured.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn http_error_status_is_reported() { + let app = + Router::new().fallback(|| async { (axum::http::StatusCode::PRECONDITION_FAILED, "") }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let bili = bili(); + let api = SeasonApi { + bili: &bili, + member: &base, + api: &base, + }; + let message = api.seasons(1, 30).await.unwrap_err().to_string(); + assert!(message.contains("412"), "{message}"); + } + + /// 真实账号只读冒烟:`BILIUP_SEASON_COOKIES=/path/to/cookies.json cargo test -p biliup -- --ignored season_live` + #[tokio::test] + #[ignore = "needs a real Bilibili cookie file in BILIUP_SEASON_COOKIES"] + async fn season_live_read_only() { + let path = std::env::var("BILIUP_SEASON_COOKIES") + .expect("set BILIUP_SEASON_COOKIES to a cookies.json path"); + let bili = crate::credential::login_by_cookies(&path, None) + .await + .unwrap(); + + let page = bili.seasons(1, 30).await.unwrap(); + println!("seasons: {}", page.seasons.len()); + if let Some(section) = page + .seasons + .first() + .and_then(|entry| entry.sections.sections.first()) + { + let detail = bili.season_section(section.id, None).await.unwrap(); + println!("section {} episodes: {}", section.id, detail.episodes.len()); + if let Some(episode) = detail.episodes.first() { + let view = bili.archive_view(episode.aid).await.unwrap(); + assert_eq!(view.aid, episode.aid); + } + } + } +}