feat(uploader): season (合集) API in biliup crate (#1727)

Add BiliBili::seasons, season_section, archive_view, add_to_season,
remove_from_season and sort_season_episodes, matching the endpoints and
payloads of bili_webup.py. Response types keep unknown fields in
`extra` so new fields from Bilibili do not break parsing.

Ref #1721
This commit is contained in:
Noah authored and GitHub committed 2026-09-24 03:55:02 +00:00
1 parent 44df87f211
commit 8d482db4a6
5 files changed
+819 -1

No files matched your search

Generated
+1
View File
@@ -432,6 +432,7 @@ name = "biliup"
version = "1.2.8"
dependencies = [
"async-trait",
"axum",
"base64",
"bon",
"byteorder",
+1
View File
@@ -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"]
+1
View File
@@ -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)]
+1 -1
View File
@@ -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
+815
View File
@@ -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<T, D::Error>
where
D: Deserializer<'de>,
T: Default + Deserialize<'de>,
{
Ok(Option::<T>::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<SeasonEntry>,
#[serde(default)]
pub total: Option<u64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[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<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonInfo {
pub id: u64,
#[serde(default)]
pub title: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SectionList {
#[serde(default, deserialize_with = "null_as_default")]
pub sections: Vec<SectionInfo>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// 合集下的小节;每个合集至少有一个默认小节(通常叫「正片」)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionInfo {
pub id: u64,
#[serde(default)]
pub title: String,
#[serde(default, rename = "seasonId")]
pub season_id: Option<u64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// `get_season_section` 的返回:小节信息和其中的稿件。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionDetail {
pub section: SectionInfo,
#[serde(default, deserialize_with = "null_as_default")]
pub episodes: Vec<Episode>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// 小节里的一条稿件。`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<String, Value>,
}
/// `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<ViewPage>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[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<String, Value>,
}
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<SeasonPage> {
self.season_api().seasons(pn, ps).await
}
/// 查询小节及其中的稿件;`sort` 目前 B 站只认 `"desc"`。
pub async fn season_section(
&self,
section_id: u64,
sort: Option<&str>,
) -> Result<SectionDetail> {
self.season_api().season_section(section_id, sort).await
}
/// 查询公开稿件信息(标题、cid 等),不要求是自己的稿件。
pub async fn archive_view(&self, aid: u64) -> Result<ArchiveView> {
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<SeasonPage> {
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<SectionDetail> {
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<ArchiveView> {
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<Value> {
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<String, String>,
content_type: String,
body: Bytes,
}
impl Captured {
fn json(&self) -> Value {
serde_json::from_slice(&self.body).unwrap()
}
fn form(&self) -> HashMap<String, String> {
serde_urlencoded::from_bytes(&self.body).unwrap()
}
}
#[derive(Clone)]
struct FakeState {
response: Value,
captured: Arc<Mutex<Vec<Captured>>>,
}
struct FakeServer {
base: String,
captured: Arc<Mutex<Vec<Captured>>>,
}
impl FakeServer {
async fn start(response: Value) -> Self {
async fn handler(
State(state): State<FakeState>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> axum::Json<Value> {
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<impl std::fmt::Debug>, 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<String, String> = [
("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);
}
}
}
}