Files
arr/crates/arr-core/src/lib.rs
T
Miguel Palhas b51386e004 core: carry season number on Episode
derive_series_status identified specials by looking the episode's
season up in a parallel seasons slice, so a caller passing an
incomplete slice silently reverted to pre-#118 behaviour. The season
number now rides on each episode and the slice is gone.
2026-08-23 18:18:26 +01:00

460 lines
12 KiB
Rust

//! Pure domain types shared by the arr crates.
//!
//! This crate must never depend on `axum`, `sqlx` or `reqwest`.
use std::{collections::BTreeMap, fmt, path::PathBuf, time::SystemTime};
pub mod grabbing;
pub mod lang;
pub mod layout;
pub mod matching;
pub mod policy;
pub mod score;
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_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
WantedEpisode, WantedMovie,
};
pub use score::{Score, ScoreWeights};
pub use status::{derive_series_status, SeriesStatus};
macro_rules! id_type {
($name:ident) => {
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct $name(pub i64);
};
}
id_type!(RootId);
id_type!(PolicyId);
id_type!(MovieId);
id_type!(SeriesId);
id_type!(SeasonId);
id_type!(EpisodeId);
id_type!(MediaFileId);
id_type!(ReleaseId);
id_type!(GrabId);
id_type!(OwnerId);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Audience {
Main,
Kids,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Root {
pub id: RootId,
pub audience: Audience,
pub path: PathBuf,
pub policy_id: PolicyId,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Language {
PortuguesePortugal,
PortugueseBrazil,
PortugueseUnverified,
Other(String),
}
impl fmt::Display for Language {
/// The tag the policy columns and §7.4 filenames spell it as:
/// `pt-PT`, `pt-BR`, `por-unverified`, anything else verbatim.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PortuguesePortugal => formatter.write_str("pt-PT"),
Self::PortugueseBrazil => formatter.write_str("pt-BR"),
Self::PortugueseUnverified => formatter.write_str("por-unverified"),
Self::Other(tag) => formatter.write_str(tag),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Resolution {
R2160p,
R1080p,
R720p,
Other(u16),
}
impl fmt::Display for Resolution {
/// The spelling `resolution_pref` and §7.4 filename tags use.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::R2160p => formatter.write_str("2160p"),
Self::R1080p => formatter.write_str("1080p"),
Self::R720p => formatter.write_str("720p"),
Self::Other(height) => write!(formatter, "{height}p"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum Source {
Remux,
BluRay,
WebDl,
WebRip,
Hdtv,
Dvd,
Telecine,
Telesync,
Cam,
Screener,
Other,
}
impl fmt::Display for Source {
/// The spelling `source_weights` and §7.4 filename tags use.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Remux => "Remux",
Self::BluRay => "BluRay",
Self::WebDl => "WEB-DL",
Self::WebRip => "WEBRip",
Self::Hdtv => "HDTV",
Self::Dvd => "DVD",
Self::Telecine => "Telecine",
Self::Telesync => "Telesync",
Self::Cam => "CAM",
Self::Screener => "Screener",
Self::Other => "Unknown",
})
}
}
impl From<arr_parse::Resolution> for Resolution {
fn from(value: arr_parse::Resolution) -> Self {
match value {
arr_parse::Resolution::P480 => Self::Other(480),
arr_parse::Resolution::P576 => Self::Other(576),
arr_parse::Resolution::P720 => Self::R720p,
arr_parse::Resolution::P1080 => Self::R1080p,
arr_parse::Resolution::P2160 => Self::R2160p,
}
}
}
impl From<arr_parse::Source> for Source {
fn from(value: arr_parse::Source) -> Self {
match value {
arr_parse::Source::Cam => Self::Cam,
arr_parse::Source::Telesync => Self::Telesync,
arr_parse::Source::Telecine => Self::Telecine,
arr_parse::Source::Screener => Self::Screener,
arr_parse::Source::Dvd => Self::Dvd,
arr_parse::Source::Hdtv => Self::Hdtv,
arr_parse::Source::WebRip => Self::WebRip,
arr_parse::Source::WebDl => Self::WebDl,
arr_parse::Source::BluRay => Self::BluRay,
arr_parse::Source::Remux => Self::Remux,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DolbyVisionProfile {
pub profile: u8,
pub compatibility_id: Option<u8>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HdrFormat {
Sdr,
Hdr10,
Hdr10Plus,
Hlg,
DolbyVision(DolbyVisionProfile),
}
impl fmt::Display for HdrFormat {
/// The §7.4 filename tag: `HDR10`, `DV8.1`, and so on. Dolby Vision
/// carries its profile because that is the whole point of probing it
/// (§5.3) — `ls` shows which files are DV and which profile.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Sdr => formatter.write_str("SDR"),
Self::Hdr10 => formatter.write_str("HDR10"),
Self::Hdr10Plus => formatter.write_str("HDR10+"),
Self::Hlg => formatter.write_str("HLG"),
Self::DolbyVision(dv) => match dv.compatibility_id {
Some(compatibility_id) => write!(formatter, "DV{}.{compatibility_id}", dv.profile),
None => write!(formatter, "DV{}", dv.profile),
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RequiredAudio {
OriginalLanguage,
AnyOf(Vec<Language>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HdrRules {
pub rejected_dolby_vision_profiles: Vec<DolbyVisionProfile>,
}
/// One resolution's size band (`DESIGN.md` §5.5). Below the floor is a hard
/// filter; the target is where the score peaks; above it the penalty grows
/// with every gibibyte.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SizeBand {
pub floor_bytes: u64,
pub target_bytes: u64,
/// Points lost per gibibyte above `target_bytes`, on the scale set by
/// [`ScoreWeights::size_at_target`] — 1000 points at target means one
/// point is a tenth of a percent, fine enough to tune by hand without a
/// fractional type. Applied per byte over, so a partial gibibyte costs
/// its fraction.
pub penalty_points_per_gib_over: i32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Policy {
pub id: PolicyId,
pub name: String,
pub required_audio: RequiredAudio,
pub dub_blacklist: Vec<Language>,
pub hdr_rules: HdrRules,
pub size_bands: BTreeMap<Resolution, SizeBand>,
pub resolution_preference: Vec<Resolution>,
pub source_weights: BTreeMap<Source, i32>,
pub score_weights: ScoreWeights,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TitleOverrides {
pub only_4k: bool,
pub allow_english_audio: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MediaState {
Missing,
Downloading,
Available,
/// #108: `wanted` was cleared after the grab vanished from Transmission.
/// Distinct from `Missing` so it does not read as an open gap.
Parked,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Movie {
pub id: MovieId,
pub tmdb_id: u64,
pub title: String,
pub year: u16,
pub original_language: Language,
pub root_id: RootId,
pub wanted: bool,
pub overrides: TitleOverrides,
pub state: MediaState,
pub blocked: bool,
pub search_attempts: u32,
pub last_searched_at: Option<SystemTime>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Series {
pub id: SeriesId,
pub tmdb_id: u64,
pub title: String,
pub year: u16,
pub original_language: Language,
pub root_id: RootId,
pub auto_track: bool,
pub overrides: TitleOverrides,
pub upstream_ended: bool,
pub blocked: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Season {
pub id: SeasonId,
pub series_id: SeriesId,
pub number: u16,
pub tracked: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Episode {
pub id: EpisodeId,
pub season_id: SeasonId,
/// The owning season's number, carried so status derivation never needs
/// a second slice a caller can forget (#131).
pub season_number: u16,
pub number: u16,
pub title: String,
pub air_date: Option<SystemTime>,
pub wanted: bool,
pub state: MediaState,
pub search_attempts: u32,
pub last_searched_at: Option<SystemTime>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AudioTrack {
pub language: Language,
pub title: Option<String>,
pub handler_name: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubtitleTrack {
pub language: Language,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProbedMedia {
pub resolution: Resolution,
pub source: Option<Source>,
pub hdr: HdrFormat,
pub audio_tracks: Vec<AudioTrack>,
pub subtitle_tracks: Vec<SubtitleTrack>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Rule {
RequiredAudio,
DubBlacklist(Language),
/// A Portuguese track whose pt-PT / pt-BR flavour no signal resolved.
PortugueseUnverified,
DolbyVisionProfile(DolbyVisionProfile),
Resolution(Resolution),
Source(Source),
Size,
Other(String),
}
impl Rule {
/// The stable name shared by `releases.rejected_rule`, blacklist reasons
/// and `media_files.waiver`, so one rule reads the same everywhere.
#[must_use]
pub fn name(&self) -> String {
match self {
Self::RequiredAudio => "required_audio".into(),
Self::DubBlacklist(_) => "dub_blacklist".into(),
Self::PortugueseUnverified => "portuguese_unverified".into(),
Self::DolbyVisionProfile(_) => "dolby_vision_profile".into(),
Self::Resolution(_) => "resolution".into(),
Self::Source(_) => "source".into(),
Self::Size => "size".into(),
Self::Other(name) => name.clone(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
Eligible,
Waived(Rule),
Rejected(Rule),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MediaFile {
pub id: MediaFileId,
pub movie_id: MovieId,
pub path: PathBuf,
pub size: u64,
pub probed: ProbedMedia,
pub waiver: Option<Rule>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Release {
pub id: ReleaseId,
pub indexer_id: i64,
pub guid: String,
pub name: String,
pub size: u64,
pub seeders: u32,
pub publish_date: SystemTime,
pub download_url: String,
pub parsed: ParsedRelease,
pub score: i64,
pub verdict: Verdict,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GrabState {
Downloading,
Downloaded,
Imported,
Failed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Grab {
pub id: GrabId,
pub release_id: ReleaseId,
pub movie_id: MovieId,
pub infohash: String,
pub state: GrabState,
pub grabbed_at: SystemTime,
pub imported_at: Option<SystemTime>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BlacklistEntry {
pub infohash: String,
pub normalised_name: String,
pub reason: String,
pub created_at: SystemTime,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Owner {
pub id: OwnerId,
pub name: String,
pub ntfy_topic: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MovieOwner {
pub movie_id: MovieId,
pub owner_id: OwnerId,
}
#[cfg(test)]
mod tests {
use super::{DolbyVisionProfile, HdrFormat, Language, Rule, Verdict};
#[test]
fn portuguese_variants_are_distinct() {
assert_ne!(Language::PortuguesePortugal, Language::PortugueseBrazil);
assert_ne!(Language::PortugueseBrazil, Language::PortugueseUnverified);
}
#[test]
fn dolby_vision_profile_is_preserved() {
assert_ne!(
HdrFormat::DolbyVision(DolbyVisionProfile {
profile: 8,
compatibility_id: Some(1),
}),
HdrFormat::DolbyVision(DolbyVisionProfile {
profile: 8,
compatibility_id: Some(2),
})
);
}
#[test]
fn non_eligible_verdicts_name_the_rule() {
let rule = Rule::DolbyVisionProfile(DolbyVisionProfile {
profile: 5,
compatibility_id: None,
});
assert_eq!(Verdict::Rejected(rule.clone()), Verdict::Rejected(rule));
}
}