1use std::fmt;
2use std::str::FromStr;
3
4use serde::de::{self, Visitor};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7use crate::{BpiError, BpiResult};
8
9macro_rules! numeric_id {
10 ($name:ident, $field:literal, $doc:literal) => {
11 #[doc = $doc]
12 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13 pub struct $name(u64);
14
15 impl $name {
16 pub fn new(value: u64) -> BpiResult<Self> {
18 if value == 0 {
19 return Err(BpiError::invalid_parameter($field, "id must be non-zero"));
20 }
21
22 Ok(Self(value))
23 }
24
25 pub fn get(self) -> u64 {
27 self.0
28 }
29 }
30
31 impl fmt::Display for $name {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 self.0.fmt(f)
34 }
35 }
36
37 impl FromStr for $name {
38 type Err = BpiError;
39
40 fn from_str(value: &str) -> Result<Self, Self::Err> {
41 let parsed = value
42 .parse::<u64>()
43 .map_err(|_| BpiError::invalid_parameter($field, "id must be numeric"))?;
44 Self::new(parsed)
45 }
46 }
47
48 impl Serialize for $name {
49 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50 where
51 S: Serializer,
52 {
53 serializer.serialize_u64(self.0)
54 }
55 }
56
57 impl<'de> Deserialize<'de> for $name {
58 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59 where
60 D: Deserializer<'de>,
61 {
62 struct IdVisitor;
63
64 impl Visitor<'_> for IdVisitor {
65 type Value = u64;
66
67 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 formatter.write_str("a non-zero numeric id")
69 }
70
71 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
72 where
73 E: de::Error,
74 {
75 Ok(value)
76 }
77
78 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
79 where
80 E: de::Error,
81 {
82 u64::try_from(value).map_err(|_| E::custom("id must be non-negative"))
83 }
84 }
85
86 let value = deserializer.deserialize_any(IdVisitor)?;
87 Self::new(value).map_err(de::Error::custom)
88 }
89 }
90 };
91}
92
93numeric_id!(Aid, "aid", "Bilibili AV numeric video ID.");
94numeric_id!(AudioId, "sid", "Bilibili audio song ID.");
95numeric_id!(Cid, "cid", "Bilibili video page/content ID.");
96numeric_id!(Mid, "mid", "Bilibili member/user ID.");
97numeric_id!(RoomId, "room_id", "Bilibili live room ID.");
98numeric_id!(MediaId, "media_id", "Bilibili media ID.");
99numeric_id!(SeasonId, "season_id", "Bilibili season ID.");
100numeric_id!(EpisodeId, "ep_id", "Bilibili episode ID.");
101numeric_id!(NoteId, "note_id", "Bilibili note ID.");
102numeric_id!(Cvid, "cvid", "Bilibili note/article CV ID.");
103
104macro_rules! string_id {
105 ($name:ident, $field:literal, $doc:literal, $validate:ident) => {
106 #[doc = $doc]
107 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
108 pub struct $name(String);
109
110 impl $name {
111 pub fn new(value: impl Into<String>) -> BpiResult<Self> {
113 let value = value.into();
114 $validate(&value)?;
115 Ok(Self(value))
116 }
117
118 pub fn as_str(&self) -> &str {
120 &self.0
121 }
122 }
123
124 impl fmt::Display for $name {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 self.0.fmt(f)
127 }
128 }
129
130 impl FromStr for $name {
131 type Err = BpiError;
132
133 fn from_str(value: &str) -> Result<Self, Self::Err> {
134 Self::new(value)
135 }
136 }
137
138 impl Serialize for $name {
139 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
140 where
141 S: Serializer,
142 {
143 serializer.serialize_str(&self.0)
144 }
145 }
146
147 impl<'de> Deserialize<'de> for $name {
148 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149 where
150 D: Deserializer<'de>,
151 {
152 let value = String::deserialize(deserializer)?;
153 Self::new(value).map_err(de::Error::custom)
154 }
155 }
156 };
157}
158
159string_id!(Bvid, "bvid", "Bilibili BV string video ID.", validate_bvid);
160string_id!(
161 DynamicId,
162 "dynamic_id",
163 "Bilibili dynamic feed item ID.",
164 validate_dynamic_id
165);
166
167fn validate_bvid(value: &str) -> BpiResult<()> {
168 if !value.starts_with("BV") {
169 return Err(BpiError::invalid_parameter(
170 "bvid",
171 "bvid must start with 'BV'",
172 ));
173 }
174
175 if value.len() < 12 || !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
176 return Err(BpiError::invalid_parameter(
177 "bvid",
178 "bvid must be at least 12 ASCII alphanumeric characters",
179 ));
180 }
181
182 Ok(())
183}
184
185fn validate_dynamic_id(value: &str) -> BpiResult<()> {
186 if value.trim().is_empty() {
187 return Err(BpiError::invalid_parameter(
188 "dynamic_id",
189 "dynamic id cannot be blank",
190 ));
191 }
192
193 Ok(())
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn aid_rejects_zero() {
202 let err = Aid::new(0).unwrap_err();
203
204 assert!(matches!(
205 err,
206 BpiError::InvalidParameter { field: "aid", .. }
207 ));
208 }
209
210 #[test]
211 fn aid_displays_numeric_value() -> Result<(), BpiError> {
212 let aid = Aid::new(170001)?;
213
214 assert_eq!(aid.to_string(), "170001");
215 Ok(())
216 }
217
218 #[test]
219 fn audio_id_rejects_zero() {
220 let err = AudioId::new(0).unwrap_err();
221
222 assert!(matches!(
223 err,
224 BpiError::InvalidParameter { field: "sid", .. }
225 ));
226 }
227
228 #[test]
229 fn audio_id_displays_numeric_value() -> Result<(), BpiError> {
230 let sid = AudioId::new(13603)?;
231
232 assert_eq!(sid.to_string(), "13603");
233 Ok(())
234 }
235
236 #[test]
237 fn season_id_rejects_zero() {
238 let err = SeasonId::new(0).unwrap_err();
239
240 assert!(matches!(
241 err,
242 BpiError::InvalidParameter {
243 field: "season_id",
244 ..
245 }
246 ));
247 }
248
249 #[test]
250 fn episode_id_displays_numeric_value() -> Result<(), BpiError> {
251 let episode_id = EpisodeId::new(21265)?;
252
253 assert_eq!(episode_id.to_string(), "21265");
254 Ok(())
255 }
256
257 #[test]
258 fn bvid_accepts_valid_value() -> Result<(), BpiError> {
259 let bvid: Bvid = "BV1bx411c7ux".parse()?;
260
261 assert_eq!(bvid.as_str(), "BV1bx411c7ux");
262 Ok(())
263 }
264
265 #[test]
266 fn bvid_rejects_invalid_prefix() {
267 let err = "av170001".parse::<Bvid>().unwrap_err();
268
269 assert!(matches!(
270 err,
271 BpiError::InvalidParameter { field: "bvid", .. }
272 ));
273 }
274
275 #[test]
276 fn dynamic_id_rejects_blank_value() {
277 let err = " ".parse::<DynamicId>().unwrap_err();
278
279 assert!(matches!(
280 err,
281 BpiError::InvalidParameter {
282 field: "dynamic_id",
283 ..
284 }
285 ));
286 }
287
288 #[test]
289 fn serde_round_trips_numeric_id() -> Result<(), Box<dyn std::error::Error>> {
290 let mid = Mid::new(12345)?;
291
292 let json = serde_json::to_string(&mid)?;
293 assert_eq!(json, "12345");
294 let decoded: Mid = serde_json::from_str(&json)?;
295 assert_eq!(decoded, mid);
296 Ok(())
297 }
298
299 #[test]
300 fn serde_round_trips_string_id() -> Result<(), Box<dyn std::error::Error>> {
301 let bvid: Bvid = "BV1bx411c7ux".parse()?;
302
303 let json = serde_json::to_string(&bvid)?;
304 assert_eq!(json, "\"BV1bx411c7ux\"");
305 let decoded: Bvid = serde_json::from_str(&json)?;
306 assert_eq!(decoded, bvid);
307 Ok(())
308 }
309}