Files
arr/crates/arr-core/src/layout.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

296 lines
9.5 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,
}
}
/// A subtitle sidecar's filename (§15): the video's name, the language, and
/// `srt` — `… [2160p][WEB-DL][HDR10].pt-PT.srt`. A machine translation
/// carries an extra `.mt` segment so `ls` says which subtitles arr made.
#[must_use]
pub fn subtitle_name(video_name: &str, language: &Language, machine_translated: bool) -> String {
let stem = video_name
.rsplit_once('.')
.map_or(video_name, |(stem, _)| stem);
if machine_translated {
format!("{stem}.{language}.mt.srt")
} else {
format!("{stem}.{language}.srt")
}
}
/// 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 §15 sidecar names: plain for a real subtitle, `.mt` for arr's own.
#[test]
fn subtitle_sidecars_carry_the_language_and_the_mt_marker() {
let video = "Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].mkv";
assert_eq!(
subtitle_name(video, &Language::PortuguesePortugal, false),
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].pt-PT.srt"
);
assert_eq!(
subtitle_name(video, &Language::PortugueseBrazil, true),
"Dune Part Two (2024) [tmdbid-693134] - [2160p][WEB-DL][HDR10].pt-BR.mt.srt"
);
}
/// 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]");
}
}