feat(core,meta,app,android,ios,cli): TVmaze for the shows AniList does not have

With AniList chosen, a title AniList has no match for is now searched on
TVmaze, keyless, before it is given up on. One provider for the whole library
left live-action shows in a share of anime unmatched, and switching to TMDB
would have dropped every AniList match, chain and shelf.

The service holds a chain of providers. A match is stored under the provider
that gave it, and enrichment and refreshes go to that provider; a miss is
stored under the last provider asked, and is_fresh only trusts a miss from the
end of the chain, so AniList misses stored before this are asked again at once.
A provider failure still ends the lookup instead of falling through.

The hand matcher searches every provider in the chain and labels each result
with its source. TVmaze is never offered in the settings and is not asked
about titles known to be films.
This commit is contained in:
nvrl committed 2026-10-29 16:33:16 +01:00
1 parent bb5a6638c4
commit 3ca431feca
18 files changed
+867 -170

No files matched your search

@@ -438,16 +438,16 @@ private fun MetadataSettingsDialog(
title = { Text("Metadata enrichment") }, title = { Text("Metadata enrichment") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.") Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
SettingToggle("Enable enrichment", enabled) { enabled = it } SettingToggle("Enable enrichment", enabled) { enabled = it }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetadataProvider.entries.forEach { option -> listOf(MetadataProvider.ANI_LIST, MetadataProvider.TMDB).forEach { option ->
if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) } if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) } else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
} }
} }
Text( Text(
if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required." if (provider == MetadataProvider.ANI_LIST) "Anime, and television from TVmaze; no account or API key required."
else "Film and television; requires a free TMDB API key.", else "Film and television; requires a free TMDB API key.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -497,6 +497,7 @@ private fun MetadataSettingsDialog(
internal fun MetadataProvider.displayName(): String = when (this) { internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList" MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB" MetadataProvider.TMDB -> "TMDB"
MetadataProvider.TV_MAZE -> "TVmaze"
} }
private data class LegalDocument(val title: String, val assetPath: String) private data class LegalDocument(val title: String, val assetPath: String)
@@ -759,7 +759,7 @@ internal fun ChangeMatchDialog(
title = { Text("Change match") }, title = { Text("Change match") },
text = { text = {
Column { Column {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.") Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true) OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = { AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true searching = true
@@ -794,7 +794,10 @@ internal fun ChangeMatchDialog(
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Text(option.name, fontWeight = FontWeight.SemiBold) Text(option.name, fontWeight = FontWeight.SemiBold)
Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · ")) Text(
listOfNotNull(option.year?.toString(), option.originalName, option.provider.displayName())
.joinToString(" · "),
)
} }
} }
} }
+9 -12
View File
@@ -304,6 +304,9 @@ pub enum TitleType {
pub enum MetadataProvider { pub enum MetadataProvider {
AniList, AniList,
Tmdb, Tmdb,
/// Never chosen in the settings: what AniList hands a show it does not
/// have. Seen on a [`MatchRecord`] from a hand search.
TvMaze,
} }
/// A palette family. The shared `pstr_core::appearance::Flavor`. /// A palette family. The shared `pstr_core::appearance::Flavor`.
@@ -1746,7 +1749,7 @@ impl AndroidEngine {
spawned(&runtime, async move { spawned(&runtime, async move {
let service = self.metadata_service()?; let service = self.metadata_service()?;
let metadata = title_metadata(found); let metadata = title_metadata(found);
if metadata.provider != service.provider() { if !service.chain().contains(&metadata.provider) {
return Err(BridgeError::Failure { return Err(BridgeError::Failure {
reason: "match came from a different metadata provider".to_owned(), reason: "match came from a different metadata provider".to_owned(),
}); });
@@ -1762,7 +1765,7 @@ impl AndroidEngine {
if let Some(enrichment) = &outcome.enrichment { if let Some(enrichment) = &outcome.enrichment {
let stored = self.catalog.lock().set_enrichment( let stored = self.catalog.lock().set_enrichment(
&title_key, &title_key,
service.provider(), outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -2363,21 +2366,13 @@ impl AndroidEngine {
/// behaviour. /// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> { async fn match_titles_inner(&self, force: bool) -> Result<MatchSummary, BridgeError> {
let service = Arc::new(self.metadata_service()?); let service = Arc::new(self.metadata_service()?);
let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?; let (titles, stored) = self.titles_and_metadata()?;
let enriched = self let enriched = self
.catalog .catalog
.lock() .lock()
.enrichment_ages() .enrichment_ages()
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let pending = pstr_meta::service::plan( let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)); let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new(); let mut lookups = tokio::task::JoinSet::new();
@@ -2409,7 +2404,7 @@ impl AndroidEngine {
summary.episodes += enrichment.episodes.len() as u32; summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment( let stored = self.catalog.lock().set_enrichment(
&outcome.title_key, &outcome.title_key,
provider, outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -3685,6 +3680,7 @@ fn metadata_provider(provider: ProviderId) -> MetadataProvider {
match provider { match provider {
ProviderId::AniList => MetadataProvider::AniList, ProviderId::AniList => MetadataProvider::AniList,
ProviderId::Tmdb => MetadataProvider::Tmdb, ProviderId::Tmdb => MetadataProvider::Tmdb,
ProviderId::TvMaze => MetadataProvider::TvMaze,
} }
} }
@@ -3692,6 +3688,7 @@ fn provider_id(provider: MetadataProvider) -> ProviderId {
match provider { match provider {
MetadataProvider::AniList => ProviderId::AniList, MetadataProvider::AniList => ProviderId::AniList,
MetadataProvider::Tmdb => ProviderId::Tmdb, MetadataProvider::Tmdb => ProviderId::Tmdb,
MetadataProvider::TvMaze => ProviderId::TvMaze,
} }
} }
+4 -13
View File
@@ -2605,8 +2605,6 @@ impl Engine {
} }
return self.emit(Event::Error(self.no_service_reason())); return self.emit(Event::Error(self.no_service_reason()));
}; };
let provider = service.provider();
let (stored, enriched) = { let (stored, enriched) = {
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
match catalog match catalog
@@ -2618,14 +2616,7 @@ impl Engine {
} }
}; };
let pending = pstr_meta::service::plan( let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
titles,
&stored,
&enriched,
provider,
service.has_details(),
force,
);
if pending.is_empty() { if pending.is_empty() {
if automatic { if automatic {
return; return;
@@ -2636,7 +2627,7 @@ impl Engine {
self.emit(Event::Status(format!( self.emit(Event::Status(format!(
"matching {} titles against {}…", "matching {} titles against {}…",
pending.len(), pending.len(),
provider.label() service.label()
))); )));
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize); let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
@@ -2673,7 +2664,7 @@ impl Engine {
episodes_found += enrichment.episodes.len(); episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_enrichment( let stored = self.catalog.lock().set_enrichment(
&outcome.title_key, &outcome.title_key,
provider, outcome.provider,
now(), now(),
enrichment, enrichment,
); );
@@ -2755,7 +2746,7 @@ impl Engine {
if let Some(enrichment) = &outcome.enrichment { if let Some(enrichment) = &outcome.enrichment {
let stored = engine.catalog.lock().set_enrichment( let stored = engine.catalog.lock().set_enrichment(
&title.key, &title.key,
service.provider(), outcome.provider,
now(), now(),
enrichment, enrichment,
); );
+6 -4
View File
@@ -55,8 +55,8 @@ pub fn show(
.color(theme::text()), .color(theme::text()),
); );
ui.label(ui::muted(format!( ui.label(ui::muted(format!(
"Everything {} answers with, in its own order — nothing is scored here.", "Everything {} answer with, in their own order — nothing is scored here.",
provider.label() pstr_meta::service::chain_label(provider)
))); )));
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
@@ -279,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandl
.texture(&key, || engine.request_poster(key.clone(), url)) .texture(&key, || engine.request_poster(key.clone(), url))
} }
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out /// `Film · 2017 · 26 episodes · ★ 8.2 · AniList`, with whatever the provider
/// simply absent. /// left out simply absent. The provider last, because one search asks more
/// than one, and the same show can come back from each.
fn facts(found: &TitleMetadata) -> String { fn facts(found: &TitleMetadata) -> String {
let mut parts: Vec<String> = vec![ let mut parts: Vec<String> = vec![
match found.kind { match found.kind {
@@ -298,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
} }
parts.push(found.provider.label().to_string());
parts.join(" · ") parts.join(" · ")
} }
+3 -3
View File
@@ -874,8 +874,8 @@ fn metadata_form(
without this every tile is a pair of initials. Turning it on sends the titles \ without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \ in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears. With AniList, \ the provider you choose, over HTTPS, each time a new one appears. With AniList, \
the ids it matched — not the titles — then go to ani.zip for episode names and \ a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
wide artwork.", not the titles — go to ani.zip for episode names and wide artwork.",
)); ));
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
@@ -889,7 +889,7 @@ fn metadata_form(
if settings.enabled { if settings.enabled {
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
ui.label(ui::muted("Provider")); ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL { for provider in ProviderId::CHOOSABLE {
if ui if ui
.radio(settings.provider == provider, provider.label()) .radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description()) .on_hover_text(provider.description())
+19 -10
View File
@@ -575,10 +575,14 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
api_key, api_key,
} => { } => {
if let Some(name) = provider { if let Some(name) = provider {
settings.provider = ProviderId::parse(&name).ok_or_else(|| { settings.provider = ProviderId::parse(&name)
.filter(|provider| ProviderId::CHOOSABLE.contains(provider))
.ok_or_else(|| {
anyhow::anyhow!( anyhow::anyhow!(
"unknown provider {name:?}; try one of: {}", "unknown provider {name:?}; try one of: {}",
ProviderId::ALL.map(|provider| provider.as_str()).join(", ") ProviderId::CHOOSABLE
.map(|provider| provider.as_str())
.join(", ")
) )
})?; })?;
} }
@@ -606,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// `Engine::set_metadata_config`. // `Engine::set_metadata_config`.
if off || settings.enabled { if off || settings.enabled {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?; let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let chain = pstr_meta::service::chain_of(settings.provider);
let stale = catalog let stale = catalog
.all_metadata()? .all_metadata()?
.values() .values()
.any(|record| record.provider != settings.provider); .any(|record| !chain.contains(&record.provider));
if off || stale { if off || stale {
catalog.clear_metadata()?; catalog.clear_metadata()?;
println!("cleared the stored answers"); println!("cleared the stored answers");
@@ -627,7 +632,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
anyhow::bail!( anyhow::bail!(
"lookups are off — run `pstr metadata set --on` first.\n\ "lookups are off — run `pstr metadata set --on` first.\n\
Matching sends the titles in your catalog to {}.", Matching sends the titles in your catalog to {}.",
settings.provider.label() pstr_meta::service::chain_label(settings.provider)
); );
} }
@@ -646,8 +651,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
library.titles, library.titles,
&stored, &stored,
&enriched, &enriched,
settings.provider, &service.chain(),
service.has_details(),
force, force,
); );
if let Some(limit) = limit { if let Some(limit) = limit {
@@ -661,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
println!( println!(
"looking up {} titles against {}…", "looking up {} titles against {}…",
pending.len(), pending.len(),
settings.provider.label() service.label()
); );
// Serial, not concurrent: both providers rate-limit by the minute, // Serial, not concurrent: both providers rate-limit by the minute,
@@ -691,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
Some(found) => { Some(found) => {
matched += 1; matched += 1;
println!( println!(
" {name:<40} → {} ({})", " {name:<40} → {} ({}){}",
found.name, found.name,
found found
.year .year
.map(|year| year.to_string()) .map(|year| year.to_string())
.unwrap_or_else(|| "-".into()) .unwrap_or_else(|| "-".into()),
if found.provider == service.provider() {
String::new()
} else {
format!(" on {}", found.provider.label())
}
); );
} }
None => println!(" {name:<40} → no match"), None => println!(" {name:<40} → no match"),
@@ -717,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
); );
catalog.set_enrichment( catalog.set_enrichment(
&outcome.title_key, &outcome.title_key,
settings.provider, outcome.provider,
unix_now(), unix_now(),
enrichment, enrichment,
)?; )?;
+54 -10
View File
@@ -39,15 +39,23 @@ pub enum ProviderId {
AniList, AniList,
/// The Movie Database. Film and television, needs a free API key. /// The Movie Database. Film and television, needs a free API key.
Tmdb, Tmdb,
/// TVmaze. Television, no API key. Never chosen on its own: it answers
/// for what AniList has not got — see `pstr_meta::service`.
TvMaze,
} }
impl ProviderId { impl ProviderId {
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb]; /// Every provider a stored row can name.
pub const ALL: [Self; 3] = [Self::AniList, Self::Tmdb, Self::TvMaze];
/// The ones a viewer picks between in the settings.
pub const CHOOSABLE: [Self; 2] = [Self::AniList, Self::Tmdb];
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
Self::AniList => "anilist", Self::AniList => "anilist",
Self::Tmdb => "tmdb", Self::Tmdb => "tmdb",
Self::TvMaze => "tvmaze",
} }
} }
@@ -56,6 +64,7 @@ impl ProviderId {
match self { match self {
Self::AniList => "AniList", Self::AniList => "AniList",
Self::Tmdb => "TMDB", Self::Tmdb => "TMDB",
Self::TvMaze => "TVmaze",
} }
} }
@@ -64,9 +73,11 @@ impl ProviderId {
match self { match self {
Self::AniList => { Self::AniList => {
"Anime, with no API key and no account. Episode names and wide art come from \ "Anime, with no API key and no account. Episode names and wide art come from \
ani.zip, which is sent the matched ids." ani.zip, which is sent the matched ids. Shows AniList does not have are \
looked up on TVmaze."
} }
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.", Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
Self::TvMaze => "Television, with no API key and no account.",
} }
} }
@@ -528,8 +539,13 @@ pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
impl MetadataRecord { impl MetadataRecord {
/// Whether this answer is still worth using rather than asking again. /// Whether this answer is still worth using rather than asking again.
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool { ///
if self.provider != provider { /// `chain` is the providers asked, in order. A match from any of them
/// stands. A miss is stored under the last one asked, and only stands for
/// a chain that ends there: one an earlier provider stored was never
/// followed by the providers after it.
pub fn is_fresh(&self, now: i64, chain: &[ProviderId]) -> bool {
if !chain.contains(&self.provider) {
return false; return false;
} }
// A hand-picked match does not go stale: nothing a re-ask could learn // A hand-picked match does not go stale: nothing a re-ask could learn
@@ -539,8 +555,10 @@ impl MetadataRecord {
} }
let ttl = if self.metadata.is_some() { let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS MATCH_TTL_SECS
} else { } else if chain.last() == Some(&self.provider) {
MISS_TTL_SECS MISS_TTL_SECS
} else {
return false;
}; };
now.saturating_sub(self.fetched_at) < ttl now.saturating_sub(self.fetched_at) < ttl
} }
@@ -786,13 +804,13 @@ mod tests {
fetched_at: now - MISS_TTL_SECS - 1, fetched_at: now - MISS_TTL_SECS - 1,
manual: false, manual: false,
}; };
assert!(!miss.is_fresh(now, ProviderId::AniList)); assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
let matched = MetadataRecord { let matched = MetadataRecord {
metadata: Some(metadata()), metadata: Some(metadata()),
..miss.clone() ..miss.clone()
}; };
assert!(matched.is_fresh(now, ProviderId::AniList)); assert!(matched.is_fresh(now, &[ProviderId::AniList]));
} }
/// What the viewer picked by hand outlives every TTL: it is not an answer /// What the viewer picked by hand outlives every TTL: it is not an answer
@@ -806,9 +824,9 @@ mod tests {
fetched_at: 0, fetched_at: 0,
manual: true, manual: true,
}; };
assert!(record.is_fresh(MATCH_TTL_SECS * 100, ProviderId::AniList)); 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. // 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)); assert!(!record.is_fresh(MATCH_TTL_SECS * 100, &[ProviderId::Tmdb]));
} }
/// Switching provider must re-ask rather than trust the other one's answer — /// Switching provider must re-ask rather than trust the other one's answer —
@@ -822,6 +840,32 @@ mod tests {
fetched_at: 1_000_000_000, fetched_at: 1_000_000_000,
manual: false, manual: false,
}; };
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb)); assert!(!record.is_fresh(1_000_000_000, &[ProviderId::Tmdb]));
}
/// AniList's miss was stored before TVmaze followed it, and so says
/// nothing about what TVmaze has; TVmaze's miss, last in the chain, does.
/// A match from either stands.
#[test]
fn a_miss_only_stands_when_the_last_provider_asked_stored_it() {
let chain = [ProviderId::AniList, ProviderId::TvMaze];
let now = 1_000_000_000;
let miss = |provider| MetadataRecord {
title_key: "game of thrones".into(),
provider,
metadata: None,
fetched_at: now,
manual: false,
};
assert!(!miss(ProviderId::AniList).is_fresh(now, &chain));
assert!(miss(ProviderId::TvMaze).is_fresh(now, &chain));
assert!(miss(ProviderId::AniList).is_fresh(now, &[ProviderId::AniList]));
let found = |provider| MetadataRecord {
metadata: Some(metadata()),
..miss(provider)
};
assert!(found(ProviderId::AniList).is_fresh(now, &chain));
assert!(found(ProviderId::TvMaze).is_fresh(now, &chain));
} }
} }
+1 -1
View File
@@ -812,7 +812,7 @@ fn kind_of(format: Option<&str>) -> TitleKind {
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>` /// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become /// mostly, and the occasional `<i>`. egui draws no markup, so tags become
/// nothing and `<br>` becomes the line break it stands for. /// nothing and `<br>` becomes the line break it stands for.
fn strip_markup(text: &str) -> String { pub(crate) fn strip_markup(text: &str) -> String {
let text = text let text = text
.replace("<br>", "\n") .replace("<br>", "\n")
.replace("<br/>", "\n") .replace("<br/>", "\n")
+3 -2
View File
@@ -8,10 +8,10 @@
//! //!
//! ```text //! ```text
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best //! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │ //! (anilist, then tvmaze | tmdb) │
//! ▼ //! ▼
//! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata //! Enrichment ◀── Provider::enrich ◀──────────────────── TitleMetadata
//! (episodes, fanart) (anilist relations + ani.zip | tmdb) //! (episodes, fanart) (anilist relations + ani.zip | tvmaze | tmdb)
//! ``` //! ```
//! //!
//! Three things are worth knowing before touching any of it: //! Three things are worth knowing before touching any of it:
@@ -37,6 +37,7 @@ pub mod provider;
pub mod service; pub mod service;
pub mod settings; pub mod settings;
pub mod tmdb; pub mod tmdb;
pub mod tvmaze;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use matching::{Candidate, Query}; pub use matching::{Candidate, Query};
+262 -97
View File
@@ -5,8 +5,17 @@
//! │ miss / stale //! │ miss / stale
//! ▼ //! ▼
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord //! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
//! │ no match
//! ▼
//! next provider in the chain, the same way
//! ``` //! ```
//! //!
//! The providers are asked in a chain: AniList, then TVmaze for what AniList
//! has not got. The first match wins and is stored under the provider that
//! gave it, so a library of anime with one live-action show in it is matched
//! against both without anyone choosing per title — and everything after a
//! match is asked of that match's own provider.
//!
//! Two rules govern what gets written back, and they are the difference between //! Two rules govern what gets written back, and they are the difference between
//! a library that settles down and one that hammers a third party forever: //! a library that settles down and one that hammers a third party forever:
//! //!
@@ -34,6 +43,7 @@ use crate::error::{Error, Result};
use crate::matching::{self, Query}; use crate::matching::{self, Query};
use crate::provider::Provider; use crate::provider::Provider;
use crate::tmdb::Tmdb; use crate::tmdb::Tmdb;
use crate::tvmaze::TvMaze;
/// How long to wait on a provider. /// How long to wait on a provider.
/// ///
@@ -44,10 +54,11 @@ const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Identifies this client to the providers, which both ask for it. /// Identifies this client to the providers, which both ask for it.
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The configured provider, resolved. /// One provider in the chain, resolved.
enum Source { enum Source {
AniList(AniList), AniList(AniList),
Tmdb(Tmdb), Tmdb(Tmdb),
TvMaze(TvMaze),
} }
impl Source { impl Source {
@@ -55,6 +66,7 @@ impl Source {
match self { match self {
Self::AniList(provider) => provider.id(), Self::AniList(provider) => provider.id(),
Self::Tmdb(provider) => provider.id(), Self::Tmdb(provider) => provider.id(),
Self::TvMaze(provider) => provider.id(),
} }
} }
@@ -62,78 +74,137 @@ impl Source {
match self { match self {
Self::AniList(provider) => provider.search(query).await, Self::AniList(provider) => provider.search(query).await,
Self::Tmdb(provider) => provider.search(query).await, Self::Tmdb(provider) => provider.search(query).await,
Self::TvMaze(provider) => provider.search(query).await,
} }
} }
/// The entry a stored match names, fetched again by its id. `None` from a /// The entry a stored match names, fetched again by its id. `None` from a
/// provider that has no details to add. /// provider that has no details to add — see [`gives_details`].
async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> { async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
match self { match self {
Self::AniList(provider) => provider.entry(id).await, Self::AniList(provider) => provider.entry(id).await,
Self::TvMaze(provider) => provider.entry(id).await,
Self::Tmdb(_) => Ok(None), Self::Tmdb(_) => Ok(None),
} }
} }
/// Whether [`Source::entry`] has anything to give.
fn has_details(&self) -> bool {
matches!(self, Self::AniList(_))
}
async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> { async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
match self { match self {
Self::AniList(provider) => provider.enrich(title, found).await, Self::AniList(provider) => provider.enrich(title, found).await,
Self::Tmdb(provider) => provider.enrich(title, found).await, Self::Tmdb(provider) => provider.enrich(title, found).await,
Self::TvMaze(provider) => provider.enrich(title, found).await,
} }
} }
} }
/// Metadata lookups, against one provider. /// The providers asked when the viewer chose `chosen`, in order.
///
/// AniList is followed by TVmaze, which has the television AniList does not
/// and needs no key either. TMDB has that television itself, so it is asked
/// alone. A stored answer from a provider outside the chain is the previous
/// choice's, and is re-asked.
pub fn chain_of(chosen: ProviderId) -> &'static [ProviderId] {
match chosen {
ProviderId::AniList => &[ProviderId::AniList, ProviderId::TvMaze],
ProviderId::Tmdb => &[ProviderId::Tmdb],
ProviderId::TvMaze => &[ProviderId::TvMaze],
}
}
/// [`chain_of`] `chosen`, for a sentence: `AniList and TVmaze`.
pub fn chain_label(chosen: ProviderId) -> String {
chain_of(chosen)
.iter()
.map(|provider| provider.label())
.collect::<Vec<_>>()
.join(" and ")
}
/// Whether `provider` fills in [`TitleMetadata::details`], and so whether a
/// match it gave without them, or with an older revision of them, is worth
/// fetching again.
fn gives_details(provider: ProviderId) -> bool {
matches!(provider, ProviderId::AniList | ProviderId::TvMaze)
}
/// Metadata lookups, against a chain of providers.
/// ///
/// Cheap to clone — the HTTP client inside is a connection pool that wants /// Cheap to clone — the HTTP client inside is a connection pool that wants
/// sharing, not duplicating. /// sharing, not duplicating.
#[derive(Clone)] #[derive(Clone)]
pub struct MetadataService { pub struct MetadataService {
source: Arc<Source>, /// In the order they are asked. Never empty.
sources: Arc<[Source]>,
http: reqwest::Client, http: reqwest::Client,
} }
impl MetadataService { impl MetadataService {
/// Build a service for `config`. /// Build a service for `config`, asking [`chain_of`] its provider.
/// ///
/// `api_key` is only consulted for a provider that needs one; for AniList it /// `api_key` is only consulted for a provider that needs one; for AniList it
/// is ignored, and passing `None` there is not an error. /// is ignored, and passing `None` there is not an error.
pub fn new(config: &MetadataConfig, api_key: Option<String>) -> Result<Self> { pub fn new(config: &MetadataConfig, mut api_key: Option<String>) -> Result<Self> {
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.user_agent(USER_AGENT) .user_agent(USER_AGENT)
.timeout(TIMEOUT) .timeout(TIMEOUT)
.build() .build()
.map_err(Error::Network)?; .map_err(Error::Network)?;
let source = match config.provider { let sources = chain_of(config.provider)
.iter()
.map(|provider| {
Ok(match provider {
ProviderId::AniList => { ProviderId::AniList => {
Source::AniList(AniList::new(http.clone(), config.language.clone())) Source::AniList(AniList::new(http.clone(), config.language.clone()))
} }
ProviderId::Tmdb => Source::Tmdb(Tmdb::new( ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
http.clone(), http.clone(),
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?, api_key
.take()
.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
config.language.clone(), config.language.clone(),
)), )),
}; ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self { Ok(Self {
source: Arc::new(source), sources: sources.into(),
http, http,
}) })
} }
/// The provider the viewer chose: the first one asked.
pub fn provider(&self) -> ProviderId { pub fn provider(&self) -> ProviderId {
self.source.id() self.sources[0].id()
} }
/// Ask the provider about one title. /// Every provider asked, in order. What [`plan`] and
/// [`MetadataRecord::is_fresh`] judge a stored answer against.
pub fn chain(&self) -> Vec<ProviderId> {
self.sources.iter().map(Source::id).collect()
}
/// The chain, for a status line: `AniList and TVmaze`.
pub fn label(&self) -> String {
chain_label(self.provider())
}
fn source(&self, provider: ProviderId) -> Result<&Source> {
self.sources
.iter()
.find(|source| source.id() == provider)
.ok_or_else(|| Error::Config(format!("{} is not in use", provider.label())))
}
/// Ask the chain about one title, stopping at the first match.
/// ///
/// `Ok(None)` is a real answer — nothing matched well enough — and the /// `Ok(None)` is a real answer — nothing matched well enough anywhere —
/// caller should store it. An `Err` is not, and the caller should not. /// and the caller should store it. An `Err` is not, and the caller should
/// not: a provider that could not be asked has not said it lacks the
/// title, and settling for the next one's answer would store a guess in
/// its place.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> { pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let mut 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 // Nothing in the files numbered itself, so the kind came from counting
@@ -141,34 +212,48 @@ impl MetadataService {
if !title.states_its_numbering() { if !title.states_its_numbering() {
query = query.with_guessed_kind(); query = query.with_guessed_kind();
} }
let candidates = self.source.search(&query).await?; for source in self.sources.iter() {
let candidates = source.search(&query).await?;
let found = candidates.len(); let found = candidates.len();
let best = matching::best(&query, candidates); let best = matching::best(&query, candidates);
tracing::debug!( tracing::debug!(
"{}: {found} candidates from {}, {}", "{}: {found} candidates from {}, {}",
title.name, title.name,
self.provider().label(), source.id().label(),
match &best { match &best {
Some(found) => format!("matched {:?}", found.name), Some(found) => format!("matched {:?}", found.name),
None => "no match".to_string(), None => "no match".to_string(),
} }
); );
Ok(best) if best.is_some() {
return Ok(best);
}
}
Ok(None)
} }
/// The same lookup, as a record ready to store — misses included. /// The same lookup, as a record ready to store — misses included.
///
/// A match is stored under the provider that gave it; a miss under the
/// last one asked, which is what makes it stand — see
/// [`MetadataRecord::is_fresh`].
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> { pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
let metadata = self.lookup(title).await?; let metadata = self.lookup(title).await?;
Ok(MetadataRecord { Ok(MetadataRecord {
title_key: title_key(&title.name), title_key: title_key(&title.name),
provider: self.provider(), provider: metadata
.as_ref()
.map_or_else(|| self.last(), |found| found.provider),
metadata, metadata,
fetched_at: now(), fetched_at: now(),
manual: false, manual: false,
}) })
} }
fn last(&self) -> ProviderId {
self.sources[self.sources.len() - 1].id()
}
/// Everything the provider thinks `name` might be, unscored and in its own /// Everything the provider thinks `name` might be, unscored and in its own
/// order. /// order.
/// ///
@@ -183,39 +268,57 @@ impl MetadataService {
/// Takes a kind because the providers key their search on it, and no year: /// 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 /// the point of a hand search is that the library's own guesses are what
/// went wrong. /// went wrong.
///
/// Every provider in the chain is asked, the first one's answers first,
/// and each answer says which provider gave it. One that fails leaves its
/// answers out; only when all of them fail is that an error.
pub async fn search(&self, name: &str, kind: TitleKind) -> Result<Vec<TitleMetadata>> { 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(); let query = Query::new(name.trim().to_string(), None, kind).with_guessed_kind();
if query.name.is_empty() { if query.name.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let candidates = self.source.search(&query).await?; let mut options = Vec::new();
let mut failure = None;
for source in self.sources.iter() {
match source.search(&query).await {
Ok(candidates) => {
tracing::debug!( tracing::debug!(
"hand search {:?}: {} candidates from {}", "hand search {:?}: {} candidates from {}",
query.name, query.name,
candidates.len(), candidates.len(),
self.provider().label() source.id().label()
); );
Ok(candidates options.extend(candidates.into_iter().map(|candidate| candidate.metadata));
.into_iter() }
.map(|candidate| candidate.metadata) Err(error) => {
.collect()) tracing::warn!("hand search on {}: {error}", source.id().label());
failure.get_or_insert(error);
}
}
}
match failure {
Some(error) if options.is_empty() => Err(error),
_ => Ok(options),
}
} }
/// A record for an entry the viewer picked themselves. /// A record for an entry the viewer picked themselves, under the provider
/// it came from.
/// ///
/// Marked [`MetadataRecord::manual`], which is what keeps the next match run /// Marked [`MetadataRecord::manual`], which is what keeps the next match run
/// — including a forced one — from undoing it. /// — including a forced one — from undoing it.
pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord { pub fn chosen(&self, title_key: String, found: TitleMetadata) -> MetadataRecord {
MetadataRecord { MetadataRecord {
title_key, title_key,
provider: self.provider(), provider: found.provider,
metadata: Some(found), metadata: Some(found),
fetched_at: now(), fetched_at: now(),
manual: true, manual: true,
} }
} }
/// The episodes of a matched title, and any art its match lacked. /// The episodes of a matched title, and any art its match lacked, from
/// the provider that matched it.
/// ///
/// Only ever called for a title that already has a match, because the /// Only ever called for a title that already has a match, because the
/// provider's own id is what it takes — there is no second search here, and /// provider's own id is what it takes — there is no second search here, and
@@ -225,12 +328,12 @@ impl MetadataService {
/// un-enriched and the next match run asks again. An empty answer is an /// un-enriched and the next match run asks again. An empty answer is an
/// answer, and is — see `Catalog::set_enrichment`. /// answer, and is — see `Catalog::set_enrichment`.
pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> { pub async fn enrich(&self, title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let enrichment = self.source.enrich(title, found).await?; let enrichment = self.source(found.provider)?.enrich(title, found).await?;
tracing::debug!( tracing::debug!(
"{}: {} episodes from {}{}", "{}: {} episodes from {}{}",
title.name, title.name,
enrichment.episodes.len(), enrichment.episodes.len(),
self.provider().label(), found.provider.label(),
if enrichment.backdrop_url.is_some() { if enrichment.backdrop_url.is_some() {
", with a backdrop" ", with a backdrop"
} else { } else {
@@ -285,6 +388,9 @@ impl Work {
#[derive(Debug)] #[derive(Debug)]
pub struct Outcome { pub struct Outcome {
pub title_key: String, pub title_key: String,
/// The provider the title is matched to, and so the one
/// [`Outcome::enrichment`] is stored under.
pub provider: ProviderId,
/// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it — /// The new answer, for [`Work::Match`] and [`Work::Refresh`]. Store it —
/// misses included. /// misses included.
pub record: Option<MetadataRecord>, pub record: Option<MetadataRecord>,
@@ -308,22 +414,21 @@ pub struct Outcome {
/// the viewer already fixed by hand is the opposite of what they asked for. It /// the viewer already fixed by hand is the opposite of what they asked for. It
/// is still enriched and refreshed. /// is still enriched and refreshed.
/// ///
/// `details` is whether the provider has [`TitleMetadata::details`] to give /// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the
/// — see [`MetadataService::has_details`]. /// provider each title is matched to.
pub fn plan( pub fn plan(
titles: Vec<Title>, titles: Vec<Title>,
stored: &HashMap<String, MetadataRecord>, stored: &HashMap<String, MetadataRecord>,
enriched: &HashMap<String, (ProviderId, i64)>, enriched: &HashMap<String, (ProviderId, i64)>,
provider: ProviderId, chain: &[ProviderId],
details: bool,
force: bool, force: bool,
) -> Vec<Work> { ) -> Vec<Work> {
let now = now(); let now = now();
let mut work = Vec::new(); let mut work = Vec::new();
for title in titles { for title in titles {
let record = stored.get(&title.key); let record = stored.get(&title.key);
let pinned = record.is_some_and(|record| record.manual && record.provider == provider); let pinned = record.is_some_and(|record| record.manual && chain.contains(&record.provider));
if !pinned && (force || !is_usable(record, provider)) { if !pinned && (force || !is_usable(record, chain)) {
work.push(Work::Match(title)); work.push(Work::Match(title));
continue; continue;
} }
@@ -331,9 +436,9 @@ pub fn plan(
continue; continue;
}; };
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| { let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| {
*asked == provider && now.saturating_sub(*at) < MATCH_TTL_SECS *asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS
}); });
let stale_details = details let stale_details = gives_details(record.provider)
&& record && record
.metadata .metadata
.as_ref() .as_ref()
@@ -352,12 +457,6 @@ pub fn plan(
} }
impl MetadataService { impl MetadataService {
/// Whether this provider fills in [`TitleMetadata::details`], and so
/// whether a match stored without them is worth fetching again.
pub fn has_details(&self) -> bool {
self.source.has_details()
}
/// Do one piece of [`Work`]. /// Do one piece of [`Work`].
/// ///
/// A failed search is an `Err` and nothing is stored. A failed enrichment /// A failed search is an `Err` and nothing is stored. A failed enrichment
@@ -375,12 +474,14 @@ impl MetadataService {
}; };
Ok(Outcome { Ok(Outcome {
title_key: record.title_key.clone(), title_key: record.title_key.clone(),
provider: record.provider,
record: Some(record), record: Some(record),
enrichment, enrichment,
}) })
} }
Work::Enrich(title, found) => Ok(Outcome { Work::Enrich(title, found) => Ok(Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: found.provider,
record: None, record: None,
enrichment: Some(self.enrich(title, found).await?), enrichment: Some(self.enrich(title, found).await?),
}), }),
@@ -392,6 +493,7 @@ impl MetadataService {
let Some(stored) = &record.metadata else { let Some(stored) = &record.metadata else {
return Ok(Outcome { return Ok(Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: record.provider,
record: None, record: None,
enrichment: None, enrichment: None,
}); });
@@ -399,7 +501,7 @@ impl MetadataService {
// An id the provider no longer has keeps what was stored, its // An id the provider no longer has keeps what was stored, its
// details marked current so it is not asked about on every run. // details marked current so it is not asked about on every run.
let found = self let found = self
.source .source(stored.provider)?
.entry(&stored.remote_id) .entry(&stored.remote_id)
.await? .await?
.unwrap_or_else(|| TitleMetadata { .unwrap_or_else(|| TitleMetadata {
@@ -415,6 +517,7 @@ impl MetadataService {
}; };
Ok(Outcome { Ok(Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: record.provider,
record: Some(MetadataRecord { record: Some(MetadataRecord {
metadata: Some(found), metadata: Some(found),
fetched_at: now(), fetched_at: now(),
@@ -437,6 +540,7 @@ impl MetadataService {
let enrichment = self.enrich_or_log(title, &found).await; let enrichment = self.enrich_or_log(title, &found).await;
Outcome { Outcome {
title_key: title.key.clone(), title_key: title.key.clone(),
provider: found.provider,
record: Some(self.chosen(title.key.clone(), found)), record: Some(self.chosen(title.key.clone(), found)),
enrichment, enrichment,
} }
@@ -450,9 +554,9 @@ impl MetadataService {
} }
} }
/// Whether a stored answer can be used as-is. /// Whether a stored answer can be used as-is by `chain`.
pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool { pub fn is_usable(record: Option<&MetadataRecord>, chain: &[ProviderId]) -> bool {
record.is_some_and(|record| record.is_fresh(now(), provider)) record.is_some_and(|record| record.is_fresh(now(), chain))
} }
fn now() -> i64 { fn now() -> i64 {
@@ -516,13 +620,29 @@ mod tests {
genres: Vec::new(), genres: Vec::new(),
episodes: None, episodes: None,
url: None, url: None,
details: None, details: Some(TitleDetails {
revision: TitleDetails::REVISION,
..TitleDetails::default()
}),
}), }),
fetched_at: now(), fetched_at: now(),
manual, manual,
} }
} }
/// [`matched`], stored before there were details.
fn without_details(key: &str, manual: bool, provider: ProviderId) -> MetadataRecord {
let mut record = matched(key, manual);
record.provider = provider;
if let Some(found) = record.metadata.as_mut() {
found.provider = provider;
found.details = None;
}
record
}
const ANILIST: &[ProviderId] = &[ProviderId::AniList, ProviderId::TvMaze];
fn kinds(work: &[Work]) -> Vec<(&str, &str)> { fn kinds(work: &[Work]) -> Vec<(&str, &str)> {
work.iter() work.iter()
.map(|work| match work { .map(|work| match work {
@@ -551,14 +671,7 @@ mod tests {
]); ]);
let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec(); let titles = ["enriched", "stale", "bare", "new"].map(title).to_vec();
let work = plan( let work = plan(titles, &stored, &enriched, ANILIST, false);
titles,
&stored,
&enriched,
ProviderId::AniList,
false,
false,
);
assert_eq!( assert_eq!(
kinds(&work), kinds(&work),
vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")] vec![("stale", "enrich"), ("bare", "enrich"), ("new", "match")]
@@ -575,14 +688,7 @@ mod tests {
]); ]);
let titles = ["pinned", "automatic"].map(title).to_vec(); let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan( let work = plan(titles, &stored, &HashMap::new(), ANILIST, true);
titles,
&stored,
&HashMap::new(),
ProviderId::AniList,
false,
true,
);
assert_eq!( assert_eq!(
kinds(&work), kinds(&work),
vec![("pinned", "enrich"), ("automatic", "match")] vec![("pinned", "enrich"), ("automatic", "match")]
@@ -590,23 +696,30 @@ mod tests {
} }
/// A match stored before details existed is fetched again by id — a /// A match stored before details existed is fetched again by id — a
/// pinned one included, and without being searched for — and only by a /// pinned one included, and without being searched for — and only from a
/// provider that has details to give. /// provider that has details to give.
#[test] #[test]
fn a_match_without_details_is_refreshed_rather_than_searched_for() { fn a_match_without_details_is_refreshed_rather_than_searched_for() {
let stored = HashMap::from([ let records = |provider| {
("pinned".to_string(), matched("pinned", true)), HashMap::from([
("automatic".to_string(), matched("automatic", false)), (
]); "pinned".to_string(),
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::AniList, now()))]); without_details("pinned", true, provider),
),
(
"automatic".to_string(),
without_details("automatic", false, provider),
),
])
};
let enriched = |provider| HashMap::from([("pinned".to_string(), (provider, now()))]);
let titles = ["pinned", "automatic"].map(title).to_vec(); let titles = ["pinned", "automatic"].map(title).to_vec();
let work = plan( let work = plan(
titles.clone(), titles.clone(),
&stored, &records(ProviderId::AniList),
&enriched, &enriched(ProviderId::AniList),
ProviderId::AniList, ANILIST,
true,
false, false,
); );
assert_eq!( assert_eq!(
@@ -618,15 +731,52 @@ mod tests {
let work = plan( let work = plan(
titles, titles,
&stored, &records(ProviderId::Tmdb),
&enriched, &enriched(ProviderId::Tmdb),
ProviderId::AniList, &[ProviderId::Tmdb],
false,
false, false,
); );
assert_eq!(kinds(&work), vec![("automatic", "enrich")]); assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
} }
/// A title AniList had nothing for is asked of TVmaze at once, rather than
/// waiting out AniList's stored miss; a match from TVmaze is enriched by
/// TVmaze, and a hand-picked one stays pinned.
#[test]
fn titles_are_planned_against_the_provider_each_is_matched_to() {
let miss = MetadataRecord {
metadata: None,
..matched("anilist miss", false)
};
let tvmaze = |key: &str, manual| {
let mut record = matched(key, manual);
record.provider = ProviderId::TvMaze;
if let Some(found) = record.metadata.as_mut() {
found.provider = ProviderId::TvMaze;
}
record
};
let stored = HashMap::from([
("anilist miss".to_string(), miss),
("on tvmaze".to_string(), tvmaze("on tvmaze", false)),
("pinned".to_string(), tvmaze("pinned", true)),
]);
let enriched = HashMap::from([("pinned".to_string(), (ProviderId::TvMaze, now()))]);
let titles = ["anilist miss", "on tvmaze", "pinned"].map(title).to_vec();
let work = plan(titles.clone(), &stored, &enriched, ANILIST, false);
assert_eq!(
kinds(&work),
vec![("anilist miss", "match"), ("on tvmaze", "enrich")]
);
let work = plan(titles, &stored, &enriched, ANILIST, true);
assert_eq!(
kinds(&work),
vec![("anilist miss", "match"), ("on tvmaze", "match")]
);
}
/// Details from an older build are refetched by id, and kept meanwhile; /// Details from an older build are refetched by id, and kept meanwhile;
/// details this build wrote are not asked for again. /// details this build wrote are not asked for again.
#[test] #[test]
@@ -644,14 +794,7 @@ mod tests {
let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]); let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]);
let run = |record: MetadataRecord| { let run = |record: MetadataRecord| {
let stored = HashMap::from([("k".to_string(), record)]); let stored = HashMap::from([("k".to_string(), record)]);
let work = plan( let work = plan(vec![title("k")], &stored, &enriched, ANILIST, false);
vec![title("k")],
&stored,
&enriched,
ProviderId::AniList,
true,
false,
);
kinds(&work) kinds(&work)
.into_iter() .into_iter()
.map(|(_, kind)| kind.to_string()) .map(|(_, kind)| kind.to_string())
@@ -661,8 +804,9 @@ mod tests {
assert!(run(with(TitleDetails::REVISION)).is_empty()); assert!(run(with(TitleDetails::REVISION)).is_empty());
} }
/// A record from the other provider is never usable — switching provider has /// A record from a provider outside the chain is never usable — switching
/// to re-ask, including about titles the previous one found nothing for. /// provider has to re-ask, including about titles the previous one found
/// nothing for.
#[test] #[test]
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() { fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
let record = MetadataRecord { let record = MetadataRecord {
@@ -672,8 +816,29 @@ mod tests {
fetched_at: now(), fetched_at: now(),
manual: false, manual: false,
}; };
assert!(is_usable(Some(&record), ProviderId::AniList)); assert!(is_usable(Some(&record), &[ProviderId::AniList]));
assert!(!is_usable(Some(&record), ProviderId::Tmdb)); assert!(!is_usable(Some(&record), &[ProviderId::Tmdb]));
assert!(!is_usable(None, ProviderId::AniList)); assert!(!is_usable(None, &[ProviderId::AniList]));
}
/// AniList is followed by TVmaze, which needs no key either; TMDB, which
/// has television of its own, is asked alone.
#[test]
fn anilist_is_followed_by_tvmaze_and_tmdb_stands_alone() {
let config = MetadataConfig {
enabled: true,
..MetadataConfig::default()
};
let service = MetadataService::new(&config, None).expect("service");
assert_eq!(service.chain(), ANILIST);
assert_eq!(service.provider(), ProviderId::AniList);
assert_eq!(service.label(), "AniList and TVmaze");
let tmdb = MetadataConfig {
provider: ProviderId::Tmdb,
..config
};
let service = MetadataService::new(&tmdb, Some("key".into())).expect("service");
assert_eq!(service.chain(), vec![ProviderId::Tmdb]);
} }
} }
+446
View File
@@ -0,0 +1,446 @@
//! TVmaze, over its public REST API.
//!
//! Television AniList does not have — which is everything that is not anime —
//! with no API key and no account. It is asked only about what AniList found
//! nothing for (see [`crate::service`]), so a library of anime sends it
//! nothing, and a library with one live-action show in it sends it that one
//! name.
//!
//! What it does not have is film: TVmaze indexes shows and nothing else, so a
//! title the library knows to be a film is not asked about at all. A show's
//! name is also a film's often enough that asking would mostly find the wrong
//! thing.
//!
//! Its art is good where it matters here: a 16:9 still for most episodes and a
//! full-HD background for most shows. Its "typography" image would be a logo
//! but is a JPEG on a solid ground, so there is no logo from here.
use pstr_core::library::{Title, TitleKind};
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata};
use serde::Deserialize;
use crate::anilist::strip_markup;
use crate::error::{Error, Result};
use crate::limiter::RateLimiter;
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const API_BASE: &str = "https://api.tvmaze.com";
/// TVmaze allows at least twenty calls every ten seconds per address. A little
/// under it, because the limit is counted on its side and a burst that lands
/// at the edge of its window gets a 429 for nothing.
const REQUESTS_PER_MINUTE: u32 = 100;
/// As [`crate::anilist`]'s.
const RETRIES: usize = 2;
/// The background width worth asking for. Shows often carry a 4K one beside
/// it, which is four times the bytes for a band no window draws that wide.
const BACKDROP_WIDTH: u32 = 1920;
pub struct TvMaze {
http: reqwest::Client,
limiter: RateLimiter,
}
impl TvMaze {
pub fn new(http: reqwest::Client) -> Self {
Self {
http,
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
}
}
}
impl Provider for TvMaze {
fn id(&self) -> ProviderId {
ProviderId::TvMaze
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
if !asks_about(query) {
return Ok(Vec::new());
}
let hits: Vec<SearchHit> = self
.get("/search/shows", &[("q", query.name.as_str())])
.await?
.unwrap_or_default();
Ok(hits
.into_iter()
.map(|hit| hit.show.into_candidate())
.collect())
}
/// Every episode and the show's background, in one request.
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
let show: Option<Show> = self
.get(
&format!("/shows/{}", found.remote_id),
&[("embed[]", "episodes"), ("embed[]", "images")],
)
.await?;
Ok(show.map(Show::into_enrichment).unwrap_or_default())
}
}
impl TvMaze {
/// The show the TVmaze id `id` names, as search would have found it.
/// `None` for an id TVmaze no longer has.
pub async fn entry(&self, id: &str) -> Result<Option<TitleMetadata>> {
let show: Option<Show> = self.get(&format!("/shows/{id}"), &[]).await?;
Ok(show.map(|show| show.into_candidate().metadata))
}
/// One GET, paced. `None` for a 404, which TVmaze answers for an id it
/// does not have.
async fn get<T: serde::de::DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<Option<T>> {
let mut attempts = 0usize;
let response = loop {
self.limiter.acquire().await;
let response = self
.http
.get(format!("{API_BASE}{path}"))
.query(query)
.send()
.await?;
let status = response.status();
if status.as_u16() == 429 {
let waited = self.limiter.throttled(response.headers()).await;
attempts += 1;
if attempts > RETRIES {
return Err(Error::RateLimited);
}
tracing::debug!("TVmaze is throttling; waiting {waited:?}");
continue;
}
if status.as_u16() == 404 {
return Ok(None);
}
if !status.is_success() {
return Err(Error::Http(format!("TVmaze answered {status}")));
}
break response;
};
Ok(Some(response.json().await?))
}
}
/// Whether TVmaze could have `query` at all: not when the library knows it is
/// a film.
fn asks_about(query: &Query) -> bool {
!(query.kind_known && query.kind == TitleKind::Film)
}
#[derive(Deserialize)]
struct SearchHit {
show: Show,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Show {
id: i64,
name: String,
url: Option<String>,
#[serde(default)]
genres: Vec<String>,
status: Option<String>,
runtime: Option<u32>,
average_runtime: Option<u32>,
premiered: Option<String>,
rating: Option<Rating>,
network: Option<Channel>,
web_channel: Option<Channel>,
image: Option<Image>,
summary: Option<String>,
#[serde(rename = "_embedded")]
embedded: Option<Embedded>,
}
#[derive(Deserialize)]
struct Rating {
average: Option<f32>,
}
#[derive(Deserialize)]
struct Channel {
name: String,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
original: Option<String>,
}
#[derive(Deserialize, Default)]
struct Embedded {
#[serde(default)]
episodes: Vec<Episode>,
#[serde(default)]
images: Vec<Picture>,
}
#[derive(Deserialize)]
struct Episode {
name: Option<String>,
season: Option<u32>,
/// `null` on a special, which is numbered nowhere a file name would say.
number: Option<u32>,
airdate: Option<String>,
runtime: Option<u32>,
image: Option<Image>,
summary: Option<String>,
}
#[derive(Deserialize)]
struct Picture {
#[serde(rename = "type")]
kind: Option<String>,
#[serde(default)]
main: bool,
resolutions: Resolutions,
}
#[derive(Deserialize)]
struct Resolutions {
original: Option<Resolution>,
}
#[derive(Deserialize)]
struct Resolution {
url: String,
width: Option<u32>,
}
impl Show {
fn into_candidate(self) -> Candidate {
let status = match self.status.as_deref() {
Some("Running") => Some("RELEASING"),
Some("Ended") => Some("FINISHED"),
Some("To Be Determined") => Some("HIATUS"),
Some("In Development") => Some("NOT_YET_RELEASED"),
_ => None,
};
let channel = self.network.or(self.web_channel);
let details = TitleDetails {
start_date: self.premiered.clone().filter(|date| !date.is_empty()),
// Not a studio, but what the facts line names in its place: who
// made it, as a viewer would put it.
studios: channel.map(|channel| channel.name).into_iter().collect(),
status: status.map(str::to_string),
runtime: self
.runtime
.or(self.average_runtime)
.filter(|minutes| *minutes > 0),
revision: TitleDetails::REVISION,
..TitleDetails::default()
};
Candidate {
aliases: vec![self.name.clone()],
metadata: TitleMetadata {
provider: ProviderId::TvMaze,
remote_id: self.id.to_string(),
year: self
.premiered
.as_deref()
.and_then(|date| date.get(..4)?.parse().ok()),
name: self.name,
original_name: None,
overview: self.summary.as_deref().and_then(overview),
kind: TitleKind::Series,
poster_url: self.image.and_then(|image| image.original.or(image.medium)),
backdrop_url: None,
logo_url: None,
rating: self
.rating
.and_then(|rating| rating.average)
.filter(|rating| *rating > 0.0),
genres: self.genres,
episodes: None,
url: self.url,
details: Some(details),
},
// Always: TVmaze has nothing but shows.
kind_known: true,
}
}
fn into_enrichment(self) -> Enrichment {
let embedded = self.embedded.unwrap_or_default();
let episodes = embedded
.episodes
.into_iter()
.filter_map(|episode| {
Some(EpisodeMetadata {
season: episode.season,
number: episode.number?,
absolute: None,
entry_season: None,
entry_number: None,
name: episode.name.filter(|name| !name.is_empty()),
overview: episode.summary.as_deref().and_then(overview),
// The row draws a still about this wide; the original is
// a full-HD frame per episode.
still_url: episode
.image
.and_then(|image| image.medium.or(image.original)),
air_date: episode.airdate.filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
})
})
.collect();
Enrichment {
episodes,
backdrop_url: backdrop(&embedded.images),
logo_url: None,
chain: Vec::new(),
}
}
}
/// The background to draw: the show's main one if it marked one, else the one
/// nearest [`BACKDROP_WIDTH`].
fn backdrop(images: &[Picture]) -> Option<String> {
images
.iter()
.filter(|picture| picture.kind.as_deref() == Some("background"))
.filter_map(|picture| Some((picture, picture.resolutions.original.as_ref()?)))
.min_by_key(|(picture, resolution)| {
(
!picture.main,
resolution.width.unwrap_or(0).abs_diff(BACKDROP_WIDTH),
)
})
.map(|(_, resolution)| resolution.url.clone())
}
/// TVmaze's summaries are HTML paragraphs. Each one keeps its own line.
fn overview(html: &str) -> Option<String> {
let text = strip_markup(&html.replace("</p>", "</p>\n"));
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
const SHOW: &str = r#"{
"id": 82, "url": "https://www.tvmaze.com/shows/82/game-of-thrones",
"name": "Game of Thrones", "type": "Scripted",
"genres": ["Drama", "Adventure", "Fantasy"], "status": "Ended",
"runtime": 60, "averageRuntime": 61, "premiered": "2011-04-17",
"rating": {"average": 8.9}, "network": {"id": 8, "name": "HBO"},
"webChannel": null,
"image": {"medium": "https://static.tvmaze.com/m/1.jpg",
"original": "https://static.tvmaze.com/o/1.jpg"},
"summary": "<p>Based on the novels.</p><p>Seven noble families fight.</p>",
"_embedded": {
"episodes": [
{"name": "Winter is Coming", "season": 1, "number": 1,
"type": "regular", "airdate": "2011-04-17", "runtime": 60,
"image": {"medium": "https://static.tvmaze.com/m/e1.jpg",
"original": "https://static.tvmaze.com/o/e1.jpg"},
"summary": "<p>Lord Stark is troubled.</p>"},
{"name": "The Kingsroad", "season": 1, "number": 2,
"type": "regular", "airdate": "2011-04-24", "runtime": null,
"image": null, "summary": null},
{"name": "Inside the Making", "season": 1, "number": null,
"type": "significant_special", "airdate": "2011-04-10"}
],
"images": [
{"type": "poster", "main": true, "resolutions":
{"original": {"url": "poster", "width": 2030}}},
{"type": "background", "main": false, "resolutions":
{"original": {"url": "uhd", "width": 3840}}},
{"type": "background", "main": false, "resolutions":
{"original": {"url": "hd", "width": 1920}}},
{"type": "typography", "main": false, "resolutions":
{"original": {"url": "lettering", "width": 840}}}
]
}
}"#;
#[test]
fn a_show_becomes_a_series_with_its_details() {
let show: Show = serde_json::from_str(SHOW).expect("parse");
let found = show.into_candidate().metadata;
assert_eq!(found.provider, ProviderId::TvMaze);
assert_eq!(found.remote_id, "82");
assert_eq!(found.kind, TitleKind::Series);
assert_eq!(found.year, Some(2011));
assert_eq!(found.rating, Some(8.9));
assert_eq!(
found.poster_url.as_deref(),
Some("https://static.tvmaze.com/o/1.jpg")
);
assert_eq!(
found.overview.as_deref(),
Some("Based on the novels.\nSeven noble families fight.")
);
let details = found.details.expect("details");
assert!(details.is_current());
assert_eq!(details.studios, vec!["HBO"]);
assert_eq!(details.status.as_deref(), Some("FINISHED"));
assert_eq!(details.runtime, Some(60));
assert_eq!(details.start_date.as_deref(), Some("2011-04-17"));
}
/// Specials are numbered nowhere a file would be, so they are left out
/// rather than colliding with the episode they share a season with.
#[test]
fn enrichment_keeps_numbered_episodes_and_the_full_hd_background() {
let show: Show = serde_json::from_str(SHOW).expect("parse");
let enrichment = show.into_enrichment();
assert_eq!(enrichment.episodes.len(), 2);
let first = &enrichment.episodes[0];
assert_eq!((first.season, first.number), (Some(1), 1));
assert_eq!(first.name.as_deref(), Some("Winter is Coming"));
assert_eq!(first.overview.as_deref(), Some("Lord Stark is troubled."));
assert_eq!(
first.still_url.as_deref(),
Some("https://static.tvmaze.com/m/e1.jpg")
);
assert_eq!(first.runtime, Some(60));
assert_eq!(enrichment.episodes[1].runtime, None);
assert_eq!(enrichment.backdrop_url.as_deref(), Some("hd"));
assert_eq!(enrichment.logo_url, None);
}
#[test]
fn a_background_the_show_marked_main_wins_over_a_better_sized_one() {
let images: Vec<Picture> = serde_json::from_str(
r#"[{"type": "background", "main": false, "resolutions":
{"original": {"url": "hd", "width": 1920}}},
{"type": "background", "main": true, "resolutions":
{"original": {"url": "chosen", "width": 1280}}}]"#,
)
.expect("parse");
assert_eq!(backdrop(&images).as_deref(), Some("chosen"));
assert_eq!(backdrop(&[]), None);
}
/// TVmaze has no films, and a show sharing a film's name is the wrong
/// answer far more often than it is an adaptation.
#[test]
fn a_known_film_is_not_asked_about_and_a_guessed_one_is() {
let film = Query::new("Paprika", None, TitleKind::Film);
assert!(!asks_about(&film));
assert!(asks_about(&film.with_guessed_kind()));
assert!(asks_about(&Query::new(
"Game of Thrones",
None,
TitleKind::Series
)));
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
| `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) | | `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) |
| `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context | | `pstr-player` — libmpv `stream_cb` + render API | **done, live-verified** — 33 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache, and renders into the app's GL context |
| `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management | | `pstr-app` — egui UI, poster grid, embedded video | **done, offline-tested** — 15 offline tests; library wall, title pages, player page with overlay controls, themes, share management |
| Metadata providers (AniList / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default | | Metadata providers (AniList, then TVmaze / TMDB) | **done** — 32 offline tests; posters, synopses, per-episode titles, hand-pinned matches. Opt-in, off by default |
| Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac | | Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The macOS half is written but has not been run on a Mac |
## Build order from here ## Build order from here
+31 -3
View File
@@ -46,7 +46,7 @@ good. What hung off it was not:
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. | | **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. | | **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. | | **TMDB** | free key | Film backdrops and logos; live-action TV. Already implemented. |
| **TVmaze** | none | Live-action TV episodes, keyless. | | **TVmaze** | none | Live-action TV: episodes with 16:9 stills and runtimes, network, status, full-HD backgrounds. No films; its "typography" art is a JPEG, so no logos. In use after AniList. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. | | **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
| **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. | | **Jikan** (MAL) | none | Filler and recap flags. Timed out when tried; optional. |
| **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. | | **manami-project/anime-offline-database** | none | Every anime with its synonyms, ids and relations, as one 6 MB `.zst`. Matching against it locally sends no title anywhere. |
@@ -192,9 +192,37 @@ answer lands, rather than being blanked by the migration as schema v10 did.
## Phase 3 — reach ## Phase 3 — reach
**Routing by title (done).** One provider for the whole library left Game of
Thrones unmatched in a share of anime, and switching the library to TMDB would
have thrown away every AniList match, chain and shelf. So the providers are now
asked in a chain (`pstr_meta::service::chain_of`): with AniList chosen, a title
AniList has no match for is searched on TVmaze, keyless, before it is given up
on. TMDB, which has television itself, is still asked alone.
- A match is stored under the provider that gave it, and everything after it
— enrichment, refreshing details by id — goes to that provider.
- A miss is stored under the *last* provider asked. `MetadataRecord::is_fresh`
takes the chain, and a miss only stands when the chain ends with the provider
that stored it. So an AniList miss from before TVmaze joined is not trusted,
and the next run asks TVmaze at once rather than after the miss expires.
- A provider that fails (a timeout, a 429) ends the lookup with an error rather
than falling through. The next provider's answer for a title AniList could
not be asked about is a guess about an anime, and a guess stored as a match
would stay.
- TVmaze is not asked about a title the library knows to be a film. It has
none, and a show sharing a film's name is far more often wrong than an
adaptation.
- A hand search asks every provider in the chain and labels each result with
the provider it came from; picking one pins the title to that provider.
TVmaze answers search with whole show records — genres, network, status,
premiere date, runtime, rating — so a match carries its details with no second
request. Enrichment is one more: `/shows/{id}` with the episodes and images
embedded. Specials, which TVmaze numbers nowhere a file would, are left out.
Still to do:
- **Local matching** against the offline database, so only ids leave the - **Local matching** against the offline database, so only ids leave the
machine. machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for
whatever is not anime — Game of Thrones in a mostly-anime share.
- **Skip intro** from AniSkip for files without chapters, and filler flags from - **Skip intro** from AniSkip for files without chapters, and filler flags from
Jikan. Jikan.
+1
View File
@@ -9,6 +9,7 @@ and to nothing else unless you turn on posters and descriptions. This page lists
|---|---|---| |---|---|---|
| Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always | | Proton | Requests for the shares you added and, if you signed in, your account's Drive | Always |
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on | | AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
| TVmaze | With AniList chosen: the names of the titles AniList has no match for, and the ids of those TVmaze matched | Only with **Posters and descriptions** on |
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check. Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
+7 -4
View File
@@ -65,9 +65,10 @@ Click a title to open its page. **Esc** goes back.
- **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from - **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from
the beginning. the beginning.
- **Download show** keeps every episode on this device. See [Downloads](#downloads). - **Download show** keeps every episode on this device. See [Downloads](#downloads).
- **Match…** opens the matcher, where you choose which AniList or TMDB entry the title is. Use it - **Match…** opens the matcher, where you choose which AniList, TVmaze or TMDB entry the title
when the app guessed wrong or did not guess at all. A match you pick is never replaced. is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
- **More on AniList** (or TMDB) opens the title's page there. at all. A match you pick is never replaced.
- **More on AniList** (or TVmaze, or TMDB) opens the title's page there.
- Each season has **Mark season watched** and **Download season**. - Each season has **Mark season watched** and **Download season**.
- Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark - Each episode shows a tick when you have watched it. Right-click an episode and choose **Mark
watched up to here** to tick it and every episode before it. watched up to here** to tick it and every episode before it.
@@ -220,6 +221,8 @@ a video that is playing changes at once.
**Posters and descriptions** **Posters and descriptions**
- Off by default. When it is on, the app sends the names of your titles to the provider you pick: - Off by default. When it is on, the app sends the names of your titles to the provider you pick:
**AniList** (no key needed) or **TMDB** (needs a free API key). See [Privacy](PRIVACY.md). **AniList** (no key needed) or **TMDB** (needs a free API key). With AniList, a title it does
not have, such as a live-action show, is looked up on **TVmaze** next, which needs no key
either. See [Privacy](PRIVACY.md).
- **Match the library** looks up new titles now. **Match everything again** looks up every title, - **Match the library** looks up new titles now. **Match everything again** looks up every title,
except those you matched by hand. except those you matched by hand.
+2 -2
View File
@@ -321,7 +321,7 @@ private struct MetadataSettingsSheet: View {
var body: some View { var body: some View {
DialogSheet("Metadata enrichment") { DialogSheet("Metadata enrichment") {
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, the matched ids also go to ani.zip for episode names and wide artwork.") Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
Toggle("Enable enrichment", isOn: $enabled).tint(scheme.primary).foregroundStyle(scheme.onSurface).padding(.vertical, 6) Toggle("Enable enrichment", isOn: $enabled).tint(scheme.primary).foregroundStyle(scheme.onSurface).padding(.vertical, 6)
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach([MetadataProvider.aniList, .tmdb], id: \.self) { option in ForEach([MetadataProvider.aniList, .tmdb], id: \.self) { option in
@@ -329,7 +329,7 @@ private struct MetadataSettingsSheet: View {
.buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged)) .buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
} }
} }
Text(provider == .aniList ? "Anime; no account or API key required." : "Film and television; requires a free TMDB API key.") Text(provider == .aniList ? "Anime, and television from TVmaze; no account or API key required." : "Film and television; requires a free TMDB API key.")
.textStyle(.bodySmall) .textStyle(.bodySmall)
if provider == .tmdb { if provider == .tmdb {
Field(label: "Language", text: $language) Field(label: "Language", text: $language)
+9 -3
View File
@@ -376,10 +376,16 @@ extension MetadataProvider {
switch self { switch self {
case .aniList: "AniList" case .aniList: "AniList"
case .tmdb: "TMDB" case .tmdb: "TMDB"
case .tvMaze: "TVmaze"
} }
} }
} }
extension MatchRecord {
/// Unique across one search: the same id can come back from two providers.
var choiceId: String { "\(provider.displayName):\(remoteId)" }
}
extension EpisodeRecord { extension EpisodeRecord {
/// `S03E38`, `E38`, or the row's place in the season. /// `S03E38`, `E38`, or the row's place in the season.
/// ///
@@ -515,7 +521,7 @@ struct ChangeMatchSheet: View {
var body: some View { var body: some View {
DialogSheet("Change match") { DialogSheet("Change match") {
Text("Search the configured metadata provider. Nothing is stored until you choose an entry.") Text("Search the configured metadata providers. Nothing is stored until you choose an entry.")
Field(label: "Title", text: $term, submit: .search) Field(label: "Title", text: $term, submit: .search)
.onSubmit(search) .onSubmit(search)
Button(searching ? "Searching…" : "Search", action: search) Button(searching ? "Searching…" : "Search", action: search)
@@ -534,11 +540,11 @@ struct ChangeMatchSheet: View {
if !searching, options.isEmpty, searched { if !searching, options.isEmpty, searched {
Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall) Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall)
} }
ForEach(options, id: \.remoteId) { option in ForEach(options, id: \.choiceId) { option in
Button { choose(option) } label: { Button { choose(option) } label: {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(option.name).textStyle(.labelLarge, semibold: true) Text(option.name).textStyle(.labelLarge, semibold: true)
Text([option.year.map(String.init), option.originalName].compactMap { $0 }.joined(separator: " · ")) Text([option.year.map(String.init), option.originalName, option.provider.displayName].compactMap { $0 }.joined(separator: " · "))
.textStyle(.labelLarge) .textStyle(.labelLarge)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)