feat: season-pack vs per-episode grab selection (#94)
This commit was merged in pull request #94.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user