feat(arr): report release-name match per candidate

The manual subtitle view shows the facts that decided a row (§9.3), and
release-name match is §15's second ranking tier. The file's own release
name is not otherwise on the wire, so the UI cannot derive it.
This commit is contained in:
Miguel Palhas
2026-08-25 02:29:50 +01:00
parent 7b4cff1874
commit 9a21649afa
+38
View File
@@ -116,6 +116,12 @@ pub struct SubtitleCandidate {
/// Whether the provider matched the exact file by `moviehash` — §15's
/// outright winner.
pub hash_match: bool,
/// Whether the candidate's release name is the one the file was
/// imported under — §15's second ranking tier. Reported rather than
/// left implicit in the order, because §9.3's manual view shows the
/// facts that decided a row, and the release name of the file on disk
/// is not otherwise on the wire.
pub release_match: bool,
pub release_name: Option<String>,
pub group: Option<String>,
pub source: Option<String>,
@@ -448,6 +454,16 @@ fn language_of(tag: &str) -> Result<Language, ApiError> {
Ok(arr_db::policy::language(tag))
}
/// Whether two release names are the same one, matched exactly as
/// `arr_core::subs`' ranking tier does — case-insensitively, and never when
/// either side is unknown.
fn same_release(candidate: Option<&str>, target: Option<&str>) -> bool {
match (candidate, target) {
(Some(candidate), Some(target)) => candidate.eq_ignore_ascii_case(target),
_ => false,
}
}
async fn subtitles_of(state: &AppState, media_file_id: i64) -> Result<Vec<Subtitle>, ApiError> {
let files = db::files_for(pool(state)?, media_file_id).await?;
Ok(files.into_iter().map(Subtitle::from).collect())
@@ -818,6 +834,10 @@ pub async fn search(
candidate_id: candidate.id.to_string(),
language: candidate.language.to_string(),
hash_match: candidate.hash_match,
release_match: same_release(
candidate.release_name.as_deref(),
target.release_name.as_deref(),
),
release_name: candidate.release_name.clone(),
group: candidate.group.clone(),
source: candidate.source.map(|source| source.to_string()),
@@ -1188,6 +1208,21 @@ mod tests {
const SRT: &str = "1\n00:00:01,000 --> 00:00:02,000\nolá\n";
/// §15's second ranking tier, as the manual view reads it: the same
/// release name matches whatever its casing, and an unknown name on
/// either side is never a match.
#[test]
fn release_names_match_case_insensitively_and_never_when_unknown() {
assert!(super::same_release(
Some("Dune.2021.2160p.WEB-DL-GROUP"),
Some("dune.2021.2160p.web-dl-group")
));
assert!(!super::same_release(Some("Dune.2021"), Some("Dune.2024")));
assert!(!super::same_release(Some("Dune.2021"), None));
assert!(!super::same_release(None, Some("Dune.2021")));
assert!(!super::same_release(None, None));
}
/// Offers three candidates for whatever it is asked: one plain, one that
/// matched by hash, one forced.
#[derive(Debug)]
@@ -1559,6 +1594,9 @@ mod tests {
assert_eq!(candidates[2]["candidate_id"], "forced");
assert_eq!(candidates[2]["verdict"], "rejected");
assert_eq!(candidates[2]["rejected_rule"], "forced");
// No grab record backs this file, so nothing can claim its release
// name — the chip reads "no", never "unknown".
assert_eq!(candidates[0]["release_match"], false);
assert!(body["provider_errors"]
.as_array()
.expect("errors")