feat: season-pack vs per-episode grab selection (#94)
ci / web (push) Successful in 24s
e2e / e2e (push) Successful in 54s
ci / rust (push) Successful in 2m11s

This commit was merged in pull request #94.
This commit is contained in:
2026-08-23 01:40:53 +01:00
parent 35391f02fc
commit 9f812fac11
28 changed files with 2855 additions and 143 deletions
+123
View File
@@ -0,0 +1,123 @@
//! Season-pack versus per-episode grab selection (`DESIGN.md` §13 phase 6).
//!
//! The operator's rule: season packs only when the season is fully released;
//! while a season is airing, grab per episode. A completed season with no
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
//! encode. A pack that hard-failed must not cost the whole season, so the
//! season falls back to per-episode instead of being blacklisted outright.
//!
//! Re-grabbing a pack once an airing season completes is deliberately not
//! done (§14): a season with any episode already on disk grabs per episode.
use std::time::SystemTime;
/// How a season's missing wanted episodes should be grabbed next.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeasonGrabMode {
/// One season-pack torrent for the whole season.
SeasonPack,
/// One grab per aired wanted episode.
PerEpisode,
}
/// Everything the season-pack decision depends on.
#[derive(Clone, Copy, Debug)]
pub struct SeasonGrabFacts<'a> {
/// One entry per episode the season is known to hold, aired or not.
/// `None` is an announced episode with no date yet.
pub air_dates: &'a [Option<SystemTime>],
pub now: SystemTime,
/// Whether any episode of the season already has a file (§14: nothing
/// re-grabs a pack over episodes on disk, and a pack must not re-import
/// what exists).
pub any_episode_on_disk: bool,
/// Whether a season-pack grab for this season already hard-failed.
pub pack_hard_failed: bool,
}
/// Picks the grab mode for one season.
///
/// A season is fully released only when every known episode has an air date
/// in the past. An episode with no date could still be unaired, and grabbing
/// a "complete" pack of a season that is not complete costs a whole torrent
/// of the wrong thing — so an undated episode keeps the season per-episode.
#[must_use]
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
let fully_released = !facts.air_dates.is_empty()
&& facts
.air_dates
.iter()
.all(|date| date.is_some_and(|date| date <= facts.now));
if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed {
SeasonGrabMode::SeasonPack
} else {
SeasonGrabMode::PerEpisode
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
const DAY: Duration = Duration::from_hours(24);
fn facts(air_dates: &[Option<SystemTime>]) -> SeasonGrabFacts<'_> {
SeasonGrabFacts {
air_dates,
now: SystemTime::UNIX_EPOCH + 100 * DAY,
any_episode_on_disk: false,
pack_hard_failed: false,
}
}
#[test]
fn a_fully_released_season_with_nothing_on_disk_takes_the_pack() {
let aired = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 17 * DAY),
];
assert_eq!(season_grab_mode(&facts(&aired)), SeasonGrabMode::SeasonPack);
}
#[test]
fn an_airing_season_grabs_per_episode() {
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
assert_eq!(
season_grab_mode(&facts(&airing)),
SeasonGrabMode::PerEpisode
);
}
#[test]
fn an_undated_episode_keeps_the_season_per_episode() {
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
assert_eq!(
season_grab_mode(&facts(&undated)),
SeasonGrabMode::PerEpisode
);
assert_eq!(season_grab_mode(&facts(&[])), SeasonGrabMode::PerEpisode);
}
#[test]
fn a_hard_failed_pack_falls_back_to_per_episode() {
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut facts = facts(&aired);
facts.pack_hard_failed = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
}
/// §14: episodes already on disk are never re-grabbed as part of a pack.
#[test]
fn a_season_with_an_episode_on_disk_grabs_per_episode() {
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut facts = facts(&aired);
facts.any_episode_on_disk = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
}
}
+68
View File
@@ -50,6 +50,53 @@ pub fn movie_file_name(
}
}
/// The per-series folder: `Bluey (2018) [tmdbid-82728]`. Same shape as a
/// movie's, and the same reasons for it.
#[must_use]
pub fn series_folder(title: &str, year: Option<i64>, tmdb_id: i64) -> String {
movie_folder(title, year, tmdb_id)
}
/// The per-season folder inside a series folder: `Season 01`.
#[must_use]
pub fn season_folder(season: u16) -> String {
format!("Season {season:02}")
}
/// An episode's filename: `Bluey (2018) - S01E02 - Hospital [1080p][WEB-DL][pt-PT].mkv`.
///
/// Unlike the folder there is no provider ID — Jellyfin matches episodes by
/// the `SxxEyy` tag once the folder pinned the series.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn episode_file_name(
series_title: &str,
year: Option<i64>,
season: u16,
episode: u16,
episode_title: &str,
tags: &[String],
extension: Option<&str>,
) -> String {
let series = sanitise(series_title);
let series = match year {
Some(year) => format!("{series} ({year})"),
None => series,
};
let episode_title = sanitise(episode_title);
let tags = tags.iter().fold(String::new(), |mut out, tag| {
out.push('[');
out.push_str(tag);
out.push(']');
out
});
let stem = format!("{series} - S{season:02}E{episode:02} - {episode_title} {tags}");
match extension {
Some(extension) => format!("{stem}.{extension}"),
None => stem,
}
}
/// The §7.4 attribute tags, in a fixed order: resolution, source, HDR,
/// Portuguese audio.
///
@@ -147,6 +194,27 @@ mod tests {
);
}
/// The exact §7.4 TV example.
#[test]
fn the_design_document_episode_example() {
let media = probed(
Resolution::R1080p,
HdrFormat::Sdr,
vec![Language::PortuguesePortugal],
);
let tags = attribute_tags(&media, Some(Source::WebDl));
assert_eq!(
series_folder("Bluey", Some(2018), 82_728),
"Bluey (2018) [tmdbid-82728]"
);
assert_eq!(season_folder(1), "Season 01");
assert_eq!(
episode_file_name("Bluey", Some(2018), 1, 2, "Hospital", &tags, Some("mkv")),
"Bluey (2018) - S01E02 - Hospital [1080p][WEB-DL][pt-PT].mkv"
);
}
/// The kids audit surface: a pt-PT track is tagged, SDR is not.
#[test]
fn portuguese_audio_is_tagged_and_sdr_is_not() {
+2
View File
@@ -4,6 +4,7 @@
use std::{collections::BTreeMap, fmt, path::PathBuf, time::SystemTime};
pub mod grabbing;
pub mod lang;
pub mod layout;
pub mod matching;
@@ -13,6 +14,7 @@ pub mod status;
pub mod tracking;
pub use arr_parse::NameClaims as ParsedRelease;
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
pub use matching::{match_movie, MatchKind, MovieMatch, WantedMovie};
pub use score::{Score, ScoreWeights};
pub use status::{derive_series_status, SeriesStatus};