diff --git a/crates/arr-core/src/subs.rs b/crates/arr-core/src/subs.rs index 9730d8c..102fbf6 100644 --- a/crates/arr-core/src/subs.rs +++ b/crates/arr-core/src/subs.rs @@ -72,6 +72,11 @@ pub enum SubtitleVerdict { /// One candidate paired with the verdict that placed it in the ranking. #[derive(Clone, Copy, Debug)] pub struct RankedSubtitle<'a> { + /// Position of this candidate in the slice handed to [`rank`]. Ranking + /// reorders, and a candidate carries no identity of its own, so this is + /// how a caller maps a result back to the provider candidate it came + /// from — and therefore to the id it must ask the provider to download. + pub index: usize, pub candidate: SubtitleCandidate<'a>, pub verdict: SubtitleVerdict, } @@ -128,7 +133,9 @@ pub fn rank<'a>( ) -> Vec> { let mut ranked: Vec> = candidates .iter() - .map(|candidate| RankedSubtitle { + .enumerate() + .map(|(index, candidate)| RankedSubtitle { + index, candidate: *candidate, verdict: if candidate.forced { SubtitleVerdict::Rejected(SubtitleRule::Forced) @@ -171,6 +178,38 @@ mod tests { } } + #[test] + fn ranking_reports_where_each_candidate_came_from() { + // Ranking reorders, and a candidate carries no id of its own, so the + // index is the only way back to the provider candidate — and so to + // the id the provider is asked to download. + let weak = plain(); + let strong = SubtitleCandidate { + moviehash: Some("abc123"), + ..plain() + }; + let ranked = rank(&target(), &[weak, strong]); + + assert_eq!(ranked[0].index, 1); + assert_eq!(ranked[1].index, 0); + } + + #[test] + fn a_rejected_candidate_still_reports_its_index() { + let forced = SubtitleCandidate { + forced: true, + ..plain() + }; + let ranked = rank(&target(), &[forced, plain()]); + + assert_eq!(ranked[0].index, 1); + assert_eq!(ranked[1].index, 0); + assert_eq!( + ranked[1].verdict, + SubtitleVerdict::Rejected(SubtitleRule::Forced) + ); + } + #[test] fn a_moviehash_match_wins_outright_over_every_other_tier() { let hash_match = SubtitleCandidate { diff --git a/crates/arr-subs/src/model.rs b/crates/arr-subs/src/model.rs index b36b089..33c6e01 100644 --- a/crates/arr-subs/src/model.rs +++ b/crates/arr-subs/src/model.rs @@ -152,6 +152,46 @@ pub struct Candidate { pub sdh: bool, } +impl Candidate { + /// The same candidate in the shape `arr_core::subs::rank` eats. + /// + /// The two types are deliberately separate — this crate reports what a + /// provider said, `arr-core` decides what it means — so the mapping + /// between them is decided here, once, rather than reinvented by every + /// caller: + /// + /// * `hash_match` is a boolean here because that is what a provider + /// answers, while ranking compares hashes. A hash match is therefore + /// expressed by handing ranking `target_moviehash`; no match is `None`, + /// which never compares equal. + /// * `rating` and `download_count` are optional here because a provider + /// may not report them. Both are tiebreakers, so a missing one sorts + /// last rather than blocking the candidate: `0.0` and `0`. + /// + /// `language` is dropped: ranking runs over one wanted language at a + /// time, so the caller has already filtered by it. + #[must_use] + pub fn to_core<'a>( + &'a self, + target_moviehash: Option<&'a str>, + ) -> arr_core::subs::SubtitleCandidate<'a> { + arr_core::subs::SubtitleCandidate { + forced: self.forced, + hearing_impaired: self.sdh, + moviehash: if self.hash_match { + target_moviehash + } else { + None + }, + release_name: self.release_name.as_deref(), + release_group: self.group.as_deref(), + source: self.source, + uploader_rating: f64::from(self.rating.unwrap_or(0.0)), + download_count: self.download_count.unwrap_or(0), + } + } +} + /// The wire format of a downloaded subtitle. /// /// Sidecars on disk are SRT (DESIGN.md §15), but providers serve other things @@ -200,7 +240,54 @@ pub struct Fetched { #[cfg(test)] mod tests { - use super::{CandidateId, ProviderId, SubtitleFormat}; + use arr_core::Language; + + use super::{Candidate, CandidateId, ProviderId, SubtitleFormat}; + + fn candidate() -> Candidate { + Candidate { + provider: ProviderId::new("opensubtitles"), + id: CandidateId::new("1"), + language: Language::PortuguesePortugal, + hash_match: false, + release_name: Some("Movie.2024.1080p.WEB-DL-GROUP".into()), + group: Some("GROUP".into()), + source: None, + rating: None, + download_count: None, + forced: false, + sdh: true, + } + } + + #[test] + fn a_hash_match_is_handed_to_ranking_as_the_target_hash() { + let matched = Candidate { + hash_match: true, + ..candidate() + }; + assert_eq!(matched.to_core(Some("abc123")).moviehash, Some("abc123")); + // No match must never compare equal, whatever the target carries. + let unmatched = candidate(); + assert_eq!(unmatched.to_core(Some("abc123")).moviehash, None); + } + + #[test] + fn missing_tiebreakers_sort_last_rather_than_blocking_a_candidate() { + let candidate = candidate(); + let core = candidate.to_core(None); + assert!((core.uploader_rating - 0.0).abs() < f64::EPSILON); + assert_eq!(core.download_count, 0); + } + + #[test] + fn the_sdh_and_group_facts_survive_the_rename_across_the_seam() { + let candidate = candidate(); + let core = candidate.to_core(None); + assert!(core.hearing_impaired); + assert!(!core.forced); + assert_eq!(core.release_group, Some("GROUP")); + } #[test] fn ids_render_as_the_string_they_wrap() {