Import pipeline: probe, hardlink, rename, layout (#82)
This commit was merged in pull request #82.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
//! Library layout and naming (`DESIGN.md` §7.4). Pure string building; the
|
||||
//! import pipeline joins these onto a root path and does the IO.
|
||||
//!
|
||||
//! ```text
|
||||
//! Dune Part Two (2024) [tmdbid-693134]/
|
||||
//! Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].mkv
|
||||
//! ```
|
||||
//!
|
||||
//! The provider ID in the folder name turns Jellyfin matching into exact
|
||||
//! lookup, and the attribute tags come from `ffprobe` so they are true — with
|
||||
//! one exception: no file knows whether it came off a disc or a streaming
|
||||
//! service (§5.6), so the source tag stays the release name's claim. Release
|
||||
//! group is deliberately absent.
|
||||
|
||||
use crate::{HdrFormat, Language, ProbedMedia, Source};
|
||||
|
||||
/// The per-title folder: `Dune Part Two (2024) [tmdbid-693134]`.
|
||||
///
|
||||
/// One folder per title even for a single file, so sidecars stay contained
|
||||
/// and deletes are atomic.
|
||||
#[must_use]
|
||||
pub fn movie_folder(title: &str, year: Option<i64>, tmdb_id: i64) -> String {
|
||||
let title = sanitise(title);
|
||||
match year {
|
||||
Some(year) => format!("{title} ({year}) [tmdbid-{tmdb_id}]"),
|
||||
None => format!("{title} [tmdbid-{tmdb_id}]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The feature's filename: the folder name, the attribute tags, and the
|
||||
/// source file's extension.
|
||||
#[must_use]
|
||||
pub fn movie_file_name(
|
||||
title: &str,
|
||||
year: Option<i64>,
|
||||
tmdb_id: i64,
|
||||
tags: &[String],
|
||||
extension: Option<&str>,
|
||||
) -> String {
|
||||
let stem = movie_folder(title, year, tmdb_id);
|
||||
let tags = tags.iter().fold(String::new(), |mut out, tag| {
|
||||
out.push('[');
|
||||
out.push_str(tag);
|
||||
out.push(']');
|
||||
out
|
||||
});
|
||||
match extension {
|
||||
Some(extension) => format!("{stem} - {tags}.{extension}"),
|
||||
None => format!("{stem} - {tags}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The §7.4 attribute tags, in a fixed order: resolution, source, HDR,
|
||||
/// Portuguese audio.
|
||||
///
|
||||
/// Everything but the source comes from the probe. HDR is tagged only when
|
||||
/// present, and audio only for Portuguese variants — that is the audit `ls`
|
||||
/// answers: which files are DV, which of the kids' files are still
|
||||
/// English-only.
|
||||
#[must_use]
|
||||
pub fn attribute_tags(media: &ProbedMedia, claimed_source: Option<Source>) -> Vec<String> {
|
||||
let mut tags = vec![media.resolution.to_string()];
|
||||
|
||||
if let Some(source) = claimed_source {
|
||||
if source != Source::Other {
|
||||
tags.push(source.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if media.hdr != HdrFormat::Sdr {
|
||||
tags.push(media.hdr.to_string());
|
||||
}
|
||||
|
||||
let mut portuguese: Vec<&Language> = Vec::new();
|
||||
for track in &media.audio_tracks {
|
||||
if matches!(
|
||||
track.language,
|
||||
Language::PortuguesePortugal
|
||||
| Language::PortugueseBrazil
|
||||
| Language::PortugueseUnverified
|
||||
) && !portuguese.contains(&&track.language)
|
||||
{
|
||||
portuguese.push(&track.language);
|
||||
}
|
||||
}
|
||||
tags.extend(portuguese.into_iter().map(ToString::to_string));
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// A title as a filesystem name: filesystem-hostile characters dropped,
|
||||
/// whitespace collapsed. TMDB's `Dune: Part Two` becomes `Dune Part Two`.
|
||||
fn sanitise(title: &str) -> String {
|
||||
let cleaned: String = title
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
!matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') && !c.is_control()
|
||||
})
|
||||
.collect();
|
||||
cleaned
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim_matches(|c| c == '.' || c == ' ')
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AudioTrack, DolbyVisionProfile, Resolution, SubtitleTrack};
|
||||
|
||||
fn probed(resolution: Resolution, hdr: HdrFormat, audio: Vec<Language>) -> ProbedMedia {
|
||||
ProbedMedia {
|
||||
resolution,
|
||||
source: None,
|
||||
hdr,
|
||||
audio_tracks: audio
|
||||
.into_iter()
|
||||
.map(|language| AudioTrack {
|
||||
language,
|
||||
title: None,
|
||||
handler_name: None,
|
||||
})
|
||||
.collect(),
|
||||
subtitle_tracks: Vec::<SubtitleTrack>::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact §7.4 movie example.
|
||||
#[test]
|
||||
fn the_design_document_movie_example() {
|
||||
let media = probed(
|
||||
Resolution::R2160p,
|
||||
HdrFormat::Hdr10,
|
||||
vec![Language::Other("en".into())],
|
||||
);
|
||||
let tags = attribute_tags(&media, Some(Source::WebDl));
|
||||
|
||||
assert_eq!(
|
||||
movie_folder("Dune: Part Two", Some(2024), 693_134),
|
||||
"Dune Part Two (2024) [tmdbid-693134]"
|
||||
);
|
||||
assert_eq!(
|
||||
movie_file_name("Dune: Part Two", Some(2024), 693_134, &tags, Some("mkv")),
|
||||
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].mkv"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kids audit surface: a pt-PT track is tagged, SDR is not.
|
||||
#[test]
|
||||
fn portuguese_audio_is_tagged_and_sdr_is_not() {
|
||||
let media = probed(
|
||||
Resolution::R1080p,
|
||||
HdrFormat::Sdr,
|
||||
vec![
|
||||
Language::Other("en".into()),
|
||||
Language::PortuguesePortugal,
|
||||
Language::PortuguesePortugal,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
attribute_tags(&media, Some(Source::WebDl)),
|
||||
vec!["1080p", "WEB-DL", "pt-PT"]
|
||||
);
|
||||
}
|
||||
|
||||
/// `ls` shows which files are DV, profile included (§5.3, §7.4).
|
||||
#[test]
|
||||
fn dolby_vision_tags_carry_the_profile() {
|
||||
let media = probed(
|
||||
Resolution::R2160p,
|
||||
HdrFormat::DolbyVision(DolbyVisionProfile {
|
||||
profile: 8,
|
||||
compatibility_id: Some(1),
|
||||
}),
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(attribute_tags(&media, None), vec!["2160p", "DV8.1"]);
|
||||
}
|
||||
|
||||
/// An unknown source claim is no tag rather than a lie.
|
||||
#[test]
|
||||
fn unknown_sources_are_not_tagged() {
|
||||
let media = probed(Resolution::R1080p, HdrFormat::Sdr, vec![]);
|
||||
assert_eq!(attribute_tags(&media, Some(Source::Other)), vec!["1080p"]);
|
||||
assert_eq!(attribute_tags(&media, None), vec!["1080p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_are_sanitised_for_the_filesystem() {
|
||||
assert_eq!(
|
||||
movie_folder("What / If: A* Story?", Some(2020), 1),
|
||||
"What If A Story (2020) [tmdbid-1]"
|
||||
);
|
||||
assert_eq!(movie_folder("Untitled", None, 2), "Untitled [tmdbid-2]");
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! This crate must never depend on `axum`, `sqlx` or `reqwest`.
|
||||
|
||||
use std::{collections::BTreeMap, path::PathBuf, time::SystemTime};
|
||||
use std::{collections::BTreeMap, fmt, path::PathBuf, time::SystemTime};
|
||||
|
||||
pub mod lang;
|
||||
pub mod layout;
|
||||
pub mod policy;
|
||||
pub mod score;
|
||||
pub mod status;
|
||||
@@ -54,6 +55,19 @@ pub enum Language {
|
||||
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,
|
||||
@@ -62,6 +76,18 @@ pub enum Resolution {
|
||||
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,
|
||||
@@ -77,6 +103,25 @@ pub enum Source {
|
||||
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 {
|
||||
@@ -121,6 +166,24 @@ pub enum HdrFormat {
|
||||
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,
|
||||
@@ -258,6 +321,24 @@ pub enum Rule {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user