From 99604a7e557c0333cd006235a373cedd21918407 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 20:33:59 +0800 Subject: [PATCH] Prefer season packs over individual episodes in search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- breadarrd/src/scheduler.rs | 64 +++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs index 3a990c6..ba55675 100644 --- a/breadarrd/src/scheduler.rs +++ b/breadarrd/src/scheduler.rs @@ -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, std::cmp::Reverse) { + 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();