@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user