mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +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
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user