use log::{debug, trace};
use crate::HunchResult;
use crate::hunch_result::Confidence;
use crate::matcher::span::{MatchSpan, Property, Source};
use super::invariance;
pub(super) fn apply_invariance_signals(
matches: &mut Vec<MatchSpan>,
report: &invariance::InvarianceReport,
) {
for ys in &report.year_signals {
if ys.is_invariant {
let before = matches.len();
matches.retain(|m| {
if m.property != Property::Year {
return true;
}
!(m.start <= ys.start && m.end >= ys.end)
});
if matches.len() < before {
debug!(
"[INVARIANCE] suppressed Year match for invariant \"{}\" at {}..{}",
ys.value, ys.start, ys.end
);
}
}
}
for es in &report.episode_signals {
if !es.is_sequential {
trace!(
"[INVARIANCE] variant number {} at {}..{} is non-sequential, skipping",
es.value, es.start, es.end
);
continue;
}
let correctly_claimed = matches.iter().any(|m| {
m.property == Property::Episode
&& m.start <= es.start
&& m.end >= es.end
&& m.value == es.value.to_string()
});
if correctly_claimed {
trace!(
"[INVARIANCE] episode {} at {}..{} already correctly claimed, skipping",
es.value, es.start, es.end
);
continue;
}
let overlaps_non_heuristic = matches.iter().any(|m| {
let overlaps = m.start < es.end && m.end > es.start;
let is_decomposed = overlaps
&& (m.property == Property::Season || m.property == Property::Episode)
&& m.priority <= 0;
overlaps && !is_decomposed
});
if overlaps_non_heuristic {
trace!(
"[INVARIANCE] non-heuristic overlap at {}..{}, skipping episode {} injection",
es.start, es.end, es.value
);
continue;
}
matches.retain(|m| {
let overlaps = m.start < es.end && m.end > es.start;
let is_decomposed = overlaps
&& (m.property == Property::Season || m.property == Property::Episode)
&& m.priority <= 0;
if is_decomposed {
debug!(
"[INVARIANCE] evicting heuristic {:?}={} at {}..{} (pri={})",
m.property, m.value, m.start, m.end, m.priority
);
}
!is_decomposed
});
debug!(
"[CONTEXT] injecting Episode={} at {}..{} (sequential, {}-digit)",
es.value, es.start, es.end, es.digit_count
);
matches.push(
MatchSpan::new(es.start, es.end, Property::Episode, es.value.to_string())
.with_source(Source::Context),
);
}
}
pub(super) fn compute_confidence(
result: &HunchResult,
used_cross_file: bool,
matches: &[MatchSpan],
) -> Confidence {
let tech_properties = [
Property::VideoCodec,
Property::AudioCodec,
Property::ScreenSize,
Property::Source,
Property::Season,
Property::Episode,
];
let anchor_count = tech_properties
.iter()
.filter(|p| result.first(**p).is_some())
.count();
let has_title = result.title().is_some();
let title_len = result.title().map(|t| t.chars().count()).unwrap_or(0);
let has_heuristic_only = matches.iter().any(|m| m.source == Source::Heuristic)
&& !matches.iter().any(|m| m.source == Source::Context);
if used_cross_file && has_title {
return Confidence::High;
}
if anchor_count >= 3 && has_title && title_len >= 2 {
if has_heuristic_only {
return Confidence::Medium;
}
return Confidence::High;
}
if !has_title || title_len <= 1 {
return Confidence::Low;
}
if anchor_count >= 1 {
Confidence::Medium
} else {
Confidence::Low
}
}
const SUBTITLE_CONTAINERS: &[&str] = &["srt", "sub", "ass", "ssa", "idx", "sup", "vtt", "smi"];
const SUBTITLE_STRIP_PROPERTIES: &[Property] = &[
Property::VideoCodec,
Property::ColorDepth,
Property::VideoProfile,
Property::Source,
Property::AudioCodec,
Property::AudioChannels,
Property::AudioProfile,
Property::FrameRate,
];
pub(super) fn strip_tech_from_subtitle_containers(matches: &mut Vec<MatchSpan>) {
let is_subtitle = matches.iter().any(|m| {
m.property == Property::Container
&& SUBTITLE_CONTAINERS
.iter()
.any(|ext| m.value.eq_ignore_ascii_case(ext))
});
if is_subtitle {
matches.retain(|m| !SUBTITLE_STRIP_PROPERTIES.contains(&m.property));
}
}
pub(super) fn compute_override_title_span(
start: usize,
title_len: usize,
input_len: usize,
) -> (usize, usize) {
let end = start.saturating_add(title_len).min(input_len);
(start, end)
}
pub(super) fn release_group_overlaps_episode_title(
rg_start: usize,
rg_end: usize,
ep_start: usize,
ep_end: usize,
) -> bool {
let overlap_start = rg_start.max(ep_start);
let overlap_end = rg_end.min(ep_end);
let overlap = overlap_end.saturating_sub(overlap_start);
let rg_len = rg_end.saturating_sub(rg_start).max(1);
overlap * 2 >= rg_len
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_override_title_span_basic_addition() {
assert_eq!(compute_override_title_span(5, 10, 100), (5, 15));
}
#[test]
fn compute_override_title_span_clamps_to_input_len() {
assert_eq!(compute_override_title_span(90, 20, 100), (90, 100));
}
#[test]
fn compute_override_title_span_zero_length_title() {
assert_eq!(compute_override_title_span(7, 0, 100), (7, 7));
}
#[test]
fn compute_override_title_span_at_input_boundary() {
assert_eq!(compute_override_title_span(0, 100, 100), (0, 100));
}
#[test]
fn compute_override_title_span_does_not_underflow() {
let (s, e) = compute_override_title_span(usize::MAX, 1, usize::MAX);
assert_eq!(s, usize::MAX);
assert_eq!(e, usize::MAX); }
#[test]
fn rg_overlaps_ep_title_no_overlap_keeps() {
assert!(!release_group_overlaps_episode_title(0, 5, 10, 20));
}
#[test]
fn rg_overlaps_ep_title_fully_inside_drops() {
assert!(release_group_overlaps_episode_title(12, 16, 10, 20));
}
#[test]
fn rg_overlaps_ep_title_exactly_50pct_drops() {
assert!(release_group_overlaps_episode_title(10, 20, 5, 15));
}
#[test]
fn rg_overlaps_ep_title_just_under_50pct_keeps() {
assert!(!release_group_overlaps_episode_title(10, 20, 5, 14));
}
#[test]
fn rg_overlaps_ep_title_just_over_50pct_drops() {
assert!(release_group_overlaps_episode_title(10, 20, 5, 16));
}
#[test]
fn rg_overlaps_ep_title_zero_width_rg_is_handled() {
assert!(!release_group_overlaps_episode_title(10, 10, 5, 20));
}
}