Skip to main content

rs_plugin_common_interfaces/domain/
rs_ids.rs

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
14// region:    --- Error Boilerplate
15
16impl 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    /// check if the provided id need parsing like "trakt:xxxxx" and is not directly the local id from this server
598    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}