feat(core,app,android,ios): quality badges from file names

Release names say what their files are — 2160p, HDR, 10-bit, Dual-Audio —
and nothing showed it. naming::quality reads those words, whatever they
are separated by, and Title::quality keeps what more than half a title's
files state, so one odd special does not mark a show down. The facts line
of a title's page ends with them: 4K, 1080p, HDR, 10-bit, Dual audio.
Release groups' names are never read.
This commit is contained in:
nvrl committed 2026-10-29 16:56:19 +01:00
1 parent a7b87cdc79
commit f4c08d03c7
7 files changed
+183 -2

No files matched your search

@@ -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),
+10
View File
@@ -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<u32>,
/// 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<String>,
}
/// 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()
+2
View File
@@ -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(" · ")
}
+59
View File
@@ -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<naming::Quality> = 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<u32, usize> = 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(
+108
View File
@@ -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<u32>,
/// `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<u32> {
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() {
+2
View File
@@ -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.
+1 -1
View File
@@ -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 {