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)