Files
arr/crates/arr-core/src/layout.rs
T
naps62-yolo 9f812fac11
ci / web (push) Successful in 24s
e2e / e2e (push) Successful in 54s
ci / rust (push) Successful in 2m11s
feat: season-pack vs per-episode grab selection (#94)
2026-08-23 01:40:53 +01:00

267 lines
8.3 KiB
Rust

//! 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 per-series folder: `Bluey (2018) [tmdbid-82728]`. Same shape as a
/// movie's, and the same reasons for it.
#[must_use]
pub fn series_folder(title: &str, year: Option<i64>, tmdb_id: i64) -> String {
movie_folder(title, year, tmdb_id)
}
/// The per-season folder inside a series folder: `Season 01`.
#[must_use]
pub fn season_folder(season: u16) -> String {
format!("Season {season:02}")
}
/// An episode's filename: `Bluey (2018) - S01E02 - Hospital [1080p][WEB-DL][pt-PT].mkv`.
///
/// Unlike the folder there is no provider ID — Jellyfin matches episodes by
/// the `SxxEyy` tag once the folder pinned the series.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn episode_file_name(
series_title: &str,
year: Option<i64>,
season: u16,
episode: u16,
episode_title: &str,
tags: &[String],
extension: Option<&str>,
) -> String {
let series = sanitise(series_title);
let series = match year {
Some(year) => format!("{series} ({year})"),
None => series,
};
let episode_title = sanitise(episode_title);
let tags = tags.iter().fold(String::new(), |mut out, tag| {
out.push('[');
out.push_str(tag);
out.push(']');
out
});
let stem = format!("{series} - S{season:02}E{episode:02} - {episode_title} {tags}");
match extension {
Some(extension) => format!("{stem}.{extension}"),
None => stem,
}
}
/// 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 exact §7.4 TV example.
#[test]
fn the_design_document_episode_example() {
let media = probed(
Resolution::R1080p,
HdrFormat::Sdr,
vec![Language::PortuguesePortugal],
);
let tags = attribute_tags(&media, Some(Source::WebDl));
assert_eq!(
series_folder("Bluey", Some(2018), 82_728),
"Bluey (2018) [tmdbid-82728]"
);
assert_eq!(season_folder(1), "Season 01");
assert_eq!(
episode_file_name("Bluey", Some(2018), 1, 2, "Hospital", &tags, Some("mkv")),
"Bluey (2018) - S01E02 - Hospital [1080p][WEB-DL][pt-PT].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]");
}
}