diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index a3273dc..5283cd0 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -243,7 +243,7 @@ internal fun TitleScreen( title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime), title.rating?.let { "★ %.1f".format(it) }, "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, - ).joinToString(" · "), + ).plus(title.quality).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index c3e96b1..f0b824a 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -587,6 +587,10 @@ pub struct TitleRecord { /// Minutes: an episode's length for a series, the running time of a film. #[uniffi(default = None)] pub runtime: Option, + /// What most of its files' names say about them — `4K`, `HDR`, `10-bit`, + /// `Dual audio` — in that order. Never a release group's name. + #[uniffi(default = [])] + pub quality: Vec, } /// Playback preferences that outlive one file, one title and one launch. @@ -3656,6 +3660,12 @@ fn title_record( franchise: Vec::new(), logo_url: metadata.and_then(|found| found.logo_url.clone()), runtime: details.and_then(|details| details.runtime), + quality: title + .quality() + .badges() + .into_iter() + .map(str::to_owned) + .collect(), continue_index: title.continue_with().and_then(|episode| { let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?; u32::try_from(at).ok() diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index e9a1ed4..5b1f9b5 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -689,6 +689,8 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String { parts.push(format!("{total} on {}", found.provider.label())); } } + // Last: what the files are, rather than what the title is. + parts.extend(title.quality().badges().into_iter().map(str::to_owned)); parts.join(" · ") } diff --git a/crates/pstr-core/src/library.rs b/crates/pstr-core/src/library.rs index 6174562..ea010d5 100644 --- a/crates/pstr-core/src/library.rs +++ b/crates/pstr-core/src/library.rs @@ -18,6 +18,7 @@ use std::collections::HashMap; use crate::catalog::{CatalogNode, WatchState}; +use crate::naming; /// A file has been watched once playback passed this fraction of it. Short of /// 1.0 because nobody sits through the credits, and a title that never reaches @@ -158,6 +159,34 @@ impl Title { .sum() } + /// What most of its files' names say about their quality — see + /// [`naming::quality`]. A fact holds when more than half the files state + /// it, so one 720p special does not mark a 1080p show down. + pub fn quality(&self) -> naming::Quality { + let stated: Vec = self + .episodes() + .map(|episode| naming::quality(&episode.node.name)) + .collect(); + let most = |holds: &dyn Fn(&naming::Quality) -> bool| { + stated.iter().filter(|quality| holds(quality)).count() * 2 > stated.len() + }; + // The height most files have, if most have the same one. + let mut heights: HashMap = HashMap::new(); + for height in stated.iter().filter_map(|quality| quality.height) { + *heights.entry(height).or_default() += 1; + } + let height = heights + .into_iter() + .find(|(_, files)| files * 2 > stated.len()) + .map(|(height, _)| height); + naming::Quality { + height, + hdr: most(&|quality| quality.hdr), + ten_bit: most(&|quality| quality.ten_bit), + dual_audio: most(&|quality| quality.dual_audio), + } + } + pub fn watched_count(&self) -> usize { self.episodes().filter(|e| e.is_watched()).count() } @@ -567,6 +596,36 @@ mod tests { assert_eq!(title.preceding("s1", "a"), None); } + /// One odd file out — a 720p special, a release with no dub — does not + /// change what the title as a whole is said to be. + #[test] + fn a_title_states_the_quality_most_of_its_files_have() { + let library = Library::build( + vec![ + node( + "s1", + "a", + "Show - S01E01 [1080p][Dual-Audio].mkv", + Some(1), + Some(1), + ), + node( + "s1", + "b", + "Show - S01E02 [1080p][Dual-Audio].mkv", + Some(1), + Some(2), + ), + node("s1", "c", "Show - S01E03 [720p].mkv", Some(1), Some(3)), + ], + &HashMap::new(), + ); + assert_eq!( + library.titles[0].quality().badges(), + ["1080p", "Dual audio"] + ); + } + #[test] fn a_file_from_another_share_is_not_this_titles_neighbour() { let library = Library::build( diff --git a/crates/pstr-core/src/naming.rs b/crates/pstr-core/src/naming.rs index 8762971..a1e3fc2 100644 --- a/crates/pstr-core/src/naming.rs +++ b/crates/pstr-core/src/naming.rs @@ -145,6 +145,87 @@ fn is_release_tag(word: &str) -> bool { resolution || TAGS.contains(&word.as_str()) } +/// What a release name says about its picture and sound. +/// +/// Read for the facts line of a title's page, and nothing else: no release +/// group's name is ever taken, and a name that says nothing yields the default. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Quality { + /// Lines of picture: `2160` for `2160p`, `4K` or `UHD`. + pub height: Option, + /// `HDR`, `HDR10`, `HDR10+` or Dolby Vision. + pub hdr: bool, + pub ten_bit: bool, + /// Two audio languages, as anime releases mark a dub beside the original. + pub dual_audio: bool, +} + +impl Quality { + /// The short labels to show, in a fixed order: `4K`, `HDR`, `10-bit`, + /// `Dual audio`. + pub fn badges(&self) -> Vec<&'static str> { + let resolution = self.height.map(|height| match height { + 2160.. => "4K", + 1440.. => "1440p", + 1080.. => "1080p", + 720.. => "720p", + _ => "SD", + }); + resolution + .into_iter() + .chain(self.hdr.then_some("HDR")) + .chain(self.ten_bit.then_some("10-bit")) + .chain(self.dual_audio.then_some("Dual audio")) + .collect() + } +} + +/// The [`Quality`] a file name states. +/// +/// Words are taken apart on everything but letters, digits and `+`, so +/// `[1080p][10-bit]`, `.10bit.` and `(BD 1080p HEVC 10bit Dual Audio)` read +/// the same. +pub fn quality(file_name: &str) -> Quality { + let lower = strip_extension(file_name).to_ascii_lowercase(); + let words: Vec<&str> = lower + .split(|c: char| !c.is_ascii_alphanumeric() && c != '+') + .filter(|word| !word.is_empty()) + .collect(); + let mut quality = Quality::default(); + for (index, word) in words.iter().enumerate() { + let next = words.get(index + 1).copied(); + let height = match *word { + "4k" | "uhd" => Some(2160), + _ => resolution(word), + }; + if let Some(height) = height { + quality.height = quality.height.max(Some(height)); + } + match (*word, next) { + ("hdr" | "hdr10" | "hdr10+" | "dv" | "dovi", _) | ("dolby", Some("vision")) => { + quality.hdr = true; + } + ("10bit" | "10bits" | "hi10" | "hi10p", _) | ("10", Some("bit" | "bits")) => { + quality.ten_bit = true; + } + ("dualaudio", _) | ("dual" | "multi", Some("audio")) => quality.dual_audio = true, + _ => {} + } + } + quality +} + +/// `1080` out of `1080p`, and out of `bd1080p`, which some groups write. +fn resolution(word: &str) -> Option { + let digits = word + .strip_suffix('p')? + .trim_start_matches(|c: char| c.is_ascii_alphabetic()); + if !(3..=4).contains(&digits.len()) { + return None; + } + digits.parse().ok() +} + /// Drop a trailing extension, but only one that looks like an extension. /// /// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the @@ -1162,6 +1243,33 @@ mod tests { assert_eq!(parse("Show.S01E01.Pilot.mkv").episode_title, None); } + #[test] + fn a_scene_release_states_4k_hdr_and_10_bit() { + let stated = + quality("Game.of.Thrones.S01E01.Winter.Is.Coming.2160p.UHD.BluRay.x265.10bit.HDR.mkv"); + assert_eq!(stated.badges(), ["4K", "HDR", "10-bit"]); + } + + #[test] + fn a_fansub_release_states_its_tags_in_brackets() { + let bracketed = + quality("[Group] Show - S01E01 [1080p][BD][x265][10-bit][Dual-Audio][1A2B3C4D].mkv"); + assert_eq!(bracketed.badges(), ["1080p", "10-bit", "Dual audio"]); + let spaced = quality("Film (BD 1080p HEVC 10bit Dual Audio).mkv"); + assert_eq!(spaced.badges(), ["1080p", "10-bit", "Dual audio"]); + assert_eq!( + quality("[Group] Show - 01 [BD1080p].mkv").height, + Some(1080) + ); + } + + /// Most of a real library: a group, a number and nothing else to say. + #[test] + fn a_name_with_no_tags_states_nothing() { + assert_eq!(quality("[Group] Bleach - S17E01.mkv"), Quality::default()); + assert!(Quality::default().badges().is_empty()); + } + /// A library that names its files with nothing but the episode marker. #[test] fn a_bare_episode_marker_is_read_as_an_episode() { diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e7dbc15..18b893e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -76,6 +76,8 @@ Click a title to open its page. **Esc** goes back. - With posters and descriptions on, a show's logo takes the place of its name at the top of the page and on the library's banner, and each episode says how long it runs. A film says how long it runs in the line under its name. +- The line under the name also says what most of the files are, when their names say it: **4K**, + **1080p**, **HDR**, **10-bit** or **Dual audio**. An episode counts as watched once you have seen 90% of it. diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index f4af142..803983f 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -231,7 +231,7 @@ struct TitleView: View { // A series' running time is its episodes', which each row says. let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched] - return parts.compactMap { $0 }.joined(separator: " · ") + return (parts.compactMap { $0 } + title.quality).joined(separator: " · ") } private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String {