mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
bug fixes, windows build
This commit is contained in:
1 parent
a1b345a029
commit
14c48428a3
29 files changed
+2514
-170
No files matched your search
@@ -21,3 +21,17 @@ directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
# The credential store, per platform. Without the target's feature `keyring`
|
||||
# compiles fine and silently resolves to its in-memory `mock` store, which
|
||||
# accepts every write and has forgotten it by the next `Entry` — a share that
|
||||
# adds cleanly and then reports "has no stored credentials" on open. These
|
||||
# blocks only add features to the dependency above.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))'.dependencies]
|
||||
keyring = { workspace = true, features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { workspace = true, features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||
keyring = { workspace = true, features = ["apple-native"] }
|
||||
@@ -0,0 +1,212 @@
|
||||
//! What the window wears.
|
||||
//!
|
||||
//! Only the *choice* lives here — the colours themselves are `pstr-app`'s, and
|
||||
//! nothing outside the window has an opinion about them. This is stored beside
|
||||
//! the playback preferences and read the same way: a file that will not parse is
|
||||
//! a line in the log and a fall back to the defaults, because a broken theme
|
||||
//! file must never be a reason the library does not open.
|
||||
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::Result;
|
||||
|
||||
/// A palette family: Catppuccin's four, plus the one this app shipped with.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Flavor {
|
||||
/// Near-black and Proton purple. The default, and the only one that is not
|
||||
/// somebody else's palette.
|
||||
#[default]
|
||||
Proton,
|
||||
/// Catppuccin Latte — the light one.
|
||||
Latte,
|
||||
Frappe,
|
||||
Macchiato,
|
||||
Mocha,
|
||||
}
|
||||
|
||||
impl Flavor {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Proton,
|
||||
Self::Mocha,
|
||||
Self::Macchiato,
|
||||
Self::Frappe,
|
||||
Self::Latte,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Proton => "Proton",
|
||||
Self::Latte => "Catppuccin Latte",
|
||||
Self::Frappe => "Catppuccin Frappé",
|
||||
Self::Macchiato => "Catppuccin Macchiato",
|
||||
Self::Mocha => "Catppuccin Mocha",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Proton => "Near-black, so the artwork is the only bright thing on screen",
|
||||
Self::Latte => "Light. The only one that is",
|
||||
Self::Frappe => "Dark, warm, the lowest contrast of the three",
|
||||
Self::Macchiato => "Dark, between Frappé and Mocha",
|
||||
Self::Mocha => "Dark, the deepest of Catppuccin's three",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this flavour is a light theme. Latte, and only Latte.
|
||||
///
|
||||
/// It decides more than the colours: egui keeps a style per theme and the
|
||||
/// platform draws the title bar from the window's own, so both have to be
|
||||
/// told which of the two this is.
|
||||
pub fn is_light(self) -> bool {
|
||||
matches!(self, Self::Latte)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one strong colour in the window — and, where a gradient is drawn, the
|
||||
/// hue it runs into.
|
||||
///
|
||||
/// Named for the first hue rather than for the pair, except where the pair *is*
|
||||
/// the point: [`Accent::PinkSky`] is a gradient before it is a colour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Accent {
|
||||
/// Proton purple, and the equivalent hue in every Catppuccin flavour.
|
||||
#[default]
|
||||
Mauve,
|
||||
Pink,
|
||||
Sky,
|
||||
/// Pink running into light blue.
|
||||
PinkSky,
|
||||
Lavender,
|
||||
Blue,
|
||||
Teal,
|
||||
Peach,
|
||||
}
|
||||
|
||||
impl Accent {
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::Mauve,
|
||||
Self::Pink,
|
||||
Self::Sky,
|
||||
Self::PinkSky,
|
||||
Self::Lavender,
|
||||
Self::Blue,
|
||||
Self::Teal,
|
||||
Self::Peach,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mauve => "Mauve",
|
||||
Self::Pink => "Pink",
|
||||
Self::Sky => "Sky",
|
||||
Self::PinkSky => "Pink → Sky",
|
||||
Self::Lavender => "Lavender",
|
||||
Self::Blue => "Blue",
|
||||
Self::Teal => "Teal",
|
||||
Self::Peach => "Peach",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole of the choice.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
// An older or hand-edited file is missing fields rather than invalid.
|
||||
#[serde(default)]
|
||||
pub struct Appearance {
|
||||
pub flavor: Flavor,
|
||||
pub accent: Accent,
|
||||
/// Whether the accent is painted as a gradient — the seek bar, the play
|
||||
/// button, the top bar — or flat.
|
||||
///
|
||||
/// A switch rather than a fixed decision because a gradient is the first
|
||||
/// thing to go wrong on a screen that cannot show it: on 6-bit panels a
|
||||
/// slow ramp across a wide bar bands visibly, and flat is better than
|
||||
/// striped.
|
||||
pub gradients: bool,
|
||||
}
|
||||
|
||||
impl Default for Appearance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flavor: Flavor::default(),
|
||||
accent: Accent::default(),
|
||||
gradients: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance_file(dirs: &AppDirs) -> std::path::PathBuf {
|
||||
dirs.config.join("appearance.json")
|
||||
}
|
||||
|
||||
/// The stored choice, or the defaults on a first run.
|
||||
pub fn load(dirs: &AppDirs) -> Result<Appearance> {
|
||||
Ok(read_json::<Appearance>(&appearance_file(dirs))?.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Write the choice.
|
||||
pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
|
||||
write_json(&appearance_file(dirs), appearance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dirs(root: &std::path::Path) -> AppDirs {
|
||||
AppDirs {
|
||||
config: root.to_path_buf(),
|
||||
data: root.to_path_buf(),
|
||||
cache: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_run_gets_the_palette_the_app_shipped_with() {
|
||||
let appearance = Appearance::default();
|
||||
assert_eq!(appearance.flavor, Flavor::Proton);
|
||||
assert_eq!(appearance.accent, Accent::Mauve);
|
||||
assert!(appearance.gradients);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_was_saved_is_what_loads() {
|
||||
let temp = std::env::temp_dir().join(format!("pstr-appearance-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&temp).unwrap();
|
||||
let dirs = dirs(&temp);
|
||||
|
||||
let appearance = Appearance {
|
||||
flavor: Flavor::Mocha,
|
||||
accent: Accent::PinkSky,
|
||||
gradients: false,
|
||||
};
|
||||
save(&dirs, &appearance).unwrap();
|
||||
assert_eq!(load(&dirs).unwrap(), appearance);
|
||||
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_written_before_a_field_existed_keeps_the_rest() {
|
||||
let temp = std::env::temp_dir().join(format!("pstr-appearance-old-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&temp).unwrap();
|
||||
let dirs = dirs(&temp);
|
||||
|
||||
std::fs::write(appearance_file(&dirs), r#"{"flavor":"mocha"}"#).unwrap();
|
||||
let appearance = load(&dirs).unwrap();
|
||||
assert_eq!(appearance.flavor, Flavor::Mocha);
|
||||
assert_eq!(appearance.accent, Accent::default());
|
||||
assert!(appearance.gradients);
|
||||
|
||||
std::fs::remove_dir_all(&temp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_latte_is_a_light_theme() {
|
||||
for flavor in Flavor::ALL {
|
||||
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId,
|
||||
use crate::naming::{self, ParsedName};
|
||||
|
||||
/// Bump when adding a migration.
|
||||
pub const SCHEMA_VERSION: i64 = 4;
|
||||
pub const SCHEMA_VERSION: i64 = 5;
|
||||
|
||||
const MIGRATION_V1: &str = r#"
|
||||
CREATE TABLE nodes (
|
||||
@@ -118,6 +118,16 @@ CREATE TABLE episode_metadata (
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Which rows the viewer chose themselves.
|
||||
///
|
||||
/// A column rather than a separate table of overrides, because it changes
|
||||
/// nothing about what the row *holds* — only about who put it there, and
|
||||
/// therefore whether a match run may replace it. See
|
||||
/// [`MetadataRecord::manual`].
|
||||
const MIGRATION_V5: &str = r#"
|
||||
ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0;
|
||||
"#;
|
||||
|
||||
/// What `season = -1` means in `episode_metadata`: no season at all.
|
||||
const ABSOLUTE_SEASON: i64 = -1;
|
||||
|
||||
@@ -219,6 +229,13 @@ impl Catalog {
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
if version < 5 {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATION_V5)?;
|
||||
tx.pragma_update(None, "user_version", 5)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -384,7 +401,8 @@ impl Catalog {
|
||||
pub fn all_metadata(&self) -> Result<HashMap<String, MetadataRecord>> {
|
||||
let mut statement = self.conn.prepare(
|
||||
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url
|
||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
||||
manual
|
||||
FROM title_metadata",
|
||||
)?;
|
||||
let rows = statement.query_map([], row_to_metadata)?;
|
||||
@@ -404,7 +422,8 @@ impl Catalog {
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url
|
||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
|
||||
url, manual
|
||||
FROM title_metadata WHERE title_key = ?1",
|
||||
params![title_key],
|
||||
row_to_metadata,
|
||||
@@ -422,8 +441,9 @@ impl Catalog {
|
||||
self.conn.execute(
|
||||
"INSERT INTO title_metadata (
|
||||
title_key, provider, matched, fetched_at, remote_id, name, original_name,
|
||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
|
||||
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
|
||||
manual
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
ON CONFLICT (title_key) DO UPDATE SET
|
||||
provider = excluded.provider,
|
||||
matched = excluded.matched,
|
||||
@@ -439,7 +459,8 @@ impl Catalog {
|
||||
rating = excluded.rating,
|
||||
genres = excluded.genres,
|
||||
episodes = excluded.episodes,
|
||||
url = excluded.url",
|
||||
url = excluded.url,
|
||||
manual = excluded.manual",
|
||||
params![
|
||||
record.title_key,
|
||||
record.provider.as_str(),
|
||||
@@ -457,11 +478,31 @@ impl Catalog {
|
||||
data.map(|data| data.genres.join("\n")),
|
||||
data.and_then(|data| data.episodes),
|
||||
data.and_then(|data| data.url.as_deref()),
|
||||
record.manual as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget one title's answer entirely, episodes included.
|
||||
///
|
||||
/// Different from storing a miss: a miss says "asked, nothing there" and is
|
||||
/// trusted for a while, where this leaves no row at all and the next match
|
||||
/// run treats the title as one it has never seen. That is what "match this
|
||||
/// one automatically again" has to mean after a hand-picked match, since a
|
||||
/// hand-picked one never expires.
|
||||
pub fn forget_metadata(&self, title_key: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM title_metadata WHERE title_key = ?1",
|
||||
params![title_key],
|
||||
)?;
|
||||
self.conn.execute(
|
||||
"DELETE FROM episode_metadata WHERE title_key = ?1",
|
||||
params![title_key],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget every stored answer.
|
||||
///
|
||||
/// What "match again" and "turn enrichment off" both do. Deliberately not
|
||||
@@ -618,6 +659,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
|
||||
provider,
|
||||
metadata,
|
||||
fetched_at,
|
||||
manual: row.get::<_, i64>(16)? != 0,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -893,6 +935,7 @@ mod tests {
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: 1_700_000_000,
|
||||
manual: false,
|
||||
};
|
||||
|
||||
catalog.set_metadata(&record).expect("store");
|
||||
@@ -907,6 +950,76 @@ mod tests {
|
||||
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
|
||||
}
|
||||
|
||||
/// A hand-picked match has to read back as hand-picked, or the next match
|
||||
/// run overwrites the one thing the viewer corrected themselves.
|
||||
#[test]
|
||||
fn a_hand_picked_match_says_so_when_it_is_read_back() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
let record = MetadataRecord {
|
||||
title_key: "fate stay night heavens feel".into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: 1_700_000_000,
|
||||
manual: true,
|
||||
};
|
||||
catalog.set_metadata(&record).expect("store");
|
||||
|
||||
let read = catalog.metadata(&record.title_key).expect("read");
|
||||
assert_eq!(read.as_ref().map(|read| read.manual), Some(true));
|
||||
assert_eq!(read, Some(record.clone()));
|
||||
|
||||
// And an automatic write over the top says so in turn: the flag is the
|
||||
// row's, not the title's.
|
||||
catalog
|
||||
.set_metadata(&MetadataRecord {
|
||||
manual: false,
|
||||
..record.clone()
|
||||
})
|
||||
.expect("store again");
|
||||
assert_eq!(
|
||||
catalog
|
||||
.metadata(&record.title_key)
|
||||
.expect("read")
|
||||
.map(|read| read.manual),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
/// Forgetting is not the same as storing a miss: it must leave no row at
|
||||
/// all, so the next match run treats the title as one it never asked about.
|
||||
#[test]
|
||||
fn forgetting_a_title_leaves_neither_its_match_nor_its_episodes() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.set_metadata(&MetadataRecord {
|
||||
title_key: "attack on titan".into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: 1,
|
||||
manual: true,
|
||||
})
|
||||
.expect("store");
|
||||
catalog
|
||||
.set_episode_metadata(
|
||||
"attack on titan",
|
||||
ProviderId::AniList,
|
||||
1,
|
||||
&[EpisodeMetadata {
|
||||
season: None,
|
||||
number: 1,
|
||||
name: Some("To You, in 2000 Years".into()),
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
}],
|
||||
)
|
||||
.expect("store episodes");
|
||||
|
||||
catalog.forget_metadata("attack on titan").expect("forget");
|
||||
assert!(catalog.metadata("attack on titan").expect("read").is_none());
|
||||
assert!(catalog.all_episode_metadata().expect("read").is_empty());
|
||||
}
|
||||
|
||||
/// The negative answer is the one that has to survive a round trip: without
|
||||
/// it every render re-asks about every unmatched title.
|
||||
#[test]
|
||||
@@ -917,6 +1030,7 @@ mod tests {
|
||||
provider: ProviderId::AniList,
|
||||
metadata: None,
|
||||
fetched_at: 1_700_000_000,
|
||||
manual: false,
|
||||
};
|
||||
catalog.set_metadata(&record).expect("store");
|
||||
|
||||
@@ -959,6 +1073,7 @@ mod tests {
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(found()),
|
||||
fetched_at: 1,
|
||||
manual: false,
|
||||
})
|
||||
.expect("store");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! how a share is opened and how its contents are modelled.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod appearance;
|
||||
pub mod catalog;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
|
||||
@@ -162,6 +162,19 @@ impl Title {
|
||||
self.episodes().filter(|e| e.is_watched()).count()
|
||||
}
|
||||
|
||||
/// Whether the files themselves say this is episodic, rather than
|
||||
/// [`Title::kind`] having been inferred from how many of them there are.
|
||||
///
|
||||
/// The difference matters to whoever is matching this against a provider: a
|
||||
/// folder of three unnumbered films is a `Series` here purely because it
|
||||
/// holds three files, and it must not be scored down against the film
|
||||
/// entries it is actually made of. Numbering is a statement; a file count
|
||||
/// is a guess.
|
||||
pub fn states_its_numbering(&self) -> bool {
|
||||
self.episodes()
|
||||
.any(|episode| episode.node.parsed.is_episode() || episode.node.parsed.season.is_some())
|
||||
}
|
||||
|
||||
/// The file to pull a poster thumbnail from: the first episode there is.
|
||||
/// Proton renders a thumbnail per file, so any of them is a frame of the
|
||||
/// right show; the first is the one most likely already cached.
|
||||
@@ -288,9 +301,7 @@ impl Library {
|
||||
for title in &mut titles {
|
||||
// A season number, an episode number or more than one file all mean
|
||||
// series. A lone unnumbered file is a film.
|
||||
let numbered = title.episodes().any(|episode| {
|
||||
episode.node.parsed.is_episode() || episode.node.parsed.season.is_some()
|
||||
});
|
||||
let numbered = title.states_its_numbering();
|
||||
title.kind = if numbered || title.episode_count() > 1 {
|
||||
TitleKind::Series
|
||||
} else {
|
||||
@@ -357,10 +368,19 @@ impl Library {
|
||||
|
||||
/// The grouping key: case- and punctuation-insensitive, so `Fullmetal
|
||||
/// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title.
|
||||
///
|
||||
/// An apostrophe is the one punctuation mark that closes up rather than
|
||||
/// separating: `Heaven's Feel` keys as `heavens feel`, which is what a folder
|
||||
/// that had to give its apostrophe up to the filesystem is called. Splitting it
|
||||
/// into `heaven s feel` instead files the two apart and, worse, costs the
|
||||
/// matcher a real match on the difference.
|
||||
pub fn title_key(title: &str) -> String {
|
||||
let mut key = String::with_capacity(title.len());
|
||||
let mut pending_space = false;
|
||||
for character in title.chars() {
|
||||
if matches!(character, '\'' | '\u{2019}' | '\u{02bc}') {
|
||||
continue;
|
||||
}
|
||||
if character.is_alphanumeric() {
|
||||
if pending_space && !key.is_empty() {
|
||||
key.push(' ');
|
||||
@@ -635,6 +655,16 @@ mod tests {
|
||||
assert_eq!(title_key("K-On!!"), "k on");
|
||||
}
|
||||
|
||||
/// An apostrophe closes up rather than separating, and a name written to
|
||||
/// disk has usually lost it — the two have to key alike, or a folder called
|
||||
/// `Heavens Feel` is a different title to the one the provider has.
|
||||
#[test]
|
||||
fn an_apostrophe_keys_the_same_as_its_absence() {
|
||||
assert_eq!(title_key("Heaven's Feel"), "heavens feel");
|
||||
assert_eq!(title_key("Heaven\u{2019}s Feel"), title_key("Heavens Feel"));
|
||||
assert_eq!(title_key("Devils' Line"), title_key("Devils Line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_not_episodes() {
|
||||
let mut folder = node("s", "1", "Show", None, None);
|
||||
|
||||
@@ -250,6 +250,13 @@ pub struct MetadataRecord {
|
||||
pub metadata: Option<TitleMetadata>,
|
||||
/// Unix seconds. What makes a miss expire rather than being forever.
|
||||
pub fetched_at: i64,
|
||||
/// The viewer picked this entry themselves.
|
||||
///
|
||||
/// A hand-picked match outranks anything the matcher would decide: it never
|
||||
/// expires, and "match again" leaves it alone. Otherwise the one title the
|
||||
/// scorer gets wrong — the case this flag exists for — would be corrected by
|
||||
/// hand and then quietly un-corrected by the next run.
|
||||
pub manual: bool,
|
||||
}
|
||||
|
||||
/// How long a stored answer is trusted.
|
||||
@@ -267,6 +274,11 @@ impl MetadataRecord {
|
||||
if self.provider != provider {
|
||||
return false;
|
||||
}
|
||||
// A hand-picked match does not go stale: nothing a re-ask could learn
|
||||
// would be better than what the viewer already said it was.
|
||||
if self.manual {
|
||||
return true;
|
||||
}
|
||||
let ttl = if self.metadata.is_some() {
|
||||
MATCH_TTL_SECS
|
||||
} else {
|
||||
@@ -425,6 +437,7 @@ mod tests {
|
||||
provider: ProviderId::AniList,
|
||||
metadata: None,
|
||||
fetched_at: now - MISS_TTL_SECS - 1,
|
||||
manual: false,
|
||||
};
|
||||
assert!(!miss.is_fresh(now, ProviderId::AniList));
|
||||
|
||||
@@ -435,6 +448,22 @@ mod tests {
|
||||
assert!(matched.is_fresh(now, ProviderId::AniList));
|
||||
}
|
||||
|
||||
/// What the viewer picked by hand outlives every TTL: it is not an answer
|
||||
/// that could go stale, it is an instruction.
|
||||
#[test]
|
||||
fn a_hand_picked_match_never_expires() {
|
||||
let record = MetadataRecord {
|
||||
title_key: "k".into(),
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(metadata()),
|
||||
fetched_at: 0,
|
||||
manual: true,
|
||||
};
|
||||
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList));
|
||||
// But it is still one provider's id, and it means nothing to the other.
|
||||
assert!(!record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::Tmdb));
|
||||
}
|
||||
|
||||
/// Switching provider must re-ask rather than trust the other one's answer —
|
||||
/// especially its silence.
|
||||
#[test]
|
||||
@@ -444,6 +473,7 @@ mod tests {
|
||||
provider: ProviderId::AniList,
|
||||
metadata: Some(metadata()),
|
||||
fetched_at: 1_000_000_000,
|
||||
manual: false,
|
||||
};
|
||||
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
|
||||
}
|
||||
|
||||
@@ -345,9 +345,7 @@ fn ordinal_before(lower: &str, at: usize) -> Option<(u32, usize)> {
|
||||
.into_iter()
|
||||
.find_map(|suffix| head.strip_suffix(suffix))?;
|
||||
|
||||
let digits_start = head
|
||||
.rfind(|c: char| !c.is_ascii_digit())
|
||||
.map_or(0, |index| index + 1);
|
||||
let digits_start = trailing_digits_start(head).unwrap_or(0);
|
||||
let digits = &head[digits_start..];
|
||||
if digits.is_empty() || digits.len() > 2 {
|
||||
return None;
|
||||
@@ -596,6 +594,26 @@ fn read_number(text: &str, from: usize) -> Option<(u32, usize)> {
|
||||
text[from..end].parse().ok().map(|n| (n, end))
|
||||
}
|
||||
|
||||
/// Where the trailing run of ASCII digits starts, or `None` when the text is
|
||||
/// nothing but digits (or empty).
|
||||
///
|
||||
/// Walked back by `char_indices`, not by `rfind(…) + 1`: one past the *start*
|
||||
/// of the character before the digits is inside it whenever that character is
|
||||
/// multi-byte. `Death (True)² - 007` is the case that proves it — the `+ 1`
|
||||
/// form cuts the `²` in half and panics.
|
||||
fn trailing_digits_start(text: &str) -> Option<usize> {
|
||||
let mut start = None;
|
||||
|
||||
for (index, ch) in text.char_indices().rev() {
|
||||
if ch.is_ascii_digit() {
|
||||
start = Some(index);
|
||||
} else {
|
||||
return Some(start.unwrap_or(text.len()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_token_char(byte: Option<u8>) -> bool {
|
||||
byte.is_some_and(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
@@ -715,7 +733,7 @@ pub fn classify_folder(name: &str) -> FolderRole {
|
||||
/// `Evangelion 3`, `Kaijuu 8-gou` — would lose it to a season it never claimed.
|
||||
fn trailing_season(name: &str) -> Option<(&str, u32)> {
|
||||
let trimmed = name.trim_end();
|
||||
let digits_start = trimmed.rfind(|c: char| !c.is_ascii_digit())? + 1;
|
||||
let digits_start = trailing_digits_start(trimmed)?;
|
||||
let (head, digits) = trimmed.split_at(digits_start);
|
||||
let season: u32 = digits.parse().ok()?;
|
||||
|
||||
@@ -910,6 +928,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A panic, not a mis-parse.** A folder name whose trailing digits sit
|
||||
/// directly behind a multi-byte character — or that simply *ends* in one —
|
||||
/// used to be split one byte past that character's start, i.e. inside it.
|
||||
#[test]
|
||||
fn a_multi_byte_character_does_not_break_folder_classification() {
|
||||
for name in [
|
||||
"Neon Genesis Evangelion Death (True)\u{b2}",
|
||||
"Show\u{b2}3",
|
||||
"Show\u{b2}3rd Season",
|
||||
"Caf\u{e9} Season 2",
|
||||
"\u{b2}",
|
||||
] {
|
||||
// The classification itself is the assertion: any of these used to
|
||||
// panic before it could return one.
|
||||
let _ = classify_folder(name);
|
||||
}
|
||||
|
||||
let FolderRole::Title(parsed) = classify_folder("Caf\u{e9} Season 2") else {
|
||||
panic!("expected a title");
|
||||
};
|
||||
assert_eq!((parsed.title.as_str(), parsed.season), ("Café", Some(2)));
|
||||
}
|
||||
|
||||
/// A resolution is four digits in brackets too, and must not read as a year.
|
||||
#[test]
|
||||
fn a_bracketed_resolution_is_not_mistaken_for_a_year() {
|
||||
|
||||
Reference in new issue
Block a user