267 lines
8.3 KiB
Rust
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]");
|
|
}
|
|
}
|