mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
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.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
|
||||||
title.rating?.let { "★ %.1f".format(it) },
|
title.rating?.let { "★ %.1f".format(it) },
|
||||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||||
).joinToString(" · "),
|
).plus(title.quality).joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
|||||||
@@ -587,6 +587,10 @@ pub struct TitleRecord {
|
|||||||
/// Minutes: an episode's length for a series, the running time of a film.
|
/// Minutes: an episode's length for a series, the running time of a film.
|
||||||
#[uniffi(default = None)]
|
#[uniffi(default = None)]
|
||||||
pub runtime: Option<u32>,
|
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.
|
/// Playback preferences that outlive one file, one title and one launch.
|
||||||
@@ -3656,6 +3660,12 @@ fn title_record(
|
|||||||
franchise: Vec::new(),
|
franchise: Vec::new(),
|
||||||
logo_url: metadata.and_then(|found| found.logo_url.clone()),
|
logo_url: metadata.and_then(|found| found.logo_url.clone()),
|
||||||
runtime: details.and_then(|details| details.runtime),
|
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| {
|
continue_index: title.continue_with().and_then(|episode| {
|
||||||
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
|
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
|
||||||
u32::try_from(at).ok()
|
u32::try_from(at).ok()
|
||||||
|
|||||||
@@ -689,6 +689,8 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
|
|||||||
parts.push(format!("{total} on {}", found.provider.label()));
|
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(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::catalog::{CatalogNode, WatchState};
|
use crate::catalog::{CatalogNode, WatchState};
|
||||||
|
use crate::naming;
|
||||||
|
|
||||||
/// A file has been watched once playback passed this fraction of it. Short of
|
/// 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
|
/// 1.0 because nobody sits through the credits, and a title that never reaches
|
||||||
@@ -158,6 +159,34 @@ impl Title {
|
|||||||
.sum()
|
.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 {
|
pub fn watched_count(&self) -> usize {
|
||||||
self.episodes().filter(|e| e.is_watched()).count()
|
self.episodes().filter(|e| e.is_watched()).count()
|
||||||
}
|
}
|
||||||
@@ -567,6 +596,36 @@ mod tests {
|
|||||||
assert_eq!(title.preceding("s1", "a"), None);
|
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]
|
#[test]
|
||||||
fn a_file_from_another_share_is_not_this_titles_neighbour() {
|
fn a_file_from_another_share_is_not_this_titles_neighbour() {
|
||||||
let library = Library::build(
|
let library = Library::build(
|
||||||
|
|||||||
@@ -145,6 +145,87 @@ fn is_release_tag(word: &str) -> bool {
|
|||||||
resolution || TAGS.contains(&word.as_str())
|
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.
|
/// 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
|
/// 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);
|
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.
|
/// A library that names its files with nothing but the episode marker.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bare_episode_marker_is_read_as_an_episode() {
|
fn a_bare_episode_marker_is_read_as_an_episode() {
|
||||||
|
|||||||
@@ -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
|
- 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
|
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.
|
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.
|
An episode counts as watched once you have seen 90% of it.
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ struct TitleView: View {
|
|||||||
// A series' running time is its episodes', which each row says.
|
// A series' running time is its episodes', which each row says.
|
||||||
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
|
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
|
||||||
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched]
|
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 {
|
private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String {
|
||||||
|
|||||||
Reference in new issue
Block a user