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

+3
View File
@@ -14,6 +14,9 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
# The per-platform keystore feature comes from `pstr-core` above — cargo unifies
# it across the graph, and `pstr-core` is a hard dependency here. Do not enable
# `keyring` without it: the feature-less build resolves to the `mock` store.
keyring.workspace = true
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
# binary means two TLS stacks linked into it.
+115 -12
View File
@@ -85,19 +85,28 @@ impl Provider for AniList {
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
let data: Option<SearchData> = self
.query(serde_json::json!({
"query": SEARCH,
"variables": { "search": query.name, "perPage": PAGE_SIZE },
}))
.await?;
// Each term is asked only because the one before it came back with
// nothing at all — see `search_terms`. A page of candidates, however
// weak, is the matcher's business rather than this one's.
for term in search_terms(&query.name) {
let data: Option<SearchData> = self
.query(serde_json::json!({
"query": SEARCH,
"variables": { "search": term, "perPage": PAGE_SIZE },
}))
.await?;
Ok(data
.map(|data| data.page.media)
.unwrap_or_default()
.into_iter()
.map(Media::into_candidate)
.collect())
let candidates: Vec<Candidate> = data
.map(|data| data.page.media)
.unwrap_or_default()
.into_iter()
.map(Media::into_candidate)
.collect();
if !candidates.is_empty() {
return Ok(candidates);
}
}
Ok(Vec::new())
}
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
@@ -157,6 +166,61 @@ impl AniList {
}
}
/// The search strings to try, in order, until one of them returns anything.
///
/// AniList does not do fuzzy search. Every word of the query has to be a word
/// its index actually holds — only the *last* one is matched as a prefix — and
/// all of them have to match, so a single word AniList has never seen empties
/// the whole result set. `ghost in the shel` finds the film; `cowbo bebop`
/// finds nothing.
///
/// That turns one filesystem habit into a total miss: a name written to disk
/// has had its apostrophes taken out, because plenty of tools and shares still
/// dislike them. `Fate/stay night [Heaven's Feel]` indexes the words `heaven`
/// and `s`, and the folder is called `Fate stay night Heavens Feel` — whose
/// `heavens` is not a word in the index and not a prefix of one either. AniList
/// answers with an empty page, the title is stored as a miss, and it stays
/// unmatched for as long as the record is fresh.
///
/// So the fallback puts the apostrophe back the only way that survives
/// tokenisation: by dropping the `s` it was holding on to. It is asked for only
/// after the name as written found nothing, which is what keeps it from
/// broadening a search that was already working.
fn search_terms(name: &str) -> Vec<String> {
let mut terms = vec![name.to_string()];
let depossessed = without_possessive_s(name);
if depossessed != name {
terms.push(depossessed);
}
terms
}
/// `Heavens Feel` → `Heaven Feel`.
///
/// Only words long enough to still mean something without it, and only where
/// the `s` follows a letter — a word that kept its apostrophe (`Heaven's`) is
/// already what the index holds, and cutting the `s` off it would leave the
/// apostrophe behind as its own token.
fn without_possessive_s(name: &str) -> String {
name.split(' ')
.map(|word| {
let mut characters = word.chars().rev();
let last = characters.next();
let previous = characters.next();
let long_enough = word.chars().count() >= 4;
match (last, previous) {
(Some('s' | 'S'), Some(previous)) if long_enough && previous.is_alphabetic() => {
&word[..word.len() - 1]
}
_ => word,
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Deserialize)]
struct Response<T> {
data: Option<T>,
@@ -402,6 +466,45 @@ mod tests {
assert_eq!(kind_of(None), TitleKind::Film);
}
/// The miss this exists for: a folder named after a title whose apostrophe
/// the filesystem never got, searched against an index that tokenised the
/// apostrophe into a word boundary.
#[test]
fn a_name_that_lost_its_apostrophe_is_searched_for_again_without_the_s() {
assert_eq!(
search_terms("Fate stay night Heavens Feel"),
vec![
"Fate stay night Heavens Feel".to_string(),
"Fate stay night Heaven Feel".to_string(),
]
);
}
/// One request, not two, for the names that need no repair — the fallback
/// is a second round trip and a title that already matched must not pay it.
#[test]
fn a_name_with_nothing_to_repair_is_searched_for_once() {
assert_eq!(search_terms("Cowboy Bebop"), vec!["Cowboy Bebop"]);
// Short words keep their `s`: `Kids on the Slope` is not `Kid on the
// Slope`, and a name that still has its apostrophe is already what the
// index holds.
assert_eq!(search_terms("Heaven's Feel"), vec!["Heaven's Feel"]);
assert_eq!(
search_terms("Kids on the Bus"),
vec!["Kids on the Bus".to_string(), "Kid on the Bus".to_string()],
"a four-letter word is long enough; a three-letter one is not"
);
}
/// Byte slicing, on names that are not ASCII.
#[test]
fn a_multi_byte_name_is_not_cut_inside_a_character() {
for name in ["進撃の巨人", "Kaguya-sama: Love is War?", "café"] {
let terms = search_terms(name);
assert_eq!(terms[0], name);
}
}
#[test]
fn markup_becomes_text_and_breaks_become_newlines() {
assert_eq!(
+61 -2
View File
@@ -38,6 +38,15 @@ pub struct Query {
pub name: String,
pub year: Option<u32>,
pub kind: TitleKind,
/// Whether the library *knows* what kind of thing this is.
///
/// The mirror of [`Candidate::kind_known`], and it exists for the same
/// reason. A folder holding the three `Heaven's Feel` films is a `Series`
/// to [`pstr_core::library`] only because it holds three files — the films
/// themselves are numbered nothing — and scoring that guess against the
/// provider's (correct) `Film` costs the right answer the 0.05 that puts it
/// under the floor.
pub kind_known: bool,
}
impl Query {
@@ -46,8 +55,16 @@ impl Query {
name: name.into(),
year,
kind,
kind_known: true,
}
}
/// Say that [`Query::kind`] was inferred rather than stated — see
/// [`Query::kind_known`].
pub fn with_guessed_kind(mut self) -> Self {
self.kind_known = false;
self
}
}
/// One of a provider's answers, with every name it is known by.
@@ -93,10 +110,11 @@ pub fn score(query: &Query, candidate: &Candidate) -> f32 {
_ => {}
}
if candidate.kind_known && query.kind != candidate.metadata.kind {
if query.kind_known && candidate.kind_known && query.kind != candidate.metadata.kind {
// A film and a series of the same name are usually genuinely related —
// an adaptation, a compilation — so this is a nudge, not a veto. And
// only when the provider said; see `Candidate::kind_known`.
// only when both sides actually said; see `Candidate::kind_known` and
// `Query::kind_known`.
score -= 0.05;
}
@@ -353,6 +371,47 @@ mod tests {
assert!(score(&query, &unstated) > score(&query, &stated));
assert_eq!(score(&query, &unstated), 1.0);
// And the same in reverse: a library that guessed its own kind has
// nothing to disagree with the provider about.
let guessed = query.clone().with_guessed_kind();
assert_eq!(score(&guessed, &stated), 1.0);
}
/// **The apostrophe case, both shapes of it.** A folder cannot carry the
/// apostrophe of `Fate/stay night [Heaven's Feel]`, so it is called
/// `Heavens Feel` — and until [`title_key`] closed the apostrophe up rather
/// than splitting on it, the provider's own entry scored under the floor.
///
/// One film per folder matches outright. The trilogy in a single folder is
/// the harder half: the library calls three files a series where the
/// provider calls each of them a film, so it clears the floor by a hair
/// with that mismatch subtracted.
#[test]
fn a_title_that_lost_its_apostrophe_matches_the_entry_that_kept_one() {
let film = candidate(
"Fate/stay night [Heaven's Feel] I. presage flower",
&[],
Some(2017),
TitleKind::Film,
);
let alone = Query::new(
"Fate stay night Heavens Feel I Presage Flower",
Some(2017),
TitleKind::Film,
);
assert!(score(&alone, &film) >= 1.0);
// Three films in one folder: a `Series` only because there are three of
// them, so the kind is a guess and costs the film entry nothing.
let trilogy =
Query::new("Fate stay night Heavens Feel", None, TitleKind::Series).with_guessed_kind();
assert!(
score(&trilogy, &film) >= MATCH_FLOOR,
"scored {}",
score(&trilogy, &film)
);
}
/// The tie-break must not override the score: a later candidate that
+55 -2
View File
@@ -22,7 +22,7 @@
use std::sync::Arc;
use pstr_core::library::{Title, title_key};
use pstr_core::library::{Title, TitleKind, title_key};
use pstr_core::metadata::{
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
};
@@ -124,7 +124,12 @@ impl MetadataService {
/// `Ok(None)` is a real answer — nothing matched well enough — and the
/// caller should store it. An `Err` is not, and the caller should not.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let query = Query::new(title.name.clone(), title.year, title.kind);
let mut query = Query::new(title.name.clone(), title.year, title.kind);
// Nothing in the files numbered itself, so the kind came from counting
// them — see `Query::kind_known`.
if !title.states_its_numbering() {
query = query.with_guessed_kind();
}
let candidates = self.source.search(&query).await?;
let found = candidates.len();
let best = matching::best(&query, candidates);
@@ -149,9 +154,56 @@ impl MetadataService {
provider: self.provider(),
metadata,
fetched_at: now(),
manual: false,
})
}
/// Everything the provider thinks `name` might be, unscored and in its own
/// order.
///
/// The escape hatch from [`matching::best`], for a viewer picking an entry
/// by hand. Nothing is filtered here and nothing is ranked: the floor exists
/// to stop the *matcher* guessing, and a person reading the list is not
/// guessing. The `Fate/stay night [Heaven's Feel]` trilogy is the case that
/// wants it — three films in one folder, matched against a provider that
/// files each of them separately, where no single entry is the right answer
/// and only the viewer knows which one they meant.
///
/// Takes a kind because the providers key their search on it, and no year:
/// the point of a hand search is that the library's own guesses are what
/// went wrong.
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> {
let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
if query.name.is_empty() {
return Ok(Vec::new());
}
let candidates = self.source.search(&query).await?;
tracing::debug!(
"hand search {:?}: {} candidates from {}",
query.name,
candidates.len(),
self.provider().label()
);
Ok(candidates
.into_iter()
.map(|candidate| candidate.metadata)
.collect())
}
/// A record for an entry the viewer picked themselves.
///
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run
/// — including a forced one — from undoing it.
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
MetadataRecord {
title_key,
provider: self.provider(),
metadata: Some(found),
fetched_at: now(),
manual: true,
}
}
/// What the provider lists as the episodes of a title it matched.
///
/// Only ever called for a title that already has a match, because the
@@ -339,6 +391,7 @@ mod tests {
provider: ProviderId::AniList,
metadata: None,
fetched_at: now(),
manual: false,
};
assert!(is_usable(Some(&record), ProviderId::AniList));
assert!(!is_usable(Some(&record), ProviderId::Tmdb));