From 3ca431fecae268c3bd53cdebd72cd68a72885e88 Mon Sep 17 00:00:00 2001
From: Nils Pukropp
Date: Thu, 29 Oct 2026 16:33:16 +0100
Subject: [PATCH] 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.
---
.../io/narl/protonstream/ui/SettingsScreen.kt | 7 +-
.../io/narl/protonstream/ui/TitleScreen.kt | 7 +-
crates/pstr-android/src/lib.rs | 21 +-
crates/pstr-app/src/engine.rs | 17 +-
crates/pstr-app/src/ui/matcher.rs | 10 +-
crates/pstr-app/src/ui/settings.rs | 6 +-
crates/pstr-cli/src/main.rs | 37 +-
crates/pstr-core/src/metadata.rs | 64 ++-
crates/pstr-meta/src/anilist.rs | 2 +-
crates/pstr-meta/src/lib.rs | 5 +-
crates/pstr-meta/src/service.rs | 401 +++++++++++-----
crates/pstr-meta/src/tvmaze.rs | 446 ++++++++++++++++++
docs/DEVELOPMENT.md | 2 +-
docs/METADATA.md | 34 +-
docs/PRIVACY.md | 1 +
docs/USER_GUIDE.md | 11 +-
ios/ProtonStream/UI/SettingsView.swift | 4 +-
ios/ProtonStream/UI/TitleView.swift | 12 +-
18 files changed, 892 insertions(+), 195 deletions(-)
create mode 100644 crates/pstr-meta/src/tvmaze.rs
diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt
index b5429cb..0756459 100644
--- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt
@@ -438,16 +438,16 @@ private fun MetadataSettingsDialog(
title = { Text("Metadata enrichment") },
text = {
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 }
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()) }
else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) }
}
}
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.",
style = MaterialTheme.typography.bodySmall,
)
@@ -497,6 +497,7 @@ private fun MetadataSettingsDialog(
internal fun MetadataProvider.displayName(): String = when (this) {
MetadataProvider.ANI_LIST -> "AniList"
MetadataProvider.TMDB -> "TMDB"
+ MetadataProvider.TV_MAZE -> "TVmaze"
}
private data class LegalDocument(val title: String, val assetPath: String)
diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt
index 92e212a..a3273dc 100644
--- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt
+++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt
@@ -759,7 +759,7 @@ internal fun ChangeMatchDialog(
title = { Text("Change match") },
text = {
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)
AccentButton(enabled = term.isNotBlank() && !searching, onClick = {
searching = true
@@ -794,7 +794,10 @@ internal fun ChangeMatchDialog(
) {
Column(Modifier.fillMaxWidth()) {
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(" · "),
+ )
}
}
}
diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs
index cc54ef3..10a2e21 100644
--- a/crates/pstr-android/src/lib.rs
+++ b/crates/pstr-android/src/lib.rs
@@ -304,6 +304,9 @@ pub enum TitleType {
pub enum MetadataProvider {
AniList,
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`.
@@ -1746,7 +1749,7 @@ impl AndroidEngine {
spawned(&runtime, async move {
let service = self.metadata_service()?;
let metadata = title_metadata(found);
- if metadata.provider != service.provider() {
+ if !service.chain().contains(&metadata.provider) {
return Err(BridgeError::Failure {
reason: "match came from a different metadata provider".to_owned(),
});
@@ -1762,7 +1765,7 @@ impl AndroidEngine {
if let Some(enrichment) = &outcome.enrichment {
let stored = self.catalog.lock().set_enrichment(
&title_key,
- service.provider(),
+ outcome.provider,
now(),
enrichment,
);
@@ -2363,21 +2366,13 @@ impl AndroidEngine {
/// behaviour.
async fn match_titles_inner(&self, force: bool) -> Result {
let service = Arc::new(self.metadata_service()?);
- let provider = service.provider();
let (titles, stored) = self.titles_and_metadata()?;
let enriched = self
.catalog
.lock()
.enrichment_ages()
.map_err(BridgeError::from_display)?;
- let pending = pstr_meta::service::plan(
- titles,
- &stored,
- &enriched,
- provider,
- service.has_details(),
- force,
- );
+ let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
let permits = Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY));
let mut lookups = tokio::task::JoinSet::new();
@@ -2409,7 +2404,7 @@ impl AndroidEngine {
summary.episodes += enrichment.episodes.len() as u32;
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
- provider,
+ outcome.provider,
now(),
enrichment,
);
@@ -3685,6 +3680,7 @@ fn metadata_provider(provider: ProviderId) -> MetadataProvider {
match provider {
ProviderId::AniList => MetadataProvider::AniList,
ProviderId::Tmdb => MetadataProvider::Tmdb,
+ ProviderId::TvMaze => MetadataProvider::TvMaze,
}
}
@@ -3692,6 +3688,7 @@ fn provider_id(provider: MetadataProvider) -> ProviderId {
match provider {
MetadataProvider::AniList => ProviderId::AniList,
MetadataProvider::Tmdb => ProviderId::Tmdb,
+ MetadataProvider::TvMaze => ProviderId::TvMaze,
}
}
diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs
index 43ba7ea..37a51d5 100644
--- a/crates/pstr-app/src/engine.rs
+++ b/crates/pstr-app/src/engine.rs
@@ -2605,8 +2605,6 @@ impl Engine {
}
return self.emit(Event::Error(self.no_service_reason()));
};
- let provider = service.provider();
-
let (stored, enriched) = {
let catalog = self.catalog.lock();
match catalog
@@ -2618,14 +2616,7 @@ impl Engine {
}
};
- let pending = pstr_meta::service::plan(
- titles,
- &stored,
- &enriched,
- provider,
- service.has_details(),
- force,
- );
+ let pending = pstr_meta::service::plan(titles, &stored, &enriched, &service.chain(), force);
if pending.is_empty() {
if automatic {
return;
@@ -2636,7 +2627,7 @@ impl Engine {
self.emit(Event::Status(format!(
"matching {} titles against {}…",
pending.len(),
- provider.label()
+ service.label()
)));
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
@@ -2673,7 +2664,7 @@ impl Engine {
episodes_found += enrichment.episodes.len();
let stored = self.catalog.lock().set_enrichment(
&outcome.title_key,
- provider,
+ outcome.provider,
now(),
enrichment,
);
@@ -2755,7 +2746,7 @@ impl Engine {
if let Some(enrichment) = &outcome.enrichment {
let stored = engine.catalog.lock().set_enrichment(
&title.key,
- service.provider(),
+ outcome.provider,
now(),
enrichment,
);
diff --git a/crates/pstr-app/src/ui/matcher.rs b/crates/pstr-app/src/ui/matcher.rs
index 6aa5c27..6923154 100644
--- a/crates/pstr-app/src/ui/matcher.rs
+++ b/crates/pstr-app/src/ui/matcher.rs
@@ -55,8 +55,8 @@ pub fn show(
.color(theme::text()),
);
ui.label(ui::muted(format!(
- "Everything {} answers with, in its own order — nothing is scored here.",
- provider.label()
+ "Everything {} answer with, in their own order — nothing is scored here.",
+ pstr_meta::service::chain_label(provider)
)));
ui.add_space(theme::space::L);
@@ -279,8 +279,9 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option String {
let mut parts: Vec = vec![
match found.kind {
@@ -298,5 +299,6 @@ fn facts(found: &TitleMetadata) -> String {
if let Some(rating) = found.rating {
parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
+ parts.push(found.provider.label().to_string());
parts.join(" · ")
}
diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs
index e7e5ca3..e2f02d7 100644
--- a/crates/pstr-app/src/ui/settings.rs
+++ b/crates/pstr-app/src/ui/settings.rs
@@ -874,8 +874,8 @@ fn metadata_form(
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 \
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 \
- wide artwork.",
+ a title it has nothing for is then sent to TVmaze, and the ids AniList matched — \
+ not the titles — go to ani.zip for episode names and wide artwork.",
));
ui.add_space(theme::space::L);
@@ -889,7 +889,7 @@ fn metadata_form(
if settings.enabled {
ui.add_space(theme::space::L);
ui.label(ui::muted("Provider"));
- for provider in ProviderId::ALL {
+ for provider in ProviderId::CHOOSABLE {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs
index 99b7dd9..076f1c7 100644
--- a/crates/pstr-cli/src/main.rs
+++ b/crates/pstr-cli/src/main.rs
@@ -575,12 +575,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
api_key,
} => {
if let Some(name) = provider {
- settings.provider = ProviderId::parse(&name).ok_or_else(|| {
- anyhow::anyhow!(
- "unknown provider {name:?}; try one of: {}",
- ProviderId::ALL.map(|provider| provider.as_str()).join(", ")
- )
- })?;
+ settings.provider = ProviderId::parse(&name)
+ .filter(|provider| ProviderId::CHOOSABLE.contains(provider))
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "unknown provider {name:?}; try one of: {}",
+ ProviderId::CHOOSABLE
+ .map(|provider| provider.as_str())
+ .join(", ")
+ )
+ })?;
}
if on {
settings.enabled = true;
@@ -606,10 +610,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
// `Engine::set_metadata_config`.
if off || settings.enabled {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
+ let chain = pstr_meta::service::chain_of(settings.provider);
let stale = catalog
.all_metadata()?
.values()
- .any(|record| record.provider != settings.provider);
+ .any(|record| !chain.contains(&record.provider));
if off || stale {
catalog.clear_metadata()?;
println!("cleared the stored answers");
@@ -627,7 +632,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
anyhow::bail!(
"lookups are off — run `pstr metadata set --on` first.\n\
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,
&stored,
&enriched,
- settings.provider,
- service.has_details(),
+ &service.chain(),
force,
);
if let Some(limit) = limit {
@@ -661,7 +665,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
println!(
"looking up {} titles against {}…",
pending.len(),
- settings.provider.label()
+ service.label()
);
// Serial, not concurrent: both providers rate-limit by the minute,
@@ -691,12 +695,17 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
Some(found) => {
matched += 1;
println!(
- " {name:<40} → {} ({})",
+ " {name:<40} → {} ({}){}",
found.name,
found
.year
.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"),
@@ -717,7 +726,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
);
catalog.set_enrichment(
&outcome.title_key,
- settings.provider,
+ outcome.provider,
unix_now(),
enrichment,
)?;
diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs
index 48cbe77..454f9db 100644
--- a/crates/pstr-core/src/metadata.rs
+++ b/crates/pstr-core/src/metadata.rs
@@ -39,15 +39,23 @@ pub enum ProviderId {
AniList,
/// The Movie Database. Film and television, needs a free API key.
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 {
- 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 {
match self {
Self::AniList => "anilist",
Self::Tmdb => "tmdb",
+ Self::TvMaze => "tvmaze",
}
}
@@ -56,6 +64,7 @@ impl ProviderId {
match self {
Self::AniList => "AniList",
Self::Tmdb => "TMDB",
+ Self::TvMaze => "TVmaze",
}
}
@@ -64,9 +73,11 @@ impl ProviderId {
match self {
Self::AniList => {
"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::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 {
/// 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;
}
// 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() {
MATCH_TTL_SECS
- } else {
+ } else if chain.last() == Some(&self.provider) {
MISS_TTL_SECS
+ } else {
+ return false;
};
now.saturating_sub(self.fetched_at) < ttl
}
@@ -786,13 +804,13 @@ mod tests {
fetched_at: now - MISS_TTL_SECS - 1,
manual: false,
};
- assert!(!miss.is_fresh(now, ProviderId::AniList));
+ assert!(!miss.is_fresh(now, &[ProviderId::AniList]));
let matched = MetadataRecord {
metadata: Some(metadata()),
..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
@@ -806,9 +824,9 @@ mod tests {
fetched_at: 0,
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.
- 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 —
@@ -822,6 +840,32 @@ mod tests {
fetched_at: 1_000_000_000,
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));
}
}
diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs
index 15f224d..76e1ce6 100644
--- a/crates/pstr-meta/src/anilist.rs
+++ b/crates/pstr-meta/src/anilist.rs
@@ -812,7 +812,7 @@ fn kind_of(format: Option<&str>) -> TitleKind {
/// AniList descriptions carry a little HTML even with `asHtml: false` — ` `
/// mostly, and the occasional ``. egui draws no markup, so tags become
/// nothing and ` ` becomes the line break it stands for.
-fn strip_markup(text: &str) -> String {
+pub(crate) fn strip_markup(text: &str) -> String {
let text = text
.replace(" ", "\n")
.replace(" ", "\n")
diff --git a/crates/pstr-meta/src/lib.rs b/crates/pstr-meta/src/lib.rs
index 5f4fc0e..1bd7fe3 100644
--- a/crates/pstr-meta/src/lib.rs
+++ b/crates/pstr-meta/src/lib.rs
@@ -8,10 +8,10 @@
//!
//! ```text
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
-//! (anilist | tmdb) │
+//! (anilist, then tvmaze | tmdb) │
//! ▼
//! 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:
@@ -37,6 +37,7 @@ pub mod provider;
pub mod service;
pub mod settings;
pub mod tmdb;
+pub mod tvmaze;
pub use error::{Error, Result};
pub use matching::{Candidate, Query};
diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs
index ffbdbdc..a8e0c96 100644
--- a/crates/pstr-meta/src/service.rs
+++ b/crates/pstr-meta/src/service.rs
@@ -5,8 +5,17 @@
//! │ miss / stale
//! ▼
//! 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
//! 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::provider::Provider;
use crate::tmdb::Tmdb;
+use crate::tvmaze::TvMaze;
/// 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.
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
-/// The configured provider, resolved.
+/// One provider in the chain, resolved.
enum Source {
AniList(AniList),
Tmdb(Tmdb),
+ TvMaze(TvMaze),
}
impl Source {
@@ -55,6 +66,7 @@ impl Source {
match self {
Self::AniList(provider) => provider.id(),
Self::Tmdb(provider) => provider.id(),
+ Self::TvMaze(provider) => provider.id(),
}
}
@@ -62,78 +74,137 @@ impl Source {
match self {
Self::AniList(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
- /// provider that has no details to add.
+ /// provider that has no details to add — see [`gives_details`].
async fn entry(&self, id: &str) -> Result
"},
+ {"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 = 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
+ )));
+ }
+}
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index ec3e28a..f364043 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -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-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 |
-| 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 |
## Build order from here
diff --git a/docs/METADATA.md b/docs/METADATA.md
index 30ad0ed..b93e71f 100644
--- a/docs/METADATA.md
+++ b/docs/METADATA.md
@@ -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. |
| **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. |
-| **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. |
| **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. |
@@ -192,9 +192,37 @@ answer lands, rather than being blanked by the migration as schema v10 did.
## 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
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
Jikan.
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 550bd3f..51137a4 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -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 |
| 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.
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index d8a0e73..1bb278e 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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
the beginning.
- **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
- when the app guessed wrong or did not guess at all. A match you pick is never replaced.
-- **More on AniList** (or TMDB) opens the title's page there.
+- **Match…** opens the matcher, where you choose which AniList, TVmaze or TMDB entry the title
+ is. Each result names the site it came from. Use it when the app guessed wrong or did not guess
+ 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 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.
@@ -220,6 +221,8 @@ a video that is playing changes at once.
**Posters and descriptions**
- 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,
except those you matched by hand.
diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift
index 25f27d8..4260003 100644
--- a/ios/ProtonStream/UI/SettingsView.swift
+++ b/ios/ProtonStream/UI/SettingsView.swift
@@ -321,7 +321,7 @@ private struct MetadataSettingsSheet: View {
var body: some View {
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)
HStack(spacing: 8) {
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))
}
}
- 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)
if provider == .tmdb {
Field(label: "Language", text: $language)
diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift
index a4cf6cf..f4af142 100644
--- a/ios/ProtonStream/UI/TitleView.swift
+++ b/ios/ProtonStream/UI/TitleView.swift
@@ -376,10 +376,16 @@ extension MetadataProvider {
switch self {
case .aniList: "AniList"
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 {
/// `S03E38`, `E38`, or the row's place in the season.
///
@@ -515,7 +521,7 @@ struct ChangeMatchSheet: View {
var body: some View {
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)
.onSubmit(search)
Button(searching ? "Searching…" : "Search", action: search)
@@ -534,11 +540,11 @@ struct ChangeMatchSheet: View {
if !searching, options.isEmpty, searched {
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: {
VStack(alignment: .leading, spacing: 2) {
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)
}
.frame(maxWidth: .infinity, alignment: .leading)