Import pipeline: probe, hardlink, rename, layout (#82)
ci / rust (push) Failing after 1m43s
ci / web (push) Successful in 58s
e2e / e2e (push) Successful in 1m25s

This commit was merged in pull request #82.
This commit is contained in:
2026-08-22 23:21:30 +01:00
parent 62aba6315c
commit ffb8485939
18 changed files with 1653 additions and 20 deletions
+198
View File
@@ -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]");
}
}
+82 -1
View File
@@ -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,