1use core::cmp::Ordering;
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::other_ids::OtherIds;
6
7#[derive(Debug, Serialize, strum_macros::AsRefStr)]
8pub enum RsIdsError {
9 InvalidId(),
10 NotAMediaId(String),
11 NoMediaIdRequired(Box<RsIds>),
12}
13
14impl core::fmt::Display for RsIdsError {
17 fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::result::Result<(), core::fmt::Error> {
18 write!(fmt, "{self:?}")
19 }
20}
21
22impl std::error::Error for RsIdsError {}
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25#[serde(rename_all = "camelCase")]
26pub struct RsIds {
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub redseat: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub trakt: Option<u64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub slug: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub tvdb: Option<u64>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub imdb: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub tmdb: Option<u64>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub tvrage: Option<u64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub other_ids: Option<OtherIds>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub isbn13: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub openlibrary_edition_id: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub openlibrary_work_id: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub google_books_volume_id: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub anilist_manga_id: Option<u64>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub mangadex_manga_uuid: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub myanimelist_manga_id: Option<u64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub volume: Option<f64>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub chapter: Option<f64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub asin: Option<String>,
63}
64
65pub trait ApplyRsIds {
66 fn apply_rs_ids(&mut self, ids: &RsIds);
67}
68
69#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
70enum RsDecimalKey {
71 NegInf,
72 Finite(i64),
73 PosInf,
74 NaN(u64),
75}
76
77fn normalize_manga_decimal(value: f64) -> f64 {
78 (value * 1000.0).round() / 1000.0
79}
80
81fn decimal_key(value: f64) -> RsDecimalKey {
82 if value.is_nan() {
83 return RsDecimalKey::NaN(value.to_bits());
84 }
85 if value == f64::INFINITY {
86 return RsDecimalKey::PosInf;
87 }
88 if value == f64::NEG_INFINITY {
89 return RsDecimalKey::NegInf;
90 }
91 RsDecimalKey::Finite((normalize_manga_decimal(value) * 1000.0).round() as i64)
92}
93
94fn optional_decimal_key(value: Option<f64>) -> Option<RsDecimalKey> {
95 value.map(decimal_key)
96}
97
98impl PartialEq for RsIds {
99 fn eq(&self, other: &Self) -> bool {
100 self.cmp(other) == Ordering::Equal
101 }
102}
103
104impl Eq for RsIds {}
105
106impl PartialOrd for RsIds {
107 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
108 Some(self.cmp(other))
109 }
110}
111
112impl Ord for RsIds {
113 fn cmp(&self, other: &Self) -> Ordering {
114 let ord = self.redseat.cmp(&other.redseat);
115 if ord != Ordering::Equal {
116 return ord;
117 }
118 let ord = self.trakt.cmp(&other.trakt);
119 if ord != Ordering::Equal {
120 return ord;
121 }
122 let ord = self.slug.cmp(&other.slug);
123 if ord != Ordering::Equal {
124 return ord;
125 }
126 let ord = self.tvdb.cmp(&other.tvdb);
127 if ord != Ordering::Equal {
128 return ord;
129 }
130 let ord = self.imdb.cmp(&other.imdb);
131 if ord != Ordering::Equal {
132 return ord;
133 }
134 let ord = self.tmdb.cmp(&other.tmdb);
135 if ord != Ordering::Equal {
136 return ord;
137 }
138 let ord = self.tvrage.cmp(&other.tvrage);
139 if ord != Ordering::Equal {
140 return ord;
141 }
142 let ord = self.other_ids.cmp(&other.other_ids);
143 if ord != Ordering::Equal {
144 return ord;
145 }
146 let ord = self.isbn13.cmp(&other.isbn13);
147 if ord != Ordering::Equal {
148 return ord;
149 }
150 let ord = self
151 .openlibrary_edition_id
152 .cmp(&other.openlibrary_edition_id);
153 if ord != Ordering::Equal {
154 return ord;
155 }
156 let ord = self.openlibrary_work_id.cmp(&other.openlibrary_work_id);
157 if ord != Ordering::Equal {
158 return ord;
159 }
160 let ord = self
161 .google_books_volume_id
162 .cmp(&other.google_books_volume_id);
163 if ord != Ordering::Equal {
164 return ord;
165 }
166 let ord = self.anilist_manga_id.cmp(&other.anilist_manga_id);
167 if ord != Ordering::Equal {
168 return ord;
169 }
170 let ord = self.mangadex_manga_uuid.cmp(&other.mangadex_manga_uuid);
171 if ord != Ordering::Equal {
172 return ord;
173 }
174 let ord = self.myanimelist_manga_id.cmp(&other.myanimelist_manga_id);
175 if ord != Ordering::Equal {
176 return ord;
177 }
178 let ord = optional_decimal_key(self.volume).cmp(&optional_decimal_key(other.volume));
179 if ord != Ordering::Equal {
180 return ord;
181 }
182 let ord = optional_decimal_key(self.chapter).cmp(&optional_decimal_key(other.chapter));
183 if ord != Ordering::Equal {
184 return ord;
185 }
186 self.asin.cmp(&other.asin)
187 }
188}
189
190impl RsIds {
191 pub fn apply_to<T: ApplyRsIds>(&self, target: &mut T) {
192 target.apply_rs_ids(self);
193 }
194
195 fn parse_manga_details(
196 details: &[&str],
197 value: &str,
198 ) -> Result<(Option<f64>, Option<f64>), RsIdsError> {
199 let mut volume = None;
200 let mut chapter = None;
201
202 for detail in details {
203 let detail_parts = detail.split(':').collect::<Vec<_>>();
204 if detail_parts.len() != 2 {
205 return Err(RsIdsError::NotAMediaId(value.to_string()));
206 }
207 let key = detail_parts[0].to_lowercase();
208 let parsed_value: f64 = detail_parts[1]
209 .parse()
210 .map_err(|_| RsIdsError::NotAMediaId(value.to_string()))?;
211 if !parsed_value.is_finite() {
212 return Err(RsIdsError::NotAMediaId(value.to_string()));
213 }
214 let parsed_value = normalize_manga_decimal(parsed_value);
215 match key.as_str() {
216 "volume" => {
217 if volume.is_some() {
218 return Err(RsIdsError::NotAMediaId(value.to_string()));
219 }
220 volume = Some(parsed_value);
221 }
222 "chapter" => {
223 if chapter.is_some() {
224 return Err(RsIdsError::NotAMediaId(value.to_string()));
225 }
226 chapter = Some(parsed_value);
227 }
228 _ => return Err(RsIdsError::NotAMediaId(value.to_string())),
229 }
230 }
231
232 Ok((volume, chapter))
233 }
234
235 fn manga_details_suffix(&self) -> String {
236 let mut suffix = String::new();
237 if let Some(volume) = self.volume {
238 suffix.push_str(&format!("|volume:{}", normalize_manga_decimal(volume)));
239 }
240 if let Some(chapter) = self.chapter {
241 suffix.push_str(&format!("|chapter:{}", normalize_manga_decimal(chapter)));
242 }
243 suffix
244 }
245
246 pub fn try_add(&mut self, value: String) -> Result<(), RsIdsError> {
247 if !Self::is_id(&value) {
248 return Err(RsIdsError::NotAMediaId(value));
249 }
250 let pipe_elements = value.split('|').collect::<Vec<_>>();
251 let base = pipe_elements.first().ok_or(RsIdsError::InvalidId())?;
252 let details = &pipe_elements[1..];
253 let elements = base.split(':').collect::<Vec<_>>();
254 let source = elements
255 .first()
256 .ok_or(RsIdsError::InvalidId())?
257 .to_lowercase();
258 let id = elements.get(1).ok_or(RsIdsError::InvalidId())?;
259 let is_manga_source = matches!(
260 source.as_str(),
261 "anilist"
262 | "anilist_manga_id"
263 | "mangadex"
264 | "mangadex_manga_uuid"
265 | "mal"
266 | "myanimelist_manga_id"
267 );
268 if !is_manga_source && !details.is_empty() {
269 return Err(RsIdsError::NotAMediaId(value));
270 }
271
272 match source.as_str() {
273 "redseat" => {
274 self.redseat = Some(id.to_string());
275 Ok(())
276 }
277 "imdb" => {
278 self.imdb = Some(id.to_string());
279 Ok(())
280 }
281 "trakt" => {
282 let id: u64 = id.parse().map_err(|_| RsIdsError::NotAMediaId(value))?;
283 self.trakt = Some(id);
284 Ok(())
285 }
286 "tmdb" => {
287 let id: u64 = id.parse().map_err(|_| RsIdsError::NotAMediaId(value))?;
288 self.tmdb = Some(id);
289 Ok(())
290 }
291 "tvdb" => {
292 let id: u64 = id.parse().map_err(|_| RsIdsError::NotAMediaId(value))?;
293 self.tvdb = Some(id);
294 Ok(())
295 }
296 "tvrage" => {
297 let id: u64 = id.parse().map_err(|_| RsIdsError::NotAMediaId(value))?;
298 self.tvrage = Some(id);
299 Ok(())
300 }
301 "isbn13" => {
302 self.isbn13 = Some(id.to_string());
303 Ok(())
304 }
305 "oleid" | "openlibrary_edition_id" => {
306 self.openlibrary_edition_id = Some(id.to_string());
307 Ok(())
308 }
309 "olwid" | "openlibrary_work_id" => {
310 self.openlibrary_work_id = Some(id.to_string());
311 Ok(())
312 }
313 "gbvid" | "google_books_volume_id" => {
314 self.google_books_volume_id = Some(id.to_string());
315 Ok(())
316 }
317 "anilist" | "anilist_manga_id" => {
318 let (volume, chapter) = Self::parse_manga_details(details, &value)?;
319 let id: u64 = id.parse().map_err(|_| RsIdsError::NotAMediaId(value))?;
320 self.anilist_manga_id = Some(id);
321 self.volume = volume;
322 self.chapter = chapter;
323 Ok(())
324 }
325 "mangadex" | "mangadex_manga_uuid" => {
326 let (volume, chapter) = Self::parse_manga_details(details, &value)?;
327 self.mangadex_manga_uuid = Some(id.to_string());
328 self.volume = volume;
329 self.chapter = chapter;
330 Ok(())
331 }
332 "mal" | "myanimelist_manga_id" => {
333 let (volume, chapter) = Self::parse_manga_details(details, &value)?;
334 let id: u64 = id.parse().map_err(|_| RsIdsError::NotAMediaId(value))?;
335 self.myanimelist_manga_id = Some(id);
336 self.volume = volume;
337 self.chapter = chapter;
338 Ok(())
339 }
340 "asin" => {
341 self.asin = Some(id.to_string());
342 Ok(())
343 }
344 _ => {
345 self.add_other(&source, id);
346 Ok(())
347 }
348 }
349 }
350
351 pub fn into_best(self) -> Option<String> {
352 self.as_redseat().or(self.into_best_external())
353 }
354
355 pub fn into_best_external(self) -> Option<String> {
356 self.as_trakt()
357 .or(self.as_imdb())
358 .or(self.as_tmdb())
359 .or(self.as_tvdb())
360 .or(self.as_isbn13())
361 .or(self.as_openlibrary_edition_id())
362 .or(self.as_openlibrary_work_id())
363 .or(self.as_google_books_volume_id())
364 .or(self.as_anilist_manga_id())
365 .or(self.as_mangadex_manga_uuid())
366 .or(self.as_myanimelist_manga_id())
367 .or(self.as_asin())
368 .or(self
369 .other_ids
370 .and_then(|other_ids| other_ids.as_slice().first().cloned()))
371 }
372 pub fn as_best_external(&self) -> Option<String> {
373 self.as_trakt()
374 .or(self.as_imdb())
375 .or(self.as_tmdb())
376 .or(self.as_tvdb())
377 .or(self.as_isbn13())
378 .or(self.as_openlibrary_edition_id())
379 .or(self.as_openlibrary_work_id())
380 .or(self.as_google_books_volume_id())
381 .or(self.as_anilist_manga_id())
382 .or(self.as_mangadex_manga_uuid())
383 .or(self.as_myanimelist_manga_id())
384 .or(self.as_asin())
385 .or(self
386 .other_ids
387 .as_ref()
388 .and_then(|other_ids| other_ids.as_slice().first().cloned()))
389 }
390
391 pub fn into_best_external_or_local(self) -> Option<String> {
392 self.as_best_external().or(self.as_redseat())
393 }
394
395 pub fn from_imdb(imdb: String) -> Self {
396 Self {
397 imdb: Some(imdb),
398 ..Default::default()
399 }
400 }
401 pub fn as_imdb(&self) -> Option<String> {
402 self.imdb.as_ref().map(|i| format!("imdb:{}", i))
403 }
404
405 pub fn from_trakt(trakt: u64) -> Self {
406 Self {
407 trakt: Some(trakt),
408 ..Default::default()
409 }
410 }
411 pub fn as_trakt(&self) -> Option<String> {
412 self.trakt.map(|i| format!("trakt:{}", i))
413 }
414 pub fn as_id_for_trakt(&self) -> Option<String> {
415 if let Some(trakt) = self.trakt {
416 Some(trakt.to_string())
417 } else {
418 self.imdb.as_ref().map(|imdb| imdb.to_string())
419 }
420 }
421
422 pub fn from_tvdb(tvdb: u64) -> Self {
423 Self {
424 tvdb: Some(tvdb),
425 ..Default::default()
426 }
427 }
428 pub fn as_tvdb(&self) -> Option<String> {
429 self.tvdb.map(|i| format!("tvdb:{}", i))
430 }
431 pub fn try_tvdb(self) -> Result<u64, RsIdsError> {
432 self.tvdb
433 .ok_or(RsIdsError::NoMediaIdRequired(Box::new(self.clone())))
434 }
435
436 pub fn from_tmdb(tmdb: u64) -> Self {
437 Self {
438 tmdb: Some(tmdb),
439 ..Default::default()
440 }
441 }
442 pub fn as_tmdb(&self) -> Option<String> {
443 self.tmdb.map(|i| format!("tmdb:{}", i))
444 }
445 pub fn try_tmdb(self) -> Result<u64, RsIdsError> {
446 self.tmdb
447 .ok_or(RsIdsError::NoMediaIdRequired(Box::new(self.clone())))
448 }
449
450 pub fn from_redseat(redseat: String) -> Self {
451 Self {
452 redseat: Some(redseat),
453 ..Default::default()
454 }
455 }
456 pub fn as_redseat(&self) -> Option<String> {
457 self.redseat.as_ref().map(|i| format!("redseat:{}", i))
458 }
459 pub fn as_isbn13(&self) -> Option<String> {
460 self.isbn13.as_ref().map(|i| format!("isbn13:{}", i))
461 }
462 pub fn as_openlibrary_edition_id(&self) -> Option<String> {
463 self.openlibrary_edition_id
464 .as_ref()
465 .map(|i| format!("oleid:{}", i))
466 }
467 pub fn as_openlibrary_work_id(&self) -> Option<String> {
468 self.openlibrary_work_id
469 .as_ref()
470 .map(|i| format!("olwid:{}", i))
471 }
472 pub fn as_google_books_volume_id(&self) -> Option<String> {
473 self.google_books_volume_id
474 .as_ref()
475 .map(|i| format!("gbvid:{}", i))
476 }
477 pub fn as_anilist_manga_id(&self) -> Option<String> {
478 self.anilist_manga_id.map(|i| format!("anilist:{}", i))
479 }
480 pub fn as_anilist_manga_id_with_details(&self) -> Option<String> {
481 self.anilist_manga_id
482 .map(|i| format!("anilist:{}{}", i, self.manga_details_suffix()))
483 }
484 pub fn as_mangadex_manga_uuid(&self) -> Option<String> {
485 self.mangadex_manga_uuid
486 .as_ref()
487 .map(|i| format!("mangadex:{}", i))
488 }
489 pub fn as_mangadex_manga_uuid_with_details(&self) -> Option<String> {
490 self.mangadex_manga_uuid
491 .as_ref()
492 .map(|i| format!("mangadex:{}{}", i, self.manga_details_suffix()))
493 }
494 pub fn as_myanimelist_manga_id(&self) -> Option<String> {
495 self.myanimelist_manga_id.map(|i| format!("mal:{}", i))
496 }
497 pub fn as_myanimelist_manga_id_with_details(&self) -> Option<String> {
498 self.myanimelist_manga_id
499 .map(|i| format!("mal:{}{}", i, self.manga_details_suffix()))
500 }
501 pub fn as_asin(&self) -> Option<String> {
502 self.asin.as_ref().map(|i| format!("asin:{}", i))
503 }
504
505 pub fn as_id(&self) -> Result<String, RsIdsError> {
506 if let Some(imdb) = &self.imdb {
507 Ok(format!("imdb:{}", imdb))
508 } else if let Some(trakt) = &self.trakt {
509 Ok(format!("trakt:{}", trakt))
510 } else if let Some(tmdb) = &self.tmdb {
511 Ok(format!("tmdb:{}", tmdb))
512 } else if let Some(tvdb) = &self.tvdb {
513 Ok(format!("tvdb:{}", tvdb))
514 } else {
515 Err(RsIdsError::NoMediaIdRequired(Box::new(self.clone())))
516 }
517 }
518
519 pub fn as_all_other_ids(&self) -> OtherIds {
520 let mut ids = vec![];
521 if let Some(id) = self.as_redseat() {
522 ids.push(id)
523 }
524 if let Some(id) = self.as_imdb() {
525 ids.push(id)
526 }
527 if let Some(id) = self.as_tmdb() {
528 ids.push(id)
529 }
530 if let Some(id) = self.as_trakt() {
531 ids.push(id)
532 }
533 if let Some(id) = self.as_tvdb() {
534 ids.push(id)
535 }
536 if let Some(id) = self.as_isbn13() {
537 ids.push(id)
538 }
539 if let Some(id) = self.as_openlibrary_edition_id() {
540 ids.push(id)
541 }
542 if let Some(id) = self.as_openlibrary_work_id() {
543 ids.push(id)
544 }
545 if let Some(id) = self.as_google_books_volume_id() {
546 ids.push(id)
547 }
548 if let Some(id) = self.as_anilist_manga_id_with_details() {
549 ids.push(id)
550 }
551 if let Some(id) = self.as_mangadex_manga_uuid_with_details() {
552 ids.push(id)
553 }
554 if let Some(id) = self.as_myanimelist_manga_id_with_details() {
555 ids.push(id)
556 }
557 if let Some(id) = self.as_asin() {
558 ids.push(id)
559 }
560 if let Some(other_ids) = self.other_ids.as_ref() {
561 ids.extend(other_ids.as_slice().iter().cloned());
562 }
563 OtherIds(ids)
564 }
565
566 pub fn as_all_ids(&self) -> Vec<String> {
567 self.as_all_other_ids().into_vec()
568 }
569
570 pub fn add_other(&mut self, key: &str, value: &str) {
571 if key.trim().is_empty() {
572 return;
573 }
574 self.other_ids
575 .get_or_insert_with(OtherIds::default)
576 .add(key, value);
577 }
578
579 pub fn has_other_key(&self, key: &str) -> bool {
580 self.other_ids
581 .as_ref()
582 .is_some_and(|other_ids| other_ids.has_key(key))
583 }
584
585 pub fn get_other(&self, key: &str) -> Option<String> {
586 self.other_ids
587 .as_ref()
588 .and_then(|other_ids| other_ids.get(key))
589 }
590
591 pub fn has_other(&self, key: &str, value: &str) -> bool {
592 self.other_ids
593 .as_ref()
594 .is_some_and(|other_ids| other_ids.contains(key, value))
595 }
596
597 pub fn is_id(id: &str) -> bool {
599 let base = id.split('|').next().unwrap_or(id);
600 base.contains(":") && base.split(':').count() == 2
601 }
602}
603
604impl TryFrom<Vec<String>> for RsIds {
605 type Error = RsIdsError;
606
607 fn try_from(values: Vec<String>) -> Result<Self, RsIdsError> {
608 let mut ids = Self::default();
609 for value in values {
610 ids.try_add(value)?;
611 }
612 Ok(ids)
613 }
614}
615
616impl TryFrom<OtherIds> for RsIds {
617 type Error = RsIdsError;
618
619 fn try_from(value: OtherIds) -> Result<Self, RsIdsError> {
620 Self::try_from(value.into_vec())
621 }
622}
623
624impl TryFrom<String> for RsIds {
625 type Error = RsIdsError;
626 fn try_from(value: String) -> Result<Self, RsIdsError> {
627 let mut id = RsIds::default();
628 id.try_add(value)?;
629 Ok(id)
630 }
631}
632
633impl From<RsIds> for Vec<String> {
634 fn from(value: RsIds) -> Self {
635 value.as_all_ids()
636 }
637}
638
639#[cfg(feature = "rusqlite")]
640pub mod external_images_rusqlite {
641 use rusqlite::{
642 types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef},
643 ToSql,
644 };
645
646 use super::RsIds;
647
648 impl FromSql for RsIds {
649 fn column_result(value: ValueRef) -> FromSqlResult<Self> {
650 String::column_result(value).and_then(|as_string| {
651 let r = serde_json::from_str(&as_string).map_err(|_| FromSqlError::InvalidType);
652 r
653 })
654 }
655 }
656 impl ToSql for RsIds {
657 fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
658 let r = serde_json::to_string(self).map_err(|_| FromSqlError::InvalidType)?;
659 Ok(ToSqlOutput::from(r))
660 }
661 }
662}
663
664#[cfg(test)]
665mod tests {
666
667 use super::*;
668
669 #[test]
670 fn test_parse_existing_movie_show_ids_regression() -> Result<(), RsIdsError> {
671 let parsed: RsIds = "trakt:905982".to_string().try_into()?;
672 assert_eq!(parsed.trakt, Some(905982));
673
674 let parsed: RsIds = "imdb:tt1234567".to_string().try_into()?;
675 assert_eq!(parsed.imdb, Some("tt1234567".to_string()));
676
677 let parsed: RsIds = "tmdb:42".to_string().try_into()?;
678 assert_eq!(parsed.tmdb, Some(42));
679
680 let parsed: RsIds = "tvdb:99".to_string().try_into()?;
681 assert_eq!(parsed.tvdb, Some(99));
682 assert_eq!(parsed.as_best_external(), Some("tvdb:99".to_string()));
683 assert_eq!(parsed.as_id()?, "tvdb:99");
684
685 Ok(())
686 }
687
688 #[test]
689 fn test_parse_short_prefixes() -> Result<(), RsIdsError> {
690 let mut ids = RsIds::default();
691 ids.try_add("isbn13:9780143127741".to_string())?;
692 ids.try_add("oleid:OL12345M".to_string())?;
693 ids.try_add("olwid:OL6789W".to_string())?;
694 ids.try_add("gbvid:abcDEF_123".to_string())?;
695 ids.try_add("anilist:123".to_string())?;
696 ids.try_add("mangadex:7f2f8cdd-b241-4f27-a6fe-13f7f7fb9164".to_string())?;
697 ids.try_add("mal:456".to_string())?;
698 ids.try_add("asin:B08XYZ1234".to_string())?;
699
700 assert_eq!(ids.isbn13.as_deref(), Some("9780143127741"));
701 assert_eq!(ids.openlibrary_edition_id.as_deref(), Some("OL12345M"));
702 assert_eq!(ids.openlibrary_work_id.as_deref(), Some("OL6789W"));
703 assert_eq!(ids.google_books_volume_id.as_deref(), Some("abcDEF_123"));
704 assert_eq!(ids.anilist_manga_id, Some(123));
705 assert_eq!(
706 ids.mangadex_manga_uuid.as_deref(),
707 Some("7f2f8cdd-b241-4f27-a6fe-13f7f7fb9164")
708 );
709 assert_eq!(ids.myanimelist_manga_id, Some(456));
710 assert_eq!(ids.asin.as_deref(), Some("B08XYZ1234"));
711 Ok(())
712 }
713
714 #[test]
715 fn test_parse_manga_pipe_details() -> Result<(), RsIdsError> {
716 let mut ids = RsIds::default();
717 ids.try_add("anilist:123|volume:1|chapter:2.5".to_string())?;
718 assert_eq!(ids.anilist_manga_id, Some(123));
719 assert_eq!(ids.volume, Some(1.0));
720 assert_eq!(ids.chapter, Some(2.5));
721
722 ids.try_add("mal:456|chapter:10.5".to_string())?;
723 assert_eq!(ids.myanimelist_manga_id, Some(456));
724 assert_eq!(ids.volume, None);
725 assert_eq!(ids.chapter, Some(10.5));
726
727 ids.try_add("mangadex:uuid-1|volume:3".to_string())?;
728 assert_eq!(ids.mangadex_manga_uuid.as_deref(), Some("uuid-1"));
729 assert_eq!(ids.volume, Some(3.0));
730 assert_eq!(ids.chapter, None);
731 Ok(())
732 }
733
734 #[test]
735 fn test_parse_long_aliases() -> Result<(), RsIdsError> {
736 let mut ids = RsIds::default();
737 ids.try_add("openlibrary_edition_id:OL1M".to_string())?;
738 ids.try_add("openlibrary_work_id:OL2W".to_string())?;
739 ids.try_add("google_books_volume_id:vol123".to_string())?;
740 ids.try_add("anilist_manga_id:111".to_string())?;
741 ids.try_add("mangadex_manga_uuid:uuid-1".to_string())?;
742 ids.try_add("myanimelist_manga_id:222".to_string())?;
743
744 assert_eq!(ids.openlibrary_edition_id.as_deref(), Some("OL1M"));
745 assert_eq!(ids.openlibrary_work_id.as_deref(), Some("OL2W"));
746 assert_eq!(ids.google_books_volume_id.as_deref(), Some("vol123"));
747 assert_eq!(ids.anilist_manga_id, Some(111));
748 assert_eq!(ids.mangadex_manga_uuid.as_deref(), Some("uuid-1"));
749 assert_eq!(ids.myanimelist_manga_id, Some(222));
750 Ok(())
751 }
752
753 #[test]
754 fn test_case_insensitive_parsing() -> Result<(), RsIdsError> {
755 let mut ids = RsIds::default();
756 ids.try_add("AnIlIsT:55".to_string())?;
757 ids.try_add("MAL:77".to_string())?;
758 ids.try_add("OLEID:OLX".to_string())?;
759 ids.try_add("GBVID:gbx".to_string())?;
760
761 assert_eq!(ids.anilist_manga_id, Some(55));
762 assert_eq!(ids.myanimelist_manga_id, Some(77));
763 assert_eq!(ids.openlibrary_edition_id.as_deref(), Some("OLX"));
764 assert_eq!(ids.google_books_volume_id.as_deref(), Some("gbx"));
765 Ok(())
766 }
767
768 #[test]
769 fn test_unknown_source_is_stored_as_other_id() -> Result<(), RsIdsError> {
770 let mut ids = RsIds::default();
771 ids.try_add("AniDb:1234".to_string())?;
772 assert!(ids.has_other_key("anidb"));
773 assert_eq!(ids.get_other("ANIDB"), Some("1234".to_string()));
774 assert!(ids.has_other("anidb", "1234"));
775 Ok(())
776 }
777
778 #[test]
779 fn test_add_other_replaces_existing_key_value() {
780 let mut ids = RsIds::default();
781 ids.add_other("custom", "first");
782 ids.add_other("CUSTOM", "second");
783 assert_eq!(ids.get_other("custom"), Some("second".to_string()));
784 assert_eq!(
785 ids.other_ids,
786 Some(OtherIds(vec!["custom:second".to_string()]))
787 );
788 }
789
790 #[test]
791 fn test_manga_with_details_methods_keep_base_as_methods() {
792 let ids = RsIds {
793 anilist_manga_id: Some(123),
794 myanimelist_manga_id: Some(456),
795 mangadex_manga_uuid: Some("uuid-2".to_string()),
796 volume: Some(1.0),
797 chapter: Some(2.0),
798 ..Default::default()
799 };
800
801 assert_eq!(ids.as_anilist_manga_id(), Some("anilist:123".to_string()));
802 assert_eq!(ids.as_myanimelist_manga_id(), Some("mal:456".to_string()));
803 assert_eq!(
804 ids.as_mangadex_manga_uuid(),
805 Some("mangadex:uuid-2".to_string())
806 );
807 assert_eq!(
808 ids.as_anilist_manga_id_with_details(),
809 Some("anilist:123|volume:1|chapter:2".to_string())
810 );
811 assert_eq!(
812 ids.as_myanimelist_manga_id_with_details(),
813 Some("mal:456|volume:1|chapter:2".to_string())
814 );
815 assert_eq!(
816 ids.as_mangadex_manga_uuid_with_details(),
817 Some("mangadex:uuid-2|volume:1|chapter:2".to_string())
818 );
819 }
820
821 #[test]
822 fn test_numeric_parse_failure_for_anilist_and_mal() {
823 let mut ids = RsIds::default();
824 assert!(matches!(
825 ids.try_add("anilist:not-a-number".to_string()),
826 Err(RsIdsError::NotAMediaId(_))
827 ));
828 assert!(matches!(
829 ids.try_add("myanimelist_manga_id:bad".to_string()),
830 Err(RsIdsError::NotAMediaId(_))
831 ));
832 }
833
834 #[test]
835 fn test_parse_failure_for_invalid_manga_pipe_details() {
836 let mut ids = RsIds::default();
837 assert!(matches!(
838 ids.try_add("anilist:123|volume".to_string()),
839 Err(RsIdsError::NotAMediaId(_))
840 ));
841 assert!(matches!(
842 ids.try_add("anilist:123|arc:1".to_string()),
843 Err(RsIdsError::NotAMediaId(_))
844 ));
845 assert!(matches!(
846 ids.try_add("mal:456|chapter:abc".to_string()),
847 Err(RsIdsError::NotAMediaId(_))
848 ));
849 assert!(matches!(
850 ids.try_add("mangadex:uuid|chapter:1|chapter:2".to_string()),
851 Err(RsIdsError::NotAMediaId(_))
852 ));
853 }
854
855 #[test]
856 fn test_parse_failure_for_non_manga_pipe_details() {
857 let mut ids = RsIds::default();
858 assert!(matches!(
859 ids.try_add("imdb:tt1234567|chapter:1".to_string()),
860 Err(RsIdsError::NotAMediaId(_))
861 ));
862 }
863
864 #[test]
865 fn test_roundtrip_vec_rsids_vec_uses_canonical_prefixes() -> Result<(), RsIdsError> {
866 let input = vec![
867 "openlibrary_edition_id:OL3M".to_string(),
868 "openlibrary_work_id:OL4W".to_string(),
869 "google_books_volume_id:vol-3".to_string(),
870 "anilist_manga_id:999".to_string(),
871 "mangadex_manga_uuid:uuid-3".to_string(),
872 "myanimelist_manga_id:1111".to_string(),
873 "isbn13:9780316769488".to_string(),
874 "asin:B012345678".to_string(),
875 ];
876 let ids = RsIds::try_from(input)?;
877 let output: Vec<String> = ids.into();
878
879 assert!(output.contains(&"oleid:OL3M".to_string()));
880 assert!(output.contains(&"olwid:OL4W".to_string()));
881 assert!(output.contains(&"gbvid:vol-3".to_string()));
882 assert!(output.contains(&"anilist:999".to_string()));
883 assert!(output.contains(&"mangadex:uuid-3".to_string()));
884 assert!(output.contains(&"mal:1111".to_string()));
885 assert!(output.contains(&"isbn13:9780316769488".to_string()));
886 assert!(output.contains(&"asin:B012345678".to_string()));
887 Ok(())
888 }
889
890 #[test]
891 fn test_roundtrip_vec_rsids_vec_uses_pipe_format_for_manga_details() -> Result<(), RsIdsError> {
892 let input = vec!["anilist:999|chapter:2|volume:1".to_string()];
893 let ids = RsIds::try_from(input)?;
894 let output: Vec<String> = ids.into();
895
896 assert!(output.contains(&"anilist:999|volume:1|chapter:2".to_string()));
897 Ok(())
898 }
899
900 #[test]
901 fn test_roundtrip_vec_rsids_vec_preserves_other_ids() -> Result<(), RsIdsError> {
902 let input = vec![
903 "foo:1".to_string(),
904 "bar:value-2".to_string(),
905 "imdb:tt1234567".to_string(),
906 ];
907 let ids = RsIds::try_from(input)?;
908 let output: Vec<String> = ids.into();
909
910 assert!(output.contains(&"foo:1".to_string()));
911 assert!(output.contains(&"bar:value-2".to_string()));
912 assert!(output.contains(&"imdb:tt1234567".to_string()));
913 Ok(())
914 }
915
916 #[test]
917 fn test_as_all_other_ids_and_as_all_ids_return_all_set_ids() {
918 let ids = RsIds {
919 redseat: Some("rs-1".to_string()),
920 imdb: Some("tt1234567".to_string()),
921 anilist_manga_id: Some(9),
922 volume: Some(1.0),
923 chapter: Some(2.5),
924 other_ids: Some(OtherIds(vec![
925 "custom:abc".to_string(),
926 "foo:bar".to_string(),
927 ])),
928 ..Default::default()
929 };
930
931 let expected = vec![
932 "redseat:rs-1".to_string(),
933 "imdb:tt1234567".to_string(),
934 "anilist:9|volume:1|chapter:2.5".to_string(),
935 "custom:abc".to_string(),
936 "foo:bar".to_string(),
937 ];
938
939 assert_eq!(ids.as_all_other_ids(), OtherIds(expected.clone()));
940 assert_eq!(ids.as_all_ids(), expected);
941 }
942
943 #[test]
944 fn test_best_external_selection_for_book_ids_only() {
945 let ids = RsIds {
946 isbn13: Some("9780131103627".to_string()),
947 openlibrary_edition_id: Some("OL5M".to_string()),
948 openlibrary_work_id: Some("OL6W".to_string()),
949 google_books_volume_id: Some("vol-5".to_string()),
950 anilist_manga_id: Some(12),
951 mangadex_manga_uuid: Some("uuid-5".to_string()),
952 myanimelist_manga_id: Some(34),
953 asin: Some("B00TEST000".to_string()),
954 ..Default::default()
955 };
956 assert_eq!(
957 ids.as_best_external(),
958 Some("isbn13:9780131103627".to_string())
959 );
960
961 let ids = RsIds {
962 openlibrary_edition_id: Some("OL5M".to_string()),
963 openlibrary_work_id: Some("OL6W".to_string()),
964 ..Default::default()
965 };
966 assert_eq!(ids.as_best_external(), Some("oleid:OL5M".to_string()));
967
968 let ids = RsIds {
969 anilist_manga_id: Some(12),
970 mangadex_manga_uuid: Some("uuid-5".to_string()),
971 myanimelist_manga_id: Some(34),
972 asin: Some("B00TEST000".to_string()),
973 ..Default::default()
974 };
975 assert_eq!(ids.as_best_external(), Some("anilist:12".to_string()));
976 }
977
978 #[test]
979 fn test_try_from_other_ids_to_rsids() -> Result<(), RsIdsError> {
980 let input = OtherIds(vec![
981 "imdb:tt1234567".to_string(),
982 "tmdb:42".to_string(),
983 "foo:bar".to_string(),
984 ]);
985 let ids = RsIds::try_from(input)?;
986
987 assert_eq!(ids.imdb.as_deref(), Some("tt1234567"));
988 assert_eq!(ids.tmdb, Some(42));
989 assert!(ids.has_other("foo", "bar"));
990 Ok(())
991 }
992
993 #[cfg(feature = "rusqlite")]
994 #[test]
995 fn test_rusqlite_roundtrip_rsids_with_other_ids() -> rusqlite::Result<()> {
996 use rusqlite::Connection;
997
998 let conn = Connection::open_in_memory()?;
999 conn.execute("CREATE TABLE test_rsids (ids TEXT NOT NULL)", [])?;
1000
1001 let mut ids = RsIds::default();
1002 ids.add_other("foo", "42");
1003 ids.add_other("bar", "abc");
1004 conn.execute("INSERT INTO test_rsids (ids) VALUES (?1)", [&ids])?;
1005
1006 let loaded: RsIds =
1007 conn.query_row("SELECT ids FROM test_rsids LIMIT 1", [], |row| row.get(0))?;
1008
1009 assert!(loaded.has_other("foo", "42"));
1010 assert!(loaded.has_other("bar", "abc"));
1011 Ok(())
1012 }
1013}