use crate::matcher::span::{MatchSpan, Property};
use crate::zone_map;
pub fn is_position_claimed(start: usize, end: usize, resolved: &[MatchSpan]) -> bool {
let candidate_len = end.saturating_sub(start);
if candidate_len == 0 {
return false;
}
let total_overlap: usize = resolved
.iter()
.filter(|m| {
!matches!(
m.property,
Property::ReleaseGroup
| Property::Title
| Property::EpisodeTitle
| Property::FilmTitle
| Property::BonusTitle
| Property::AlternativeTitle
)
})
.filter_map(|m| {
if start >= m.end || end <= m.start {
return None;
}
let overlap_start = start.max(m.start);
let overlap_end = end.min(m.end);
Some(overlap_end.saturating_sub(overlap_start))
})
.sum();
total_overlap * 2 >= candidate_len
}
pub fn is_non_group_token(s: &str) -> bool {
let lower = s.to_lowercase();
if matches!(
lower.as_str(),
"mkv"
| "mp4"
| "avi"
| "wmv"
| "flv"
| "mov"
| "webm"
| "ogm"
| "srt"
| "sub"
| "subs"
| "idx"
| "nfo"
| "iso"
| "par"
| "par2"
) {
return true;
}
matches!(
lower.as_str(),
"fansub"
| "fansubbed"
| "fastsub"
| "multisubs"
| "multi subs"
| "multi sub"
| "subtitle"
| "subtitles"
| "subforced"
| "noreleasegroup"
| "dublado"
| "legendas"
| "legendado"
| "subtitulado"
)
}
pub fn is_rejected_group(
candidate: &str,
abs_start: usize,
abs_end: usize,
resolved: &[MatchSpan],
) -> bool {
is_position_claimed(abs_start, abs_end, resolved)
|| is_non_group_token(candidate)
|| zone_map::is_tier2_token(candidate)
|| is_suffixed_resolution(candidate)
}
pub fn strip_trailing_metadata(filename: &str) -> String {
static META_TOKENS: &[&str] = &[
"dual",
"audio",
"dublado",
"legendas",
"legendado",
"subtitulado",
"hebsubs",
"nlsubs",
"swesub",
"subbed",
"dubbed",
"sample",
"proof",
"proper",
"repack",
"real",
"internal",
"hardcoded",
"eng",
"fre",
"fra",
"spa",
"ger",
"deu",
"ita",
"jpn",
"kor",
"rus",
"por",
"ara",
"hin",
"chi",
"hun",
"multi",
"vff",
"vost",
"vostfr",
"truefrench",
"flemish",
"cze",
"pol",
"swe",
"nor",
"dan",
"fin",
"espanol",
"esp",
];
let (base, ext) = match filename.rfind('.') {
Some(dot) if filename.len() - dot <= 6 => (&filename[..dot], &filename[dot..]),
_ => (filename, ""),
};
let mut result = base.to_string();
loop {
let trimmed = result.trim_end_matches(['.', '-', '_', ' ', '+', '[', ']']);
if trimmed.len() < result.len() {
result = trimmed.to_string();
continue;
}
if let Some(dot) = result.rfind('.') {
let segment = &result[dot + 1..];
if META_TOKENS.iter().any(|t| segment.eq_ignore_ascii_case(t)) {
result = result[..dot].to_string();
continue;
}
}
break;
}
format!("{result}{ext}")
}
pub fn is_hex_crc(s: &str) -> bool {
s.len() == 8 && s.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn is_suffixed_resolution(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() < 4 || bytes.len() > 5 {
return false;
}
let last = bytes[bytes.len() - 1].to_ascii_lowercase();
if last != b'p' && last != b'i' {
return false;
}
bytes[..bytes.len() - 1].iter().all(|b| b.is_ascii_digit())
}
fn is_tech_claimed(start: usize, end: usize, resolved: &[MatchSpan]) -> bool {
resolved.iter().any(|m| {
if !matches!(
m.property,
Property::VideoCodec
| Property::AudioCodec
| Property::Source
| Property::ScreenSize
| Property::AudioChannels
| Property::AudioProfile
| Property::VideoProfile
| Property::FrameRate
| Property::ColorDepth
| Property::StreamingService
| Property::Container
| Property::Other
| Property::Edition
) {
return false;
}
start < m.end && end > m.start
})
}
pub fn expand_group_backwards(
before: &str,
current: &str,
filename_start: usize,
resolved: &[MatchSpan],
) -> String {
let sep_pos = match before.rfind(['.', '-', '_']) {
Some(pos) => pos,
None => return current.to_string(),
};
let segment = &before[sep_pos + 1..];
let before_sep = &before[..sep_pos];
if segment.is_empty()
|| !segment.chars().all(|c| c.is_ascii_alphanumeric())
|| segment.chars().all(|c| c.is_ascii_digit())
{
return current.to_string();
}
let seg_abs_start = filename_start + sep_pos + 1;
let seg_abs_end = filename_start + sep_pos + 1 + segment.len();
if is_position_claimed(seg_abs_start, seg_abs_end, resolved)
|| zone_map::is_tier2_token(segment)
|| is_non_group_token(segment)
|| is_suffixed_resolution(segment)
{
return current.to_string();
}
let last_word_before = before_sep
.rsplit(|c: char| !c.is_ascii_alphanumeric())
.next()
.unwrap_or("");
if !last_word_before.is_empty() {
let compound = format!("{}{}", last_word_before, segment).to_lowercase();
if zone_map::is_tier2_token(&compound) || is_non_group_token(&compound) {
return current.to_string();
}
}
let last_token = before_sep.rsplit(['.', '-', '_', ' ']).next().unwrap_or("");
if !last_token.is_empty() {
let lt_abs_start = filename_start + before_sep.len() - last_token.len();
let lt_abs_end = filename_start + before_sep.len();
let last_is_tech = is_tech_claimed(lt_abs_start, lt_abs_end, resolved)
|| zone_map::is_tier2_token(last_token);
if !last_is_tech {
return current.to_string();
}
}
format!("{segment}-{current}")
}