Prefer season packs over individual episodes in search results

Search results were sorted purely by seeder count, so a season pack only
won out over a single-episode release by accident. Season packs already
clear every missing episode of that season in one grab and go through the
same seeder/quality gate as anything else (an unviable pack still gets
rejected there and iteration falls through to the next candidate) — so
sorting packs first, seeders as the tiebreaker, is a strict improvement
with no new risk. Shared by both the automated search cycle and the
manual-search candidate list.
This commit is contained in:
Breadway 2026-07-21 20:33:59 +08:00
parent ba05a3cb0c
commit 99604a7e55

View file

@ -123,6 +123,20 @@ fn looks_like_season_pack(parsed: &ParsedRelease) -> bool {
parsed.season.is_some() && parsed.episode.is_none() && parsed.absolute_episode.is_none()
}
/// Sort key for a batch of raw search results: season packs first (a pack
/// clears every missing episode in one grab instead of one at a time, and
/// each still goes through the normal seeder/quality gate in `process_item`
/// — a pack with too few seeders is rejected there and iteration just falls
/// through to the next candidate, so this never trades a viable single
/// episode for an unviable pack), highest-seeded first within each group.
fn release_sort_key(item: &RawReleaseItem) -> (std::cmp::Reverse<bool>, std::cmp::Reverse<u32>) {
let is_pack = looks_like_season_pack(&parser::parse(&item.title));
(
std::cmp::Reverse(is_pack),
std::cmp::Reverse(item.seeders.unwrap_or(0)),
)
}
/// True when a release's raw title carries an explicit non-video format
/// marker — an ebook, audiobook, or comic that happens to share a
/// monitored show/movie's title text, not an actual episode or film. A
@ -1638,7 +1652,7 @@ pub async fn execute_search_targets(
};
let mut sorted = items;
sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0)));
sorted.sort_by_key(release_sort_key);
sorted.truncate(MAX_RESULTS_PER_SEARCH);
// Computed once per target, over candidates that at least pass the
@ -2630,6 +2644,54 @@ mod tests {
)));
}
#[test]
fn release_sort_key_prefers_a_season_pack_over_a_higher_seeded_episode() {
let pack = RawReleaseItem {
title: "Some Show (S02 Complete) 1080p WEB-DL".into(),
link: String::new(),
guid: "pack".into(),
size_bytes: None,
seeders: Some(10),
leechers: None,
};
let episode = RawReleaseItem {
title: "Some Show S02E05 1080p WEB-DL".into(),
link: String::new(),
guid: "episode".into(),
size_bytes: None,
seeders: Some(500),
leechers: None,
};
let mut items = vec![episode.clone(), pack.clone()];
items.sort_by_key(release_sort_key);
assert_eq!(items[0].guid, "pack");
assert_eq!(items[1].guid, "episode");
}
#[test]
fn release_sort_key_falls_back_to_seeders_within_the_same_group() {
let low = RawReleaseItem {
title: "Some Show S02E05 1080p WEB-DL".into(),
link: String::new(),
guid: "low".into(),
size_bytes: None,
seeders: Some(5),
leechers: None,
};
let high = RawReleaseItem {
title: "Some Show S02E05 720p WEB-DL".into(),
link: String::new(),
guid: "high".into(),
size_bytes: None,
seeders: Some(50),
leechers: None,
};
let mut items = vec![low.clone(), high.clone()];
items.sort_by_key(release_sort_key);
assert_eq!(items[0].guid, "high");
assert_eq!(items[1].guid, "low");
}
#[test]
fn count_monitored_missing_episodes_in_season_counts_correctly() {
let conn = seeded_conn();