Merge #185: rank subtitle candidates in arr-core

Closes #185
This commit is contained in:
Miguel Palhas
2026-08-24 22:06:01 +01:00
2 changed files with 311 additions and 0 deletions
+5
View File
@@ -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) => {
+306
View File
@@ -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<Source>,
}
/// 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<Source>,
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<Source>, target: Option<Source>) -> 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<RankedSubtitle<'a>> {
let mut ranked: Vec<RankedSubtitle<'a>> = 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);
}
}