bug fixes, windows build

This commit is contained in:
nvrl committed 2026-08-03 09:09:40 +02:00
1 parent a1b345a029
commit 14c48428a3
29 files changed
+2514 -170

No files matched your search

+14
View File
@@ -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"] }
+212
View File
@@ -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);
}
}
}
+121 -6
View File
@@ -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");
+1
View File
@@ -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;
+33 -3
View File
@@ -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);
+30
View File
@@ -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));
}
+45 -4
View File
@@ -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() {