1use 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 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 #[serde(default)]
47 pub desc: String,
48 #[serde(default)]
50 pub tname: String,
51 #[serde(default)]
52 pub tid: i64,
53 #[serde(default)]
55 pub pubdate: i64,
56 #[serde(default)]
58 pub ctime: i64,
59 #[serde(default)]
61 pub duration: i64,
62 #[serde(default)]
64 pub videos: i64,
65 #[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 #[serde(default)]
115 pub page: i64,
116 #[serde(default)]
118 pub part: String,
119 #[serde(default)]
120 pub duration: i64,
121}
122
123impl VideoDetail {
124 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 pub fn page_of(
141 &self,
142 cid: i64,
143 ) -> Option<&VideoPage> {
144 self.pages.iter().find(|p| p.cid == cid)
145 }
146
147 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 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 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 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
222fn 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}