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
@@ -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()
|
||||
|
||||
@@ -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(" · ")
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user