b51386e004
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.
460 lines
12 KiB
Rust
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));
|
|
}
|
|
}
|