Skip to main content

ybd_core/model/
detail.rs

1//! bilibili 视频详细信息模型(对应 `x/web-interface/view` 接口)
2//!
3//! 与 [`crate::model::video::PlayUrlResponse`] 不同,这个接口提供的是
4//! 标题、UP 主、发布时间、简介、分P 列表等“可写入文件元数据”的信息。
5
6use serde::Deserialize;
7use serde::Serialize;
8
9use crate::util::MediaMeta;
10
11#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct VideoInfoResponse {
14        pub code: i64,
15        #[serde(default)]
16        pub message: String,
17        #[serde(default)]
18        pub data: Option<VideoDetail>,
19}
20
21impl VideoInfoResponse {
22        /// 提取数据部分,顺手把常见错误码翻译成可读信息
23        pub fn into_detail(self) -> crate::error::Result<VideoDetail> {
24                if self.code != 0 {
25                        return Err(crate::error::Error::Normal(format!(
26                                "获取视频信息失败: {} (code: {})",
27                                self.message, self.code
28                        )));
29                }
30                self.data.ok_or_else(|| {
31                        crate::error::Error::Normal("视频信息返回体缺少 data 字段".into())
32                })
33        }
34}
35
36#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct VideoDetail {
39        pub bvid: String,
40        #[serde(default)]
41        pub aid: i64,
42        pub title: String,
43        #[serde(default)]
44        pub pic: String,
45        /// 视频简介
46        #[serde(default)]
47        pub desc: String,
48        /// 分区名称,例如“音乐 / 演奏”
49        #[serde(default)]
50        pub tname: String,
51        #[serde(default)]
52        pub tid: i64,
53        /// 发布时间戳(秒)
54        #[serde(default)]
55        pub pubdate: i64,
56        /// 创建时间戳(秒)
57        #[serde(default)]
58        pub ctime: i64,
59        /// 总时长(秒)
60        #[serde(default)]
61        pub duration: i64,
62        /// 分P 数量
63        #[serde(default)]
64        pub videos: i64,
65        /// 1: 原创, 2: 转载
66        #[serde(default)]
67        pub copyright: i64,
68        #[serde(default)]
69        pub cid: i64,
70        #[serde(default)]
71        pub owner: Owner,
72        #[serde(default)]
73        pub stat: Stat,
74        #[serde(default)]
75        pub pages: Vec<VideoPage>,
76}
77
78#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct Owner {
81        #[serde(default)]
82        pub mid: i64,
83        #[serde(default)]
84        pub name: String,
85        #[serde(default)]
86        pub face: String,
87}
88
89#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct Stat {
92        #[serde(default)]
93        pub view: i64,
94        #[serde(default)]
95        pub danmaku: i64,
96        #[serde(default)]
97        pub reply: i64,
98        #[serde(default)]
99        pub favorite: i64,
100        #[serde(default)]
101        pub coin: i64,
102        #[serde(default)]
103        pub share: i64,
104        #[serde(default)]
105        pub like: i64,
106}
107
108#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct VideoPage {
111        #[serde(default)]
112        pub cid: i64,
113        /// 第几 P,从 1 开始
114        #[serde(default)]
115        pub page: i64,
116        /// 分P 标题
117        #[serde(default)]
118        pub part: String,
119        #[serde(default)]
120        pub duration: i64,
121}
122
123impl VideoDetail {
124        /// 视频原始地址(带分P 参数)
125        pub fn url(
126                &self,
127                cid: Option<i64>,
128        ) -> String {
129                match cid {
130                        Some(cid) => format!(
131                                "https://www.bilibili.com/video/{}/?p={}",
132                                self.bvid,
133                                self.page_index(cid).unwrap_or(1)
134                        ),
135                        None => format!("https://www.bilibili.com/video/{}", self.bvid),
136                }
137        }
138
139        /// 根据 cid 找到对应的分P
140        pub fn page_of(
141                &self,
142                cid: i64,
143        ) -> Option<&VideoPage> {
144                self.pages.iter().find(|p| p.cid == cid)
145        }
146
147        /// cid 对应的分P 序号(从 1 开始)
148        pub fn page_index(
149                &self,
150                cid: i64,
151        ) -> Option<u16> {
152                self.page_of(cid)
153                        .map(|p| p.page as u16)
154                        .or(if self.pages.is_empty() { Some(1) } else { None })
155        }
156
157        /// 发布时间所在年份
158        pub fn year(&self) -> Option<String> {
159                let ts = if self.pubdate > 0 {
160                        self.pubdate
161                } else {
162                        self.ctime
163                };
164                if ts <= 0 {
165                        return None;
166                }
167                // 不引入时间库,手工把时间戳换算成年月日(UTC 近似即可满足元数据需求)
168                let days = ts / 86_400;
169                let (y, m, d) = civil_from_days(days);
170                let _ = (m, d);
171                Some(y.to_string())
172        }
173
174        /// 组装可写入 m4a/mp4 的元数据
175        ///
176        /// * `album`: 合集/收藏夹名称,缺省时用分区名
177        /// * `cid`: 要下载的分P 标识,用于写入 track 序号
178        pub fn media_meta(
179                &self,
180                album: Option<&str>,
181                cid: Option<i64>,
182        ) -> MediaMeta {
183                let total = self.pages.len().max(if self.videos > 0 { 1 } else { 0 }) as u16;
184                let track = cid
185                        .and_then(|cid| self.page_index(cid))
186                        .map(|index| (index, total.max(index)));
187
188                let mut meta = MediaMeta::default();
189                meta.title = Some(self.title.clone());
190                if !self.owner.name.is_empty() {
191                        meta.artist = Some(self.owner.name.clone());
192                        meta.album_artist = Some(self.owner.name.clone());
193                        meta.copyright = Some(format!("© {}", self.owner.name));
194                }
195                meta.album = album.map(|s| s.to_string()).or(if self.tname.is_empty() {
196                        None
197                } else {
198                        Some(self.tname.clone())
199                });
200                meta.genre = if self.tname.is_empty() {
201                        None
202                } else {
203                        Some(self.tname.clone())
204                };
205                meta.description = if self.desc.is_empty() {
206                        None
207                } else {
208                        Some(self.desc.clone())
209                };
210                meta.comment = Some(self.url(cid));
211                meta.year = self.year();
212                meta.track = track;
213                meta.encoder = Some(format!(
214                        "{} {}",
215                        env!("CARGO_PKG_NAME"),
216                        env!("CARGO_PKG_VERSION")
217                ));
218                meta
219        }
220}
221
222/// 把“1970-01-01 起的天数”换算成公历年月日
223///
224/// 采用 Howard Hinnant 的 civil_from_days 算法,避免为了一个年份引入时间库。
225fn civil_from_days(days: i64) -> (i64, u32, u32) {
226        let z = days + 719_468;
227        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
228        let doe = (z - era * 146_097) as u64;
229        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
230        let y = yoe as i64 + era * 400;
231        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
232        let mp = (5 * doy + 2) / 153;
233        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
234        let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
235        (if m <= 2 { y + 1 } else { y }, m, d)
236}
237
238#[test]
239fn year_of_timestamp() {
240        let detail = VideoDetail {
241                pubdate: 1_700_000_000,
242                ..Default::default()
243        };
244        assert_eq!(detail.year().as_deref(), Some("2023"));
245}