mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(meta,app,android,ios,cli): film backdrops and logos from TMDB by id
TVDB has no films, so ani.zip has no fanart or logo for one, and most of
the titles in a real library with nothing wide to draw were films. ani.zip
does name the TMDB entry of every AniList match. With a TMDB key stored
and AniList chosen, an entry left without a backdrop or a logo is asked
for /{movie,tv}/{id}/images: one request, by id, so no title name goes to
TMDB. A bare backdrop and a PNG logo in the viewer's language are
preferred, and a failure there only costs the art.
The TMDB key field is now offered with either provider, labelled optional
with AniList. TMDB as the provider fetches its logo the same way, and
backdrops at w1280 rather than w780. A forced run re-enriches hand-picked
titles too, so saving a key and matching everything again reaches them.
This commit is contained in:
1 parent
70952fa7ca
commit
a7b87cdc79
15 files changed
+413
-89
No files matched your search
@@ -727,8 +727,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
val stored = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
if (provider == MetadataProvider.TMDB && apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(provider, apiKey)
|
||||
// TMDB's is the only key: the provider's with TMDB chosen, and
|
||||
// film art's with AniList.
|
||||
if (apiKey.isNotBlank()) {
|
||||
engine.setMetadataApiKey(MetadataProvider.TMDB, apiKey)
|
||||
}
|
||||
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
|
||||
engine.metadataSettings()
|
||||
|
||||
@@ -447,21 +447,33 @@ private fun MetadataSettingsDialog(
|
||||
}
|
||||
}
|
||||
Text(
|
||||
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.",
|
||||
if (provider == MetadataProvider.ANI_LIST) {
|
||||
"Anime, and television from TVmaze; no account or API key required. " +
|
||||
"With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone."
|
||||
} else {
|
||||
"Film and television; requires a free TMDB API key."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (provider == MetadataProvider.TMDB) {
|
||||
OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true)
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
label = {
|
||||
Text(
|
||||
when {
|
||||
provider == MetadataProvider.ANI_LIST -> "TMDB API key (optional)"
|
||||
current.ready -> "TMDB API key (leave blank to keep)"
|
||||
else -> "TMDB API key"
|
||||
},
|
||||
)
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
singleLine = true,
|
||||
)
|
||||
if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error)
|
||||
// Said before Save rather than after: what is forgotten
|
||||
// includes matches chosen by hand, which no look-up redoes.
|
||||
|
||||
@@ -2432,7 +2432,7 @@ impl AndroidEngine {
|
||||
reason: "turn on metadata enrichment first".to_owned(),
|
||||
});
|
||||
}
|
||||
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), config.provider);
|
||||
let key = pstr_meta::settings::api_key_in(self.secrets.as_ref(), ProviderId::Tmdb);
|
||||
pstr_meta::MetadataService::new(&config, key).map_err(BridgeError::from_display)
|
||||
}
|
||||
|
||||
|
||||
@@ -460,7 +460,7 @@ impl Enrichment {
|
||||
/// Resolve a service for `config`, if one can be had.
|
||||
fn build(config: MetadataConfig) -> Self {
|
||||
let service = config.enabled.then(|| {
|
||||
let key = pstr_meta::settings::api_key(config.provider);
|
||||
let key = pstr_meta::settings::api_key(ProviderId::Tmdb);
|
||||
MetadataService::new(&config, key)
|
||||
.inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}"))
|
||||
.ok()
|
||||
@@ -508,12 +508,13 @@ impl Engine {
|
||||
Appearance::default()
|
||||
});
|
||||
|
||||
// A provider with an API key needs it out of the OS credential store,
|
||||
// and that can be a D-Bus round trip to a keyring that has to unlock
|
||||
// first — not something to hold the first frame for. Built on the
|
||||
// blocking pool instead; until it lands, everything that needs the
|
||||
// provider waits for it rather than concluding there is none.
|
||||
let deferred = config.enabled && config.provider.needs_api_key();
|
||||
// The TMDB key, which every provider looks for, comes out of the OS
|
||||
// credential store, and that can be a D-Bus round trip to a keyring
|
||||
// that has to unlock first — not something to hold the first frame
|
||||
// for. Built on the blocking pool instead; until it lands, everything
|
||||
// that needs the provider waits for it rather than concluding there is
|
||||
// none.
|
||||
let deferred = config.enabled;
|
||||
let enrichment = if deferred {
|
||||
Enrichment {
|
||||
config: config.clone(),
|
||||
|
||||
@@ -901,28 +901,35 @@ fn metadata_form(
|
||||
}
|
||||
}
|
||||
|
||||
// TMDB's is the only key there is: the provider's with TMDB chosen,
|
||||
// and with AniList, the art of films TVDB has none for.
|
||||
ui.add_space(theme::space::L);
|
||||
if settings.provider.needs_api_key() {
|
||||
ui.add_space(theme::space::L);
|
||||
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: settings.provider,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted("TMDB API key"));
|
||||
} else {
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
"TMDB API key, optional. With one, films get TMDB's backdrops and logos, \
|
||||
asked for by the film's TMDB id alone.",
|
||||
));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(api_key)
|
||||
.password(true)
|
||||
.hint_text("v3 API key")
|
||||
.desired_width(300.0),
|
||||
);
|
||||
if ui.button("Save key").clicked() {
|
||||
actions.push(Action::SetApiKey {
|
||||
provider: ProviderId::Tmdb,
|
||||
key: std::mem::take(api_key),
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.label(ui::muted(
|
||||
"Stored in your system keyring, never in a config file. Leave the box \
|
||||
empty and save to forget it.",
|
||||
));
|
||||
|
||||
ui.add_space(theme::space::L);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
+16
-16
@@ -546,15 +546,16 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
settings.provider.label(),
|
||||
settings.provider.description()
|
||||
);
|
||||
if settings.provider.needs_api_key() {
|
||||
println!(
|
||||
"api key: {}",
|
||||
match pstr_meta::settings::api_key(settings.provider) {
|
||||
Some(_) => "stored in the credential store",
|
||||
None => "not set — run `pstr metadata set --api-key`",
|
||||
}
|
||||
);
|
||||
}
|
||||
let stored_key = pstr_meta::settings::api_key(ProviderId::Tmdb).is_some();
|
||||
println!(
|
||||
"tmdb key: {}",
|
||||
match (stored_key, settings.provider.needs_api_key()) {
|
||||
(true, true) => "stored in the credential store",
|
||||
(true, false) => "stored — used for the art of films TVDB has none for",
|
||||
(false, true) => "not set — run `pstr metadata set --api-key`",
|
||||
(false, false) => "not set (optional, for film art)",
|
||||
}
|
||||
);
|
||||
|
||||
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
|
||||
let stored = catalog.all_metadata()?;
|
||||
@@ -594,12 +595,11 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
}
|
||||
|
||||
if api_key {
|
||||
let key = rpassword::prompt_password(format!(
|
||||
"{} API key (empty to forget it): ",
|
||||
settings.provider.label()
|
||||
))
|
||||
.context("read the API key")?;
|
||||
pstr_meta::settings::set_api_key(settings.provider, &key)
|
||||
// TMDB's is the only key there is: the provider with TMDB
|
||||
// chosen, and film art with AniList.
|
||||
let key = rpassword::prompt_password("TMDB API key (empty to forget it): ")
|
||||
.context("read the API key")?;
|
||||
pstr_meta::settings::set_api_key(ProviderId::Tmdb, &key)
|
||||
.context("store the API key")?;
|
||||
}
|
||||
|
||||
@@ -638,7 +638,7 @@ async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
|
||||
|
||||
let service = pstr_meta::MetadataService::new(
|
||||
&settings,
|
||||
pstr_meta::settings::api_key(settings.provider),
|
||||
pstr_meta::settings::api_key(ProviderId::Tmdb),
|
||||
)
|
||||
.context("build the metadata provider")?;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::error::{Error, Result};
|
||||
use crate::limiter::RateLimiter;
|
||||
use crate::matching::{Candidate, Query};
|
||||
use crate::provider::Provider;
|
||||
use crate::tmdb::{Tmdb, TmdbId};
|
||||
|
||||
const ENDPOINT: &str = "https://graphql.anilist.co";
|
||||
|
||||
@@ -159,16 +160,20 @@ pub struct AniList {
|
||||
/// Shared by every lookup in flight — see [`crate::limiter`].
|
||||
limiter: RateLimiter,
|
||||
anizip: AniZip,
|
||||
/// Asked by id for the art TVDB has not got, when a TMDB key is stored.
|
||||
tmdb: Option<Tmdb>,
|
||||
}
|
||||
|
||||
impl AniList {
|
||||
/// `language` is the one episode names are wanted in, where ani.zip has
|
||||
/// them in it.
|
||||
pub fn new(http: reqwest::Client, language: String) -> Self {
|
||||
/// them in it. `tmdb` is asked for the art of entries TVDB has none for —
|
||||
/// see [`Tmdb::art`].
|
||||
pub fn new(http: reqwest::Client, language: String, tmdb: Option<Tmdb>) -> Self {
|
||||
Self {
|
||||
anizip: AniZip::new(http.clone(), language),
|
||||
http,
|
||||
limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE),
|
||||
tmdb,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +209,8 @@ impl Provider for AniList {
|
||||
}
|
||||
|
||||
/// The episodes of every entry the library's files are spread across, and
|
||||
/// the series' fanart.
|
||||
/// the series' fanart and logo — from TMDB, by the id ani.zip gives, for
|
||||
/// an entry TVDB has no art for.
|
||||
///
|
||||
/// AniList files a sequel as its own entry — Bleach's seventeenth season is
|
||||
/// four of them, Mushoku Tensei's first is two — so a title's match is
|
||||
@@ -235,6 +241,8 @@ impl Provider for AniList {
|
||||
// The same ids as `visited`, in the order they were read.
|
||||
let mut chain = Vec::new();
|
||||
let mut series_entries = 0;
|
||||
// The match's own: a sequel's art is not the title's.
|
||||
let mut tmdb_id: Option<TmdbId> = None;
|
||||
let mut next = Some(Link {
|
||||
id: first,
|
||||
series: found.kind == TitleKind::Series,
|
||||
@@ -247,6 +255,9 @@ impl Provider for AniList {
|
||||
series_entries
|
||||
});
|
||||
let mapping = self.anizip.mapping(link.id, entry_season).await?;
|
||||
if chain.len() == 1 {
|
||||
tmdb_id = mapping.tmdb;
|
||||
}
|
||||
if enrichment.backdrop_url.is_none() {
|
||||
enrichment.backdrop_url = mapping.fanart;
|
||||
}
|
||||
@@ -268,6 +279,14 @@ impl Provider for AniList {
|
||||
next = self.sequel(link.id, &visited).await?;
|
||||
}
|
||||
|
||||
if let (Some(tmdb), Some(id)) = (&self.tmdb, tmdb_id)
|
||||
&& (enrichment.backdrop_url.is_none() || enrichment.logo_url.is_none())
|
||||
{
|
||||
let art = tmdb.art_or_nothing(id).await;
|
||||
enrichment.backdrop_url = enrichment.backdrop_url.or(art.backdrop);
|
||||
enrichment.logo_url = enrichment.logo_url.or(art.logo);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"{}: {} episodes over {} entries",
|
||||
found.name,
|
||||
|
||||
@@ -20,6 +20,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::limiter::RateLimiter;
|
||||
use crate::tmdb::TmdbId;
|
||||
|
||||
const ENDPOINT: &str = "https://api.ani.zip/mappings";
|
||||
|
||||
@@ -38,6 +39,8 @@ pub struct Mapping {
|
||||
pub fanart: Option<String>,
|
||||
/// TVDB's clear logo: the series' name as lettering, a transparent PNG.
|
||||
pub logo: Option<String>,
|
||||
/// The same entry on TMDB, where TVDB has none of the films.
|
||||
pub tmdb: Option<TmdbId>,
|
||||
}
|
||||
|
||||
pub struct AniZip {
|
||||
@@ -92,6 +95,31 @@ struct Payload {
|
||||
episodes: std::collections::BTreeMap<String, Episode>,
|
||||
#[serde(default)]
|
||||
images: Vec<Image>,
|
||||
mappings: Option<Ids>,
|
||||
}
|
||||
|
||||
/// The entry's ids elsewhere.
|
||||
#[derive(Deserialize)]
|
||||
struct Ids {
|
||||
/// AniList's format, upper-cased: `TV`, `MOVIE`, `OVA`…
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
#[serde(default, deserialize_with = "lenient_number")]
|
||||
themoviedb_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Ids {
|
||||
/// The TMDB entry, where its kind says which list the id is from. An OVA
|
||||
/// or a special is filed either way on TMDB, so it is left alone rather
|
||||
/// than guessed.
|
||||
fn tmdb(&self) -> Option<TmdbId> {
|
||||
let id = self.themoviedb_id?;
|
||||
match self.kind.as_deref()? {
|
||||
"MOVIE" => Some(TmdbId::Movie(id)),
|
||||
"TV" | "TV_SHORT" | "ONA" => Some(TmdbId::Tv(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -135,6 +163,7 @@ impl Payload {
|
||||
};
|
||||
let fanart = image("Fanart");
|
||||
let logo = image("Clearlogo");
|
||||
let tmdb = self.mappings.as_ref().and_then(Ids::tmdb);
|
||||
|
||||
let episodes = self
|
||||
.episodes
|
||||
@@ -173,6 +202,7 @@ impl Payload {
|
||||
episodes,
|
||||
fanart,
|
||||
logo,
|
||||
tmdb,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,5 +352,32 @@ mod tests {
|
||||
assert!(mapping.episodes.is_empty());
|
||||
assert_eq!(mapping.fanart, None);
|
||||
assert_eq!(mapping.logo, None);
|
||||
assert_eq!(mapping.tmdb, None);
|
||||
}
|
||||
|
||||
/// Paprika: a film with no TVDB art at all, and a TMDB id written as a
|
||||
/// string.
|
||||
#[test]
|
||||
fn a_films_tmdb_id_is_kept_as_a_film() {
|
||||
let mapping = serde_json::from_str::<Payload>(
|
||||
r#"{"images": [], "mappings": {"type": "MOVIE", "themoviedb_id": "4977"}}"#,
|
||||
)
|
||||
.expect("parse")
|
||||
.into_mapping("en", None);
|
||||
assert_eq!(mapping.tmdb, Some(TmdbId::Movie(4977)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_tmdb_id_is_kept_and_a_specials_is_not() {
|
||||
let tmdb = |kind: &str| {
|
||||
serde_json::from_str::<Payload>(&format!(
|
||||
r#"{{"mappings": {{"type": "{kind}", "themoviedb_id": 209867}}}}"#
|
||||
))
|
||||
.expect("parse")
|
||||
.into_mapping("en", Some(1))
|
||||
.tmdb
|
||||
};
|
||||
assert_eq!(tmdb("TV"), Some(TmdbId::Tv(209867)));
|
||||
assert_eq!(tmdb("OVA"), None);
|
||||
}
|
||||
}
|
||||
@@ -141,29 +141,33 @@ pub struct MetadataService {
|
||||
impl MetadataService {
|
||||
/// Build a service for `config`, asking [`chain_of`] its provider.
|
||||
///
|
||||
/// `api_key` is only consulted for a provider that needs one; for AniList it
|
||||
/// is ignored, and passing `None` there is not an error.
|
||||
pub fn new(config: &MetadataConfig, mut api_key: Option<String>) -> Result<Self> {
|
||||
/// `tmdb_key` is the stored TMDB key, if there is one. TMDB as the provider
|
||||
/// cannot do without it; AniList uses one for the art of films TVDB has
|
||||
/// none for, and does without it otherwise.
|
||||
pub fn new(config: &MetadataConfig, tmdb_key: Option<String>) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(TIMEOUT)
|
||||
.build()
|
||||
.map_err(Error::Network)?;
|
||||
|
||||
let tmdb_key = tmdb_key.filter(|key| !key.trim().is_empty());
|
||||
let tmdb = || {
|
||||
tmdb_key
|
||||
.clone()
|
||||
.map(|key| Tmdb::new(http.clone(), key, config.language.clone()))
|
||||
.ok_or(Error::MissingApiKey(ProviderId::Tmdb))
|
||||
};
|
||||
let sources = chain_of(config.provider)
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
Ok(match provider {
|
||||
ProviderId::AniList => {
|
||||
Source::AniList(AniList::new(http.clone(), config.language.clone()))
|
||||
}
|
||||
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
|
||||
ProviderId::AniList => Source::AniList(AniList::new(
|
||||
http.clone(),
|
||||
api_key
|
||||
.take()
|
||||
.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
|
||||
config.language.clone(),
|
||||
tmdb().ok(),
|
||||
)),
|
||||
ProviderId::Tmdb => Source::Tmdb(tmdb()?),
|
||||
ProviderId::TvMaze => Source::TvMaze(TvMaze::new(http.clone())),
|
||||
})
|
||||
})
|
||||
@@ -412,7 +416,7 @@ pub struct Outcome {
|
||||
/// A hand-picked entry is never searched for again, not even when `force`d —
|
||||
/// that means "the automatic answers are wrong", and re-deciding the one title
|
||||
/// 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, and `force` enriches it again.
|
||||
///
|
||||
/// `chain` is [`MetadataService::chain`]. Enriching and refreshing go to the
|
||||
/// provider each title is matched to.
|
||||
@@ -435,9 +439,12 @@ pub fn plan(
|
||||
let Some(record) = record.filter(|record| record.metadata.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let fresh = enriched.get(&title.key).is_some_and(|(asked, at)| {
|
||||
*asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS
|
||||
});
|
||||
// Forced, a pinned title is enriched again even when it is fresh: art
|
||||
// a newly saved key reaches is the usual reason to force.
|
||||
let fresh = !force
|
||||
&& enriched.get(&title.key).is_some_and(|(asked, at)| {
|
||||
*asked == record.provider && now.saturating_sub(*at) < MATCH_TTL_SECS
|
||||
});
|
||||
let stale_details = gives_details(record.provider)
|
||||
&& record
|
||||
.metadata
|
||||
@@ -578,6 +585,8 @@ mod tests {
|
||||
..MetadataConfig::default()
|
||||
};
|
||||
assert!(MetadataService::new(&anilist, None).is_ok());
|
||||
// A stored TMDB key is used for art, not demanded.
|
||||
assert!(MetadataService::new(&anilist, Some("key".into())).is_ok());
|
||||
|
||||
let tmdb = MetadataConfig {
|
||||
provider: ProviderId::Tmdb,
|
||||
@@ -741,7 +750,8 @@ mod tests {
|
||||
|
||||
/// 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.
|
||||
/// TVmaze, and a hand-picked one stays pinned — re-enriched when forced,
|
||||
/// never searched for.
|
||||
#[test]
|
||||
fn titles_are_planned_against_the_provider_each_is_matched_to() {
|
||||
let miss = MetadataRecord {
|
||||
@@ -770,10 +780,16 @@ mod tests {
|
||||
vec![("anilist miss", "match"), ("on tvmaze", "enrich")]
|
||||
);
|
||||
|
||||
// Forced, the pinned title is not searched for, but is enriched again
|
||||
// although it is fresh.
|
||||
let work = plan(titles, &stored, &enriched, ANILIST, true);
|
||||
assert_eq!(
|
||||
kinds(&work),
|
||||
vec![("anilist miss", "match"), ("on tvmaze", "match")]
|
||||
vec![
|
||||
("anilist miss", "match"),
|
||||
("on tvmaze", "match"),
|
||||
("pinned", "enrich")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+200
-11
@@ -9,6 +9,11 @@
|
||||
//! not evidence enough to pick an endpoint. The `media_type` on each result is
|
||||
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
|
||||
//! anyway.
|
||||
//!
|
||||
//! A stored key is also used with AniList chosen, by id alone: ani.zip names
|
||||
//! the TMDB entry an AniList match is, and [`Tmdb::art`] fetches its backdrop
|
||||
//! and logo — what the films TVDB has no art for are missing. No title is
|
||||
//! searched for that way.
|
||||
|
||||
use pstr_core::library::{Title, TitleKind};
|
||||
use pstr_core::metadata::{Enrichment, EpisodeMetadata, ProviderId, TitleMetadata};
|
||||
@@ -34,13 +39,16 @@ const STILL_SIZE: &str = "w300";
|
||||
|
||||
/// Where TMDB serves artwork from, and the sizes worth asking for.
|
||||
///
|
||||
/// `w500` for a poster and `w780` for a backdrop are what the images are drawn
|
||||
/// at here, give or take; `original` is several megabytes of print-resolution
|
||||
/// artwork per title, which is bandwidth spent on pixels no tile can show.
|
||||
/// `w500` for a poster and a logo and `w1280` for a backdrop are what the
|
||||
/// images are drawn at here, give or take — a backdrop fills the width of the
|
||||
/// title page and the library's banner; `original` is several megabytes of
|
||||
/// print-resolution artwork per title, which is bandwidth spent on pixels no
|
||||
/// window shows.
|
||||
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
|
||||
|
||||
const POSTER_SIZE: &str = "w500";
|
||||
const BACKDROP_SIZE: &str = "w780";
|
||||
const BACKDROP_SIZE: &str = "w1280";
|
||||
const LOGO_SIZE: &str = "w500";
|
||||
|
||||
/// TMDB's published ceiling is around fifty requests a second and it no longer
|
||||
/// enforces a daily one. This is well under it: the point of pacing here is a
|
||||
@@ -68,6 +76,30 @@ impl Tmdb {
|
||||
}
|
||||
}
|
||||
|
||||
/// A TMDB entry, by id. Films and television are numbered separately, so the
|
||||
/// id alone does not name one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TmdbId {
|
||||
Movie(u32),
|
||||
Tv(u32),
|
||||
}
|
||||
|
||||
impl TmdbId {
|
||||
fn path(self) -> String {
|
||||
match self {
|
||||
Self::Movie(id) => format!("movie/{id}"),
|
||||
Self::Tv(id) => format!("tv/{id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry's wide picture and its logo.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Art {
|
||||
pub backdrop: Option<String>,
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
|
||||
impl Provider for Tmdb {
|
||||
fn id(&self) -> ProviderId {
|
||||
ProviderId::Tmdb
|
||||
@@ -95,14 +127,25 @@ impl Provider for Tmdb {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The episode list, and nothing else: the match already carried a proper
|
||||
/// The episode list and the logo. The match already carried a proper
|
||||
/// backdrop, and TMDB files every season under one show, so the library's
|
||||
/// seasons need no walking.
|
||||
async fn enrich(&self, _title: &Title, found: &TitleMetadata) -> Result<Enrichment> {
|
||||
let logo_url = match found.remote_id.parse() {
|
||||
Ok(id) => {
|
||||
self.art_or_nothing(match found.kind {
|
||||
TitleKind::Series => TmdbId::Tv(id),
|
||||
TitleKind::Film => TmdbId::Movie(id),
|
||||
})
|
||||
.await
|
||||
.logo
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
Ok(Enrichment {
|
||||
episodes: self.episodes(found).await?,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
logo_url,
|
||||
chain: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -169,7 +212,7 @@ impl Tmdb {
|
||||
still_url: episode
|
||||
.still_path
|
||||
.as_deref()
|
||||
.map(|path| image(STILL_SIZE, path)),
|
||||
.map(|path| image_url(STILL_SIZE, path)),
|
||||
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
|
||||
runtime: episode.runtime.filter(|minutes| *minutes > 0),
|
||||
}
|
||||
@@ -178,6 +221,37 @@ impl Tmdb {
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// The best backdrop and logo TMDB has for `id`.
|
||||
///
|
||||
/// Artwork in the viewer's language, English or Japanese is asked for, and
|
||||
/// artwork with no language at all, which for a backdrop is the one without
|
||||
/// lettering on it. One request.
|
||||
pub async fn art(&self, id: TmdbId) -> Result<Art> {
|
||||
let languages = format!("{},en,ja,null", self.language_code());
|
||||
let images: Images = self
|
||||
.get(
|
||||
&format!("{API_BASE}/{}/images", id.path()),
|
||||
&[("include_image_language", languages.as_str())],
|
||||
)
|
||||
.await?;
|
||||
Ok(images.into_art(self.language_code()))
|
||||
}
|
||||
|
||||
/// [`Tmdb::art`], with a failure logged and taken as no art: the art is
|
||||
/// decoration on an answer that is already complete, and a bad key or a
|
||||
/// slow minute must not stop the episodes beside it being stored.
|
||||
pub async fn art_or_nothing(&self, id: TmdbId) -> Art {
|
||||
self.art(id)
|
||||
.await
|
||||
.inspect_err(|error| tracing::debug!("TMDB art for {id:?}: {error}"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `de` out of `de-DE`: images are tagged by language alone.
|
||||
fn language_code(&self) -> &str {
|
||||
self.language.split('-').next().unwrap_or("en")
|
||||
}
|
||||
|
||||
/// One authenticated GET, paced, with TMDB's failures named.
|
||||
///
|
||||
/// TMDB is far more generous than AniList and a library-sized run rarely
|
||||
@@ -278,6 +352,64 @@ struct EpisodeRow {
|
||||
runtime: Option<u32>,
|
||||
}
|
||||
|
||||
/// `/{movie,tv}/{id}/images`.
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Images {
|
||||
#[serde(default)]
|
||||
backdrops: Vec<Image>,
|
||||
#[serde(default)]
|
||||
logos: Vec<Image>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Image {
|
||||
file_path: String,
|
||||
iso_639_1: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f32,
|
||||
}
|
||||
|
||||
impl Images {
|
||||
fn into_art(self, language: &str) -> Art {
|
||||
// Bare is best for a backdrop: a logo or a title is drawn over it,
|
||||
// and lettering already in the picture fights that.
|
||||
let backdrop = best(&self.backdrops, |image| match image.iso_639_1.as_deref() {
|
||||
None => 2,
|
||||
Some(tag) if tag == language || tag == "en" => 1,
|
||||
Some(_) => 0,
|
||||
});
|
||||
// A logo is lettering, so it is wanted in a language the viewer reads.
|
||||
// Only PNGs: an SVG is nothing the art loaders here can draw.
|
||||
let logos: Vec<&Image> = self
|
||||
.logos
|
||||
.iter()
|
||||
.filter(|image| image.file_path.ends_with(".png"))
|
||||
.collect();
|
||||
let logo = best(&logos, |image| match image.iso_639_1.as_deref() {
|
||||
Some(tag) if tag == language => 3,
|
||||
Some("en") => 2,
|
||||
None => 1,
|
||||
Some(_) => 0,
|
||||
});
|
||||
Art {
|
||||
backdrop: backdrop.map(|image| image_url(BACKDROP_SIZE, &image.file_path)),
|
||||
logo: logo.map(|image| image_url(LOGO_SIZE, &image.file_path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The image `rank` likes most, the better-voted one among equals.
|
||||
fn best<T: std::borrow::Borrow<Image>>(
|
||||
images: &[T],
|
||||
rank: impl Fn(&Image) -> u8,
|
||||
) -> Option<&Image> {
|
||||
images.iter().map(|image| image.borrow()).max_by(|a, b| {
|
||||
rank(a)
|
||||
.cmp(&rank(b))
|
||||
.then(a.vote_average.total_cmp(&b.vote_average))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchResponse {
|
||||
#[serde(default)]
|
||||
@@ -339,8 +471,10 @@ impl Result_ {
|
||||
overview: self.overview.filter(|text| !text.is_empty()),
|
||||
year: year_of(self.first_air_date.or(self.release_date).as_deref()),
|
||||
kind,
|
||||
poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)),
|
||||
backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)),
|
||||
poster_url: self.poster_path.map(|path| image_url(POSTER_SIZE, &path)),
|
||||
backdrop_url: self
|
||||
.backdrop_path
|
||||
.map(|path| image_url(BACKDROP_SIZE, &path)),
|
||||
logo_url: None,
|
||||
rating: self.vote_average.filter(|rating| *rating > 0.0),
|
||||
// `search/multi` carries neither, and a detail request per
|
||||
@@ -368,7 +502,7 @@ impl Result_ {
|
||||
}
|
||||
}
|
||||
|
||||
fn image(size: &str, path: &str) -> String {
|
||||
fn image_url(size: &str, path: &str) -> String {
|
||||
format!("{IMAGE_BASE}/{size}{path}")
|
||||
}
|
||||
|
||||
@@ -442,7 +576,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
series.metadata.backdrop_url.as_deref(),
|
||||
Some("https://image.tmdb.org/t/p/w780/b.jpg")
|
||||
Some("https://image.tmdb.org/t/p/w1280/b.jpg")
|
||||
);
|
||||
|
||||
let film: Result_ = serde_json::from_str(
|
||||
@@ -456,6 +590,61 @@ mod tests {
|
||||
assert_eq!(film.metadata.year, Some(1988));
|
||||
}
|
||||
|
||||
/// Paprika's images as TMDB answers them, trimmed: a bare backdrop beside
|
||||
/// better-voted lettered ones, and logos in three languages and two
|
||||
/// formats.
|
||||
#[test]
|
||||
fn the_bare_backdrop_and_a_png_logo_in_the_viewers_language_are_chosen() {
|
||||
let images: Images = serde_json::from_str(
|
||||
r#"{
|
||||
"backdrops": [
|
||||
{"file_path": "/en.jpg", "iso_639_1": "en", "vote_average": 5.5},
|
||||
{"file_path": "/bare.jpg", "iso_639_1": null, "vote_average": 5.2},
|
||||
{"file_path": "/bare-worse.jpg", "iso_639_1": null, "vote_average": 4.0},
|
||||
{"file_path": "/ja.jpg", "iso_639_1": "ja", "vote_average": 6.0}
|
||||
],
|
||||
"logos": [
|
||||
{"file_path": "/ja.png", "iso_639_1": "ja", "vote_average": 6.0},
|
||||
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 5.0},
|
||||
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 3.0},
|
||||
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("parse");
|
||||
let art = images.into_art("en");
|
||||
assert_eq!(
|
||||
art.backdrop.as_deref(),
|
||||
Some("https://image.tmdb.org/t/p/w1280/bare.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
art.logo.as_deref(),
|
||||
Some("https://image.tmdb.org/t/p/w500/en.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logo_in_the_viewers_own_language_beats_an_english_one() {
|
||||
let images: Images = serde_json::from_str(
|
||||
r#"{"logos": [
|
||||
{"file_path": "/en.png", "iso_639_1": "en", "vote_average": 9.0},
|
||||
{"file_path": "/de.svg", "iso_639_1": "de", "vote_average": 9.0},
|
||||
{"file_path": "/de.png", "iso_639_1": "de", "vote_average": 1.0}
|
||||
]}"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(
|
||||
images.into_art("de").logo.as_deref(),
|
||||
Some("https://image.tmdb.org/t/p/w500/de.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_with_no_images_has_no_art() {
|
||||
let images: Images = serde_json::from_str("{}").expect("parse");
|
||||
assert_eq!(images.into_art("en"), Art::default());
|
||||
}
|
||||
|
||||
/// A rating of zero means "nobody has voted", not "everybody hated it", and
|
||||
/// showing it as 0.0/10 would be a lie about the title.
|
||||
#[test]
|
||||
|
||||
+16
-1
@@ -45,7 +45,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. |
|
||||
| **TMDB** | free key | Film backdrops and logos; live-action TV. Chosen as the provider, or with AniList chosen, asked by id for art alone. |
|
||||
| **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. |
|
||||
@@ -225,6 +225,21 @@ names its episode in dotted words, and `naming::episode_title` now reads them,
|
||||
stopping at the first release tag. A dotted run that no tag ends is not taken.
|
||||
This is the fallback for a title no provider has.
|
||||
|
||||
**Film art from TMDB by id (done).** TVDB has no films, so ani.zip has no
|
||||
fanart or logo for one, and seventeen of the library's titles — nearly all
|
||||
films — had nothing wide to draw. ani.zip does give the TMDB id
|
||||
(`mappings.themoviedb_id`, with `mappings.type` saying whether it is a film's
|
||||
or a show's). With a TMDB key stored and AniList chosen, an entry left without a
|
||||
backdrop or a logo is asked for `/{movie,tv}/{id}/images`: one request, by id,
|
||||
so no title name goes to TMDB. A backdrop without lettering is preferred, and a
|
||||
PNG logo in the viewer's language, then English. An OVA's or a special's id is
|
||||
left alone, since TMDB files those either way. A failure there is logged and
|
||||
the enrichment stored without the art, rather than lost over decoration.
|
||||
|
||||
With TMDB itself chosen, enrichment now makes the same request for the logo,
|
||||
and backdrops are fetched at `w1280` rather than `w780`, since they fill the
|
||||
width of the title page.
|
||||
|
||||
Still to do:
|
||||
|
||||
- **Local matching** against the offline database, so only ids leave the
|
||||
|
||||
@@ -10,6 +10,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 |
|
||||
| TMDB, with AniList chosen | The TMDB ids of matched titles that have no wide picture or logo yet. Never a title's name | Only with **Posters and descriptions** on and a TMDB key saved |
|
||||
|
||||
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
|
||||
|
||||
|
||||
@@ -224,5 +224,8 @@ a video that is playing changes at once.
|
||||
**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).
|
||||
- **TMDB API key:** needed for TMDB, optional with AniList. With a key, films and shows that have
|
||||
no wide picture or logo get TMDB's, asked for by their TMDB id alone. After saving a key,
|
||||
**Match everything again** fetches them for titles already matched.
|
||||
- **Match the library** looks up new titles now. **Match everything again** looks up every title,
|
||||
except those you matched by hand.
|
||||
@@ -637,8 +637,10 @@ final class AppModel {
|
||||
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
|
||||
do {
|
||||
metadataSettings = try await run { engine in
|
||||
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
try engine.setMetadataApiKey(provider: provider, key: apiKey)
|
||||
// TMDB's is the only key: the provider's with TMDB chosen, and
|
||||
// film art's with AniList.
|
||||
if !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
try engine.setMetadataApiKey(provider: .tmdb, key: apiKey)
|
||||
}
|
||||
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||
return try engine.metadataSettings()
|
||||
|
||||
@@ -329,12 +329,12 @@ private struct MetadataSettingsSheet: View {
|
||||
.buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
||||
}
|
||||
}
|
||||
Text(provider == .aniList ? "Anime, and television from TVmaze; 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. With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone." : "Film and television; requires a free TMDB API key.")
|
||||
.textStyle(.bodySmall)
|
||||
if provider == .tmdb {
|
||||
Field(label: "Language", text: $language)
|
||||
Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
|
||||
}
|
||||
Field(label: provider == .aniList ? "TMDB API key (optional)" : current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
|
||||
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user