//! 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, 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, 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, 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, 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) -> Vec { 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::>() .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) -> ProbedMedia { ProbedMedia { resolution, source: None, hdr, audio_tracks: audio .into_iter() .map(|language| AudioTrack { language, title: None, handler_name: None, }) .collect(), subtitle_tracks: Vec::::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]"); } }