Files
arr/crates/arr-core/src/lib.rs
T
Miguel Palhas e852f42a9c feat(arr): name subtitle codecs and dispositions
SubtitleTrack now carries the codec, split text formats from bitmap
ones per DESIGN.md §15, plus the forced and SDH dispositions ffprobe
reports. Without the forced flag a file carrying only a forced track
read as satisfied for that language.
2026-08-24 22:32:01 +01:00

516 lines
14 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 subs;
pub mod tracking;
pub use arr_parse::NameClaims as ParsedRelease;
pub use grabbing::{
search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, 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};
pub use subs::{
rank as rank_subtitles, RankedSubtitle, SubtitleCandidate, SubtitleRule, SubtitleTarget,
SubtitleVerdict,
};
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>,
}
/// How an embedded subtitle track is encoded (DESIGN.md §15).
///
/// The split that matters is text versus bitmap: a text track extracts to a
/// sidecar SRT and can feed a translator, a bitmap one satisfies its language
/// for viewing and nothing more. There is no OCR.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SubtitleCodec {
/// `subrip`, the format sidecars are written in.
SubRip,
/// Advanced `SubStation` Alpha, and SSA with it.
Ass,
/// MP4's timed text.
MovText,
/// Presentation graphics — the bitmap track on `BluRay`.
Pgs,
/// `VobSub` — the bitmap track on DVD.
VobSub,
/// Anything else `ffprobe` names that is not one of the above.
Other,
}
impl fmt::Display for SubtitleCodec {
/// `ffprobe`'s codec name, so the probe column spells what the file said.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::SubRip => "subrip",
Self::Ass => "ass",
Self::MovText => "mov_text",
Self::Pgs => "hdmv_pgs_subtitle",
Self::VobSub => "dvd_subtitle",
Self::Other => "other",
})
}
}
impl SubtitleCodec {
/// §15: only these extract to SRT and may become a translation source.
#[must_use]
pub const fn is_text(self) -> bool {
matches!(self, Self::SubRip | Self::Ass | Self::MovText)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubtitleTrack {
pub language: Language,
pub codec: SubtitleCodec,
/// Foreign lines and on-screen signs only. Never satisfies a want (§15).
pub forced: bool,
/// Complete, with sound descriptions. Satisfies, ranked below plain (§15).
pub sdh: bool,
}
#[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));
}
}