diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 4421f0f..9e7b011 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod matching; pub mod policy; pub mod score; pub mod status; +pub mod subs; pub mod tracking; pub use arr_parse::NameClaims as ParsedRelease; @@ -24,6 +25,10 @@ pub use matching::{ }; pub use score::{Score, ScoreWeights}; pub use status::{derive_series_status, SeriesStatus}; +pub use subs::{ + rank as rank_subtitles, RankedSubtitle, SubtitleCandidate, SubtitleRule, SubtitleTarget, + SubtitleVerdict, +}; macro_rules! id_type { ($name:ident) => { diff --git a/crates/arr-core/src/subs.rs b/crates/arr-core/src/subs.rs new file mode 100644 index 0000000..9730d8c --- /dev/null +++ b/crates/arr-core/src/subs.rs @@ -0,0 +1,306 @@ +//! Ranking subtitle candidates against a media file (`DESIGN.md` §15). +//! +//! Pure and IO-free, alongside the release scoring in §5.5: the input is the +//! facts already known about the file plus a list of candidates as reported +//! by a provider, the output is those candidates ordered best first, each +//! carrying the verdict that put it there. +//! +//! The verdict vocabulary mirrors the release path — [`SubtitleVerdict`] +//! carries `Eligible` or `Rejected` a named [`SubtitleRule`], not a string +//! built for a log line — so §9.3's manual view needs no second concept for +//! subtitles. There is no `waived` bucket: nothing about a subtitle is worth +//! overriding by hand. + +use crate::Source; + +/// What is known about the file a subtitle candidate is ranked against. +#[derive(Clone, Copy, Debug, Default)] +pub struct SubtitleTarget<'a> { + /// The file's own `moviehash`, when computed. + pub moviehash: Option<&'a str>, + /// The exact name of the release that produced the file. + pub release_name: Option<&'a str>, + /// The release group claimed by that release name. + pub release_group: Option<&'a str>, + /// The source tier claimed by that release name. + pub source: Option, +} + +/// One subtitle candidate, as reported by a provider. +#[derive(Clone, Copy, Debug, Default)] +pub struct SubtitleCandidate<'a> { + /// Covers foreign-language lines and on-screen signs only; never + /// satisfies a want (§15's "Forced and SDH"). + pub forced: bool, + /// Complete and satisfies a want, but ranks below a plain subtitle for + /// the same language. + pub hearing_impaired: bool, + pub moviehash: Option<&'a str>, + pub release_name: Option<&'a str>, + pub release_group: Option<&'a str>, + pub source: Option, + pub uploader_rating: f64, + pub download_count: u64, +} + +/// Why a subtitle candidate was rejected. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SubtitleRule { + /// A forced track covers foreign-language lines only and cannot satisfy + /// a want (§15). + Forced, +} + +impl SubtitleRule { + /// The stable name shared by the API and UI, so one rule reads the same + /// everywhere (mirrors [`crate::Rule::name`]). + #[must_use] + pub const fn name(&self) -> &'static str { + match self { + Self::Forced => "forced", + } + } +} + +/// The verdict for one subtitle candidate. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SubtitleVerdict { + Eligible, + Rejected(SubtitleRule), +} + +/// One candidate paired with the verdict that placed it in the ranking. +#[derive(Clone, Copy, Debug)] +pub struct RankedSubtitle<'a> { + pub candidate: SubtitleCandidate<'a>, + pub verdict: SubtitleVerdict, +} + +/// The ordering key for an eligible candidate, most significant field first. +/// Every field is a boolean win or an integer tiebreaker, so `Ord` alone +/// gives the ranking — no float, no partial order. +type TierKey = (bool, bool, bool, bool, i64, u64); + +fn tier_key(candidate: &SubtitleCandidate<'_>, target: &SubtitleTarget<'_>) -> TierKey { + let plain = !candidate.hearing_impaired; + let moviehash_match = matches_ci(candidate.moviehash, target.moviehash); + let exact_release_match = matches_ci(candidate.release_name, target.release_name); + let group_or_source_match = matches_ci(candidate.release_group, target.release_group) + || matches_source(candidate.source, target.source); + // Scaled to hundredths so the tiebreaker is an integer, not a float. + // Ratings sit in 0..10, nowhere near i64's range. + #[allow(clippy::cast_possible_truncation)] + let rating = (candidate.uploader_rating * 100.0).round() as i64; + + ( + plain, + moviehash_match, + exact_release_match, + group_or_source_match, + rating, + candidate.download_count, + ) +} + +fn matches_ci(candidate: Option<&str>, target: Option<&str>) -> bool { + match (candidate, target) { + (Some(candidate), Some(target)) => candidate.eq_ignore_ascii_case(target), + _ => false, + } +} + +fn matches_source(candidate: Option, target: Option) -> bool { + matches!((candidate, target), (Some(candidate), Some(target)) if candidate == target) +} + +/// Rank candidates against a target, best first. +/// +/// A forced candidate is rejected outright and sorts after every eligible +/// one, in the order it was given (§15: it can never satisfy a want). Among +/// eligible candidates, a plain subtitle always outranks a hearing-impaired +/// one for the same language; within that split, a `moviehash` match wins +/// outright, then an exact release-name match, then a shared release group +/// or source, then uploader rating and download count as tiebreakers. +#[must_use] +pub fn rank<'a>( + target: &SubtitleTarget<'_>, + candidates: &[SubtitleCandidate<'a>], +) -> Vec> { + let mut ranked: Vec> = candidates + .iter() + .map(|candidate| RankedSubtitle { + candidate: *candidate, + verdict: if candidate.forced { + SubtitleVerdict::Rejected(SubtitleRule::Forced) + } else { + SubtitleVerdict::Eligible + }, + }) + .collect(); + + ranked.sort_by(|a, b| match (a.verdict, b.verdict) { + (SubtitleVerdict::Rejected(_), SubtitleVerdict::Rejected(_)) => std::cmp::Ordering::Equal, + (SubtitleVerdict::Rejected(_), SubtitleVerdict::Eligible) => std::cmp::Ordering::Greater, + (SubtitleVerdict::Eligible, SubtitleVerdict::Rejected(_)) => std::cmp::Ordering::Less, + (SubtitleVerdict::Eligible, SubtitleVerdict::Eligible) => { + tier_key(&b.candidate, target).cmp(&tier_key(&a.candidate, target)) + } + }); + + ranked +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target() -> SubtitleTarget<'static> { + SubtitleTarget { + moviehash: Some("abc123"), + release_name: Some("Movie.2024.1080p.WEB-DL-GROUP"), + release_group: Some("GROUP"), + source: Some(Source::WebDl), + } + } + + fn plain() -> SubtitleCandidate<'static> { + SubtitleCandidate { + uploader_rating: 5.0, + download_count: 100, + ..Default::default() + } + } + + #[test] + fn a_moviehash_match_wins_outright_over_every_other_tier() { + let hash_match = SubtitleCandidate { + moviehash: Some("abc123"), + uploader_rating: 0.0, + download_count: 0, + ..plain() + }; + let everything_else = SubtitleCandidate { + release_name: Some("Movie.2024.1080p.WEB-DL-GROUP"), + release_group: Some("GROUP"), + source: Some(Source::WebDl), + uploader_rating: 10.0, + download_count: 1_000_000, + ..plain() + }; + let ranked = rank(&target(), &[everything_else, hash_match]); + assert!(matches!(ranked[0].candidate.moviehash, Some("abc123"))); + assert_eq!(ranked[0].verdict, SubtitleVerdict::Eligible); + } + + #[test] + fn an_exact_release_name_match_outranks_group_or_source_alone() { + let exact_name = SubtitleCandidate { + release_name: Some("Movie.2024.1080p.WEB-DL-GROUP"), + ..plain() + }; + let group_only = SubtitleCandidate { + release_group: Some("GROUP"), + uploader_rating: 10.0, + download_count: 1_000_000, + ..plain() + }; + let ranked = rank(&target(), &[group_only, exact_name]); + assert_eq!( + ranked[0].candidate.release_name, + Some("Movie.2024.1080p.WEB-DL-GROUP") + ); + } + + #[test] + fn same_source_alone_outranks_no_match_at_all() { + let same_source = SubtitleCandidate { + source: Some(Source::WebDl), + ..plain() + }; + let no_match = SubtitleCandidate { + uploader_rating: 10.0, + download_count: 1_000_000, + ..plain() + }; + let ranked = rank(&target(), &[no_match, same_source]); + assert_eq!(ranked[0].candidate.source, Some(Source::WebDl)); + } + + #[test] + #[allow(clippy::float_cmp)] + fn rating_then_downloads_break_ties() { + let low = SubtitleCandidate { + uploader_rating: 3.0, + download_count: 50, + ..plain() + }; + let high_rating = SubtitleCandidate { + uploader_rating: 8.0, + download_count: 10, + ..plain() + }; + let ranked = rank(&target(), &[low, high_rating]); + assert_eq!(ranked[0].candidate.uploader_rating, 8.0); + + let same_rating_more_downloads = SubtitleCandidate { + uploader_rating: 8.0, + download_count: 9_000, + ..plain() + }; + let ranked = rank(&target(), &[high_rating, same_rating_more_downloads]); + assert_eq!(ranked[0].candidate.download_count, 9_000); + } + + #[test] + fn a_forced_candidate_is_never_eligible() { + let forced = SubtitleCandidate { + forced: true, + moviehash: Some("abc123"), + ..plain() + }; + let ranked = rank(&target(), &[forced]); + assert_eq!( + ranked[0].verdict, + SubtitleVerdict::Rejected(SubtitleRule::Forced) + ); + assert_eq!(SubtitleRule::Forced.name(), "forced"); + } + + #[test] + fn an_sdh_candidate_ranks_below_any_plain_candidate() { + let sdh_with_hash_match = SubtitleCandidate { + hearing_impaired: true, + moviehash: Some("abc123"), + uploader_rating: 10.0, + download_count: 1_000_000, + ..plain() + }; + let plain_with_nothing = SubtitleCandidate { + uploader_rating: 0.0, + download_count: 0, + ..plain() + }; + let ranked = rank(&target(), &[sdh_with_hash_match, plain_with_nothing]); + assert!(!ranked[0].candidate.hearing_impaired); + assert!(ranked[1].candidate.hearing_impaired); + } + + #[test] + fn rejected_candidates_sort_after_every_eligible_one_in_input_order() { + let forced_a = SubtitleCandidate { + forced: true, + download_count: 1, + ..plain() + }; + let forced_b = SubtitleCandidate { + forced: true, + download_count: 2, + ..plain() + }; + let eligible = plain(); + let ranked = rank(&target(), &[forced_a, eligible, forced_b]); + assert_eq!(ranked[0].verdict, SubtitleVerdict::Eligible); + assert_eq!(ranked[1].candidate.download_count, 1); + assert_eq!(ranked[2].candidate.download_count, 2); + } +}