mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running time from the start, and neither was kept. Enrichment now stores the logo in title_metadata.logo_url (kept across a refresh of the same entry, like the fanart) and episode runtimes in episode_metadata.runtime, TVDB's first and AniDB's length after it. AniList's duration lands in TitleDetails::runtime; TMDB episode runtimes are kept too. Schema v13 adds the two columns and marks AniList titles un-enriched without deleting anything. TitleDetails::revision lets details written before runtime existed refetch by id while the old ones stay on screen, instead of blanking them in the migration as v10 did. Clear logos are padded to a square, so each client crops them to their visible pixels before drawing. The logo replaces the name on the library's banner and the title page (not on a light theme's title page, where lettering made for dark art can vanish). Episode rows show how long they run, in place of the file size on Android and iOS; a film's runtime joins its line of facts.
This commit is contained in:
1 parent
b5ba59b41d
commit
bb5a6638c4
29 files changed
+845
-69
No files matched your search
@@ -77,6 +77,7 @@ fragment entry on Media {
|
||||
genres
|
||||
tags { name rank isMediaSpoiler }
|
||||
episodes
|
||||
duration
|
||||
format
|
||||
status
|
||||
siteUrl
|
||||
@@ -249,6 +250,9 @@ impl Provider for AniList {
|
||||
if enrichment.backdrop_url.is_none() {
|
||||
enrichment.backdrop_url = mapping.fanart;
|
||||
}
|
||||
if enrichment.logo_url.is_none() {
|
||||
enrichment.logo_url = mapping.logo;
|
||||
}
|
||||
// Where two entries both list an episode, the earlier one — the
|
||||
// one closer to the match — keeps it.
|
||||
enrichment.episodes.extend(
|
||||
@@ -308,7 +312,6 @@ impl AniList {
|
||||
Ok(next_in_chain(edges, visited))
|
||||
}
|
||||
|
||||
/// One GraphQL requestimpl AniList {
|
||||
/// One GraphQL request, paced, with AniList's two ways of failing — an HTTP
|
||||
/// status and an `errors` array under a 200 — folded into one.
|
||||
///
|
||||
@@ -560,6 +563,8 @@ struct Media {
|
||||
#[serde(default)]
|
||||
tags: Vec<Tag>,
|
||||
episodes: Option<u32>,
|
||||
/// Minutes per episode, or the film's running time.
|
||||
duration: Option<u32>,
|
||||
format: Option<String>,
|
||||
season: Option<String>,
|
||||
status: Option<String>,
|
||||
@@ -734,6 +739,8 @@ impl Media {
|
||||
.collect(),
|
||||
popularity: self.popularity,
|
||||
status: self.status,
|
||||
runtime: self.duration.filter(|minutes| *minutes > 0),
|
||||
revision: TitleDetails::REVISION,
|
||||
next_episode: self
|
||||
.next_airing_episode
|
||||
.as_ref()
|
||||
@@ -774,6 +781,8 @@ impl Media {
|
||||
// backdrop is ani.zip's fanart, found when the match is
|
||||
// enriched.
|
||||
backdrop_url: None,
|
||||
// ani.zip's too, like the fanart.
|
||||
logo_url: None,
|
||||
rating: self.average_score.map(|score| score / SCORE_SCALE),
|
||||
genres: self.genres,
|
||||
episodes: self.episodes,
|
||||
@@ -1022,6 +1031,7 @@ mod tests {
|
||||
overview: None,
|
||||
still_url: None,
|
||||
air_date: None,
|
||||
runtime: None,
|
||||
};
|
||||
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
|
||||
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
|
||||
|
||||
@@ -36,6 +36,8 @@ pub struct Mapping {
|
||||
pub episodes: Vec<EpisodeMetadata>,
|
||||
/// TVDB's fanart for the whole series: 16:9, usually 1920×1080.
|
||||
pub fanart: Option<String>,
|
||||
/// TVDB's clear logo: the series' name as lettering, a transparent PNG.
|
||||
pub logo: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AniZip {
|
||||
@@ -108,6 +110,12 @@ struct Episode {
|
||||
summary: Option<String>,
|
||||
image: Option<String>,
|
||||
air_date: Option<String>,
|
||||
/// TVDB's running time, in minutes.
|
||||
#[serde(default, deserialize_with = "lenient_number")]
|
||||
runtime: Option<u32>,
|
||||
/// AniDB's, which is there when TVDB's is not.
|
||||
#[serde(default, deserialize_with = "lenient_number")]
|
||||
length: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -119,11 +127,14 @@ struct Image {
|
||||
|
||||
impl Payload {
|
||||
fn into_mapping(self, language: &str, entry_season: Option<u32>) -> Mapping {
|
||||
let fanart = self
|
||||
.images
|
||||
.into_iter()
|
||||
.find(|image| image.cover_type.as_deref() == Some("Fanart"))
|
||||
.and_then(|image| image.url);
|
||||
let image = |kind: &str| {
|
||||
self.images
|
||||
.iter()
|
||||
.find(|image| image.cover_type.as_deref() == Some(kind))
|
||||
.and_then(|image| text(image.url.clone()))
|
||||
};
|
||||
let fanart = image("Fanart");
|
||||
let logo = image("Clearlogo");
|
||||
|
||||
let episodes = self
|
||||
.episodes
|
||||
@@ -150,11 +161,19 @@ impl Payload {
|
||||
overview: text(episode.overview).or_else(|| text(episode.summary)),
|
||||
still_url: text(episode.image),
|
||||
air_date: text(episode.air_date),
|
||||
runtime: episode
|
||||
.runtime
|
||||
.or(episode.length)
|
||||
.filter(|minutes| *minutes > 0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Mapping { episodes, fanart }
|
||||
Mapping {
|
||||
episodes,
|
||||
fanart,
|
||||
logo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,15 +225,16 @@ mod tests {
|
||||
"title": {"en": "Beast Titan", "ja": "獣の巨人", "x-jat": "Kemono no Kyojin"},
|
||||
"overview": "Titans appear inside Wall Rose.",
|
||||
"image": "https://artworks.thetvdb.com/still.jpg",
|
||||
"airDate": "2017-04-01"},
|
||||
"airDate": "2017-04-01", "runtime": 24, "length": 25},
|
||||
"S1": {"seasonNumber": "0", "episodeNumber": "7", "episode": "S1",
|
||||
"title": {"en": "TBA", "x-jat": "Ilse no Techou"},
|
||||
"summary": "An OVA."},
|
||||
"summary": "An OVA.", "length": "30"},
|
||||
"2": {"episode": "2", "title": {"en": "Unplaced"}}
|
||||
},
|
||||
"images": [
|
||||
{"coverType": "Banner", "url": "banner.jpg"},
|
||||
{"coverType": "Fanart", "url": "fanart.jpg"}
|
||||
{"coverType": "Fanart", "url": "fanart.jpg"},
|
||||
{"coverType": "Clearlogo", "url": "clearlogo.png"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
@@ -241,6 +261,8 @@ mod tests {
|
||||
Some("Titans appear inside Wall Rose.")
|
||||
);
|
||||
assert_eq!(first.air_date.as_deref(), Some("2017-04-01"));
|
||||
// TVDB's running time wins over AniDB's.
|
||||
assert_eq!(first.runtime, Some(24));
|
||||
}
|
||||
|
||||
/// A special is numbered in strings and filed under season zero, and no
|
||||
@@ -260,6 +282,8 @@ mod tests {
|
||||
assert_eq!(special.name.as_deref(), Some("Ilse no Techou"));
|
||||
// AniDB's summary stands in for TVDB's missing overview.
|
||||
assert_eq!(special.overview.as_deref(), Some("An OVA."));
|
||||
// And AniDB's length for TVDB's missing running time.
|
||||
assert_eq!(special.runtime, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -285,6 +309,11 @@ mod tests {
|
||||
assert_eq!(mapping(None).fanart.as_deref(), Some("fanart.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clear_logo_is_kept() {
|
||||
assert_eq!(mapping(None).logo.as_deref(), Some("clearlogo.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_ani_zip_has_nothing_for_parses_to_nothing() {
|
||||
let mapping = serde_json::from_str::<Payload>("{}")
|
||||
@@ -292,5 +321,6 @@ mod tests {
|
||||
.into_mapping("en", Some(1));
|
||||
assert!(mapping.episodes.is_empty());
|
||||
assert_eq!(mapping.fanart, None);
|
||||
assert_eq!(mapping.logo, None);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@ mod tests {
|
||||
kind,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
|
||||
@@ -25,7 +25,8 @@ use std::sync::Arc;
|
||||
|
||||
use pstr_core::library::{Title, TitleKind, title_key};
|
||||
use pstr_core::metadata::{
|
||||
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
|
||||
Enrichment, MATCH_TTL_SECS, MetadataConfig, MetadataRecord, ProviderId, TitleDetails,
|
||||
TitleMetadata,
|
||||
};
|
||||
|
||||
use crate::anilist::AniList;
|
||||
@@ -261,8 +262,9 @@ pub enum Work {
|
||||
Match(Title),
|
||||
/// It is already matched; only its enrichment is missing or old.
|
||||
Enrich(Title, Box<TitleMetadata>),
|
||||
/// It is matched, but stored before [`TitleMetadata::details`] existed:
|
||||
/// fetch the same entry again by id — never searched for, so a match the
|
||||
/// It is matched, but stored before [`TitleMetadata::details`] existed or
|
||||
/// by an older [`TitleDetails::REVISION`] of them: fetch the same entry
|
||||
/// again by id — never searched for, so a match the
|
||||
/// viewer picked by hand stays theirs — and enrich it too if `enrich`.
|
||||
Refresh {
|
||||
title: Title,
|
||||
@@ -298,8 +300,8 @@ pub struct Outcome {
|
||||
/// already has a good match but has not been enriched — or was enriched longer
|
||||
/// ago than a match is trusted — needs enriching only, and searching for it
|
||||
/// again would ask the provider something it has already answered. One whose
|
||||
/// match predates its details is fetched again by id, which is one request
|
||||
/// and decides nothing.
|
||||
/// match predates its details, or this build's revision of them, is fetched
|
||||
/// again by id, which is one request and decides nothing.
|
||||
///
|
||||
/// 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
|
||||
@@ -335,7 +337,7 @@ pub fn plan(
|
||||
&& record
|
||||
.metadata
|
||||
.as_ref()
|
||||
.is_some_and(|found| found.details.is_none());
|
||||
.is_some_and(|found| !found.details.as_ref().is_some_and(TitleDetails::is_current));
|
||||
if stale_details {
|
||||
work.push(Work::Refresh {
|
||||
title,
|
||||
@@ -394,14 +396,17 @@ impl MetadataService {
|
||||
enrichment: None,
|
||||
});
|
||||
};
|
||||
// An id the provider no longer has keeps what was stored, with
|
||||
// empty details so it is not asked about on every run.
|
||||
// An id the provider no longer has keeps what was stored, its
|
||||
// details marked current so it is not asked about on every run.
|
||||
let found = self
|
||||
.source
|
||||
.entry(&stored.remote_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| TitleMetadata {
|
||||
details: Some(Default::default()),
|
||||
details: Some(TitleDetails {
|
||||
revision: TitleDetails::REVISION,
|
||||
..stored.details.clone().unwrap_or_default()
|
||||
}),
|
||||
..stored.clone()
|
||||
});
|
||||
let enrichment = match enrich {
|
||||
@@ -506,6 +511,7 @@ mod tests {
|
||||
kind: TitleKind::Series,
|
||||
poster_url: None,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
rating: None,
|
||||
genres: Vec::new(),
|
||||
episodes: None,
|
||||
@@ -621,6 +627,40 @@ mod tests {
|
||||
assert_eq!(kinds(&work), vec![("automatic", "enrich")]);
|
||||
}
|
||||
|
||||
/// Details from an older build are refetched by id, and kept meanwhile;
|
||||
/// details this build wrote are not asked for again.
|
||||
#[test]
|
||||
fn details_from_an_older_revision_are_refreshed_and_current_ones_are_not() {
|
||||
let with = |revision| {
|
||||
let mut record = matched("k", false);
|
||||
if let Some(found) = record.metadata.as_mut() {
|
||||
found.details = Some(TitleDetails {
|
||||
revision,
|
||||
..TitleDetails::default()
|
||||
});
|
||||
}
|
||||
record
|
||||
};
|
||||
let enriched = HashMap::from([("k".to_string(), (ProviderId::AniList, now()))]);
|
||||
let run = |record: MetadataRecord| {
|
||||
let stored = HashMap::from([("k".to_string(), record)]);
|
||||
let work = plan(
|
||||
vec![title("k")],
|
||||
&stored,
|
||||
&enriched,
|
||||
ProviderId::AniList,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
kinds(&work)
|
||||
.into_iter()
|
||||
.map(|(_, kind)| kind.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(run(with(0)), vec!["refresh"]);
|
||||
assert!(run(with(TitleDetails::REVISION)).is_empty());
|
||||
}
|
||||
|
||||
/// A record from the other provider is never usable — switching provider has
|
||||
/// to re-ask, including about titles the previous one found nothing for.
|
||||
#[test]
|
||||
|
||||
@@ -102,6 +102,7 @@ impl Provider for Tmdb {
|
||||
Ok(Enrichment {
|
||||
episodes: self.episodes(found).await?,
|
||||
backdrop_url: None,
|
||||
logo_url: None,
|
||||
chain: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -170,6 +171,7 @@ impl Tmdb {
|
||||
.as_deref()
|
||||
.map(|path| image(STILL_SIZE, path)),
|
||||
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
|
||||
runtime: episode.runtime.filter(|minutes| *minutes > 0),
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -272,6 +274,8 @@ struct EpisodeRow {
|
||||
overview: Option<String>,
|
||||
still_path: Option<String>,
|
||||
air_date: Option<String>,
|
||||
/// Minutes.
|
||||
runtime: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -337,6 +341,7 @@ impl Result_ {
|
||||
kind,
|
||||
poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)),
|
||||
backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)),
|
||||
logo_url: None,
|
||||
rating: self.vote_average.filter(|rating| *rating > 0.0),
|
||||
// `search/multi` carries neither, and a detail request per
|
||||
// candidate would be a round-trip per *result* rather than per
|
||||
|
||||
Reference in new issue
Block a user