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:
nvrl committed 2026-10-29 16:11:24 +01:00
1 parent b5ba59b41d
commit bb5a6638c4
29 files changed
+845 -69

No files matched your search

@@ -137,6 +137,17 @@ internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = n
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
?: 0
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
internal fun formatRuntime(minutes: UInt): String {
val hours = minutes / 60u
val rest = minutes % 60u
return when {
hours == 0u -> "$rest min"
rest == 0u -> "$hours h"
else -> "$hours h $rest min"
}
}
internal fun formatBytes(bytes: ULong): String {
val value = bytes.toDouble()
return when {
@@ -48,6 +48,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
@@ -640,13 +641,21 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
),
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(
// Always over the dark scrim above, so a logo made for dark art
// reads here whatever the theme.
RemoteLogo(
title.logoUrl,
title.displayName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Modifier.fillMaxWidth(0.6f).heightIn(max = 72.dp).padding(bottom = 6.dp),
) {
Text(
title.displayName,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Text(
listOfNotNull(
title.rating?.let { "★ %.1f".format(it) },
@@ -112,6 +112,84 @@ internal fun RemoteArtwork(
}
}
/**
* A title's logo — its name as lettering — in place of [fallback], once it has
* loaded. [fallback] stands until then, and for good when there is none, so a
* title without a logo looks as it always did and one with a logo only gains.
*
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
* logo to a square, and the padding would otherwise decide its size and place.
*/
@Composable
internal fun RemoteLogo(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: @Composable () -> Unit,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, enabled) {
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
}
when (val loaded = logo) {
null -> fallback()
else -> Image(
loaded,
description,
modifier,
alignment = Alignment.BottomStart,
contentScale = ContentScale.Fit,
)
}
}
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
val requested = url ?: return null
val key = "logo:$requested"
decoded.get(key)?.let { return it }
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
}
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
private fun trimTransparent(bitmap: Bitmap): Bitmap {
if (!bitmap.hasAlpha()) return bitmap
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
}
/**
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
* row-major image of [width] that are not (nearly) transparent; `null` when
* none are. Below the threshold is anti-aliasing haze, not lettering.
*/
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
var left = Int.MAX_VALUE
var top = Int.MAX_VALUE
var right = -1
var bottom = -1
for (index in pixels.indices) {
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
val x = index % width
val y = index / width
if (x < left) left = x
if (x > right) right = x
if (y < top) top = y
if (y > bottom) bottom = y
}
return if (right < 0) null else intArrayOf(left, top, right, bottom)
}
private const val VISIBLE_ALPHA = 8
/** A logo is drawn a few hundred pixels wide at most. */
private const val LOGO_EDGE = 800
private sealed interface ArtworkLoad {
data object Pending : ArtworkLoad
data object Missing : ArtworkLoad
@@ -92,6 +92,7 @@ import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
@@ -211,7 +212,21 @@ internal fun TitleScreen(
}
item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
val heading = @Composable {
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
}
// A light page keeps the name: a clear logo is lettering
// made for dark art, white as often as not.
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 4.dp),
heading,
)
} else {
heading()
}
// The other names it goes by: the share's when the shown one
// is the provider's, and the original-language one.
listOfNotNull(
@@ -224,6 +239,8 @@ internal fun TitleScreen(
listOfNotNull(
title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
// A series' running time is its episodes', which each row says.
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
title.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "),
@@ -625,7 +642,9 @@ private fun EpisodeRow(
Text(
listOfNotNull(
numbering,
episode.size?.let(::formatBytes),
// How long it runs says more than how big it is; the
// size is what there is when no provider said.
episode.runtime?.let(::formatRuntime) ?: episode.size?.let(::formatBytes),
episode.airDate,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -0,0 +1,30 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LogoAndRuntimeTest {
@Test
fun `a padded logo is bounded by its lettering and not its haze`() {
val width = 10
val pixels = IntArray(width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
pixels[5 * width + 9] = 0x03FFFFFF
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
}
@Test
fun `a logo with nothing visible has no bounds`() {
assertNull(opaqueBounds(IntArray(16), 4))
}
@Test
fun `a running time reads in hours and minutes`() {
assertEquals("24 min", formatRuntime(24u))
assertEquals("2 h", formatRuntime(120u))
assertEquals("2 h 4 min", formatRuntime(124u))
}
}
+17
View File
@@ -517,6 +517,9 @@ pub struct EpisodeRecord {
/// What a continue-watching shelf orders by — "where I was last" is a
/// question about time, and progress alone cannot answer it.
pub last_played: i64,
/// The provider's running time, in minutes.
#[uniffi(default = None)]
pub runtime: Option<u32>,
}
#[derive(Debug, Clone, uniffi::Record)]
@@ -574,6 +577,13 @@ pub struct TitleRecord {
/// episode finished last. `None` keeps it off the shelf. See
/// `pstr_core::library::Title::continue_with`.
pub continue_index: Option<u32>,
/// The title's name as lettering, a transparent PNG with its source's
/// padding still around it — trim it before drawing.
#[uniffi(default = None)]
pub logo_url: Option<String>,
/// Minutes: an episode's length for a series, the running time of a film.
#[uniffi(default = None)]
pub runtime: Option<u32>,
}
/// Playback preferences that outlive one file, one title and one launch.
@@ -3649,6 +3659,8 @@ fn title_record(
next_episode: None,
next_airing_at: None,
franchise: Vec::new(),
logo_url: metadata.and_then(|found| found.logo_url.clone()),
runtime: details.and_then(|details| details.runtime),
continue_index: title.continue_with().and_then(|episode| {
let at = title.position_of(&episode.node.share_id, &episode.node.link_id)?;
u32::try_from(at).ok()
@@ -3730,6 +3742,8 @@ fn title_metadata(record: MatchRecord) -> TitleMetadata {
kind: title_kind(record.kind),
poster_url: record.poster_url,
backdrop_url: record.backdrop_url,
// A search answer carries none; enriching the choice finds it.
logo_url: None,
rating: record.rating.map(|rating| rating as f32),
genres: record.genres,
episodes: record.episode_count,
@@ -3773,6 +3787,7 @@ fn episode_record(
still_url: named.and_then(|found| found.still_url.clone()),
air_date: named.and_then(|found| found.air_date.clone()),
last_played: episode.last_played(),
runtime: named.and_then(|found| found.runtime),
}
}
@@ -4110,6 +4125,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: Some("https://example.test/poster.jpg".to_owned()),
backdrop_url: Some("https://example.test/backdrop.jpg".to_owned()),
logo_url: None,
rating: Some(8.7),
genres: vec!["Action".to_owned(), "Sci-Fi".to_owned()],
episodes: Some(26),
@@ -4204,6 +4220,7 @@ mod tests {
kind,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+8 -1
View File
@@ -2812,12 +2812,18 @@ fn changed_art(
.map(|(url, _)| url.to_owned()),
found.and_then(|metadata| metadata.poster_url.clone()),
found.and_then(|metadata| metadata.wide_art().map(str::to_owned)),
found.and_then(|metadata| metadata.logo_url.clone()),
]
};
let mut keys: Vec<String> = Vec::new();
for key in before.keys().chain(after.keys()) {
let (old, new) = (urls(before, key), urls(after, key));
let texture_keys = [key.clone(), ui::poster_key(key), ui::wide_key(key)];
let texture_keys = [
key.clone(),
ui::poster_key(key),
ui::wide_key(key),
ui::logo_key(key),
];
for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) {
if old != new {
keys.push(texture);
@@ -3073,6 +3079,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: Some(url.into()),
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+105 -6
View File
@@ -170,6 +170,21 @@ const THUMBNAIL_MAX_EDGE: u32 = 640;
/// best there is.
pub const WIDE_MAX_EDGE: u32 = 1920;
/// The longest edge of a title's logo once its padding is trimmed. Drawn at
/// most a few hundred pixels wide; this leaves room for a 2× display.
const LOGO_MAX_EDGE: u32 = 800;
/// How a fetched picture is fitted before it becomes a texture.
#[derive(Clone, Copy)]
enum Fit {
/// Scaled down to this edge, nothing else.
Scaled(u32),
/// A logo: cropped to what is not transparent, then scaled to
/// [`LOGO_MAX_EDGE`]. TVDB pads every clear logo to a square, and the
/// padding would otherwise decide where it sits and how big it is drawn.
Logo,
}
/// What the background side tells the UI.
pub enum Event {
/// The share list changed on disk.
@@ -2794,18 +2809,29 @@ impl Engine {
/// pixels rather than a tile's 640. A 640 px still stretched to 1600 was
/// most of why those looked soft.
pub fn request_art(&self, title_key: String, url: String, max_edge: u32) {
self.fetch_art(title_key, url, Fit::Scaled(max_edge));
}
/// [`Engine::request_poster`], for a title's logo: trimmed to its
/// lettering. See [`Fit::Logo`].
pub fn request_logo(&self, key: String, url: String) {
self.fetch_art(key, url, Fit::Logo);
}
fn fetch_art(&self, title_key: String, url: String, fit: Fit) {
let engine = self.clone();
// A size of its own on disk: the tile-sized copy of the same URL is
// already there under the plain name, scaled down.
let name = match max_edge {
THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)),
edge => format!("{}-{edge}.img", digest(&url)),
let name = match fit {
Fit::Scaled(THUMBNAIL_MAX_EDGE) => format!("{}.img", digest(&url)),
Fit::Scaled(edge) => format!("{}-{edge}.img", digest(&url)),
Fit::Logo => format!("{}-logo.img", digest(&url)),
};
let path = self.dirs.poster_cache().join(name);
self.runtime.spawn(async move {
if let Ok(bytes) = tokio::fs::read(&path).await {
match decode_scaled(&bytes, max_edge) {
match decode_fitted(&bytes, fit) {
Ok(decoded) => {
if let Some(smaller) = &decoded.smaller {
store_image(&path, smaller).await;
@@ -2832,7 +2858,7 @@ impl Engine {
}
};
match decode_scaled(&bytes, max_edge) {
match decode_fitted(&bytes, fit) {
Ok(decoded) => {
store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await;
engine.emit(Event::Poster {
@@ -2984,11 +3010,31 @@ fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> {
/// [`decode_thumbnail`], to `max_edge` rather than a tile's size.
fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
let decoded = image::load_from_memory(bytes)?;
decode_fitted(bytes, Fit::Scaled(max_edge))
}
/// [`decode_scaled`], fitted as `fit` says. A trimmed logo is handed back
/// for the cache like a scaled picture is, so the trim is paid once.
fn decode_fitted(bytes: &[u8], fit: Fit) -> anyhow::Result<Decoded> {
let mut decoded = image::load_from_memory(bytes)?;
let mut changed = false;
let max_edge = match fit {
Fit::Scaled(edge) => edge,
Fit::Logo => {
if let Some(trimmed) = trim_alpha(&decoded) {
decoded = trimmed;
changed = true;
}
LOGO_MAX_EDGE
}
};
let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge {
let scaled = decoded.thumbnail(max_edge, max_edge);
let encoded = encode_for_cache(&scaled);
(scaled, encoded)
} else if changed {
let encoded = encode_for_cache(&decoded);
(decoded, encoded)
} else {
(decoded, None)
};
@@ -3000,6 +3046,36 @@ fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> {
})
}
/// `image` cropped to the pixels that are not (nearly) transparent, or
/// `None` when there is nothing to crop — no padding, no alpha, or nothing
/// but padding.
fn trim_alpha(image: &image::DynamicImage) -> Option<image::DynamicImage> {
/// Below this the pixel is anti-aliasing haze or compression noise, not
/// lettering.
const VISIBLE: u8 = 8;
if !image.color().has_alpha() {
return None;
}
let rgba = image.to_rgba8();
let (mut left, mut top, mut right, mut bottom) = (u32::MAX, u32::MAX, 0, 0);
for (x, y, pixel) in rgba.enumerate_pixels() {
if pixel[3] > VISIBLE {
left = left.min(x);
top = top.min(y);
right = right.max(x);
bottom = bottom.max(y);
}
}
if left > right {
return None;
}
let (width, height) = (right - left + 1, bottom - top + 1);
if (width, height) == (rgba.width(), rgba.height()) {
return None;
}
Some(image.crop_imm(left, top, width, height))
}
/// Encode a scaled picture for the cache: PNG when it has transparency to keep
/// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache
/// holding the original — slower to load, never wrong.
@@ -3261,6 +3337,29 @@ mod image_cache_tests {
cache.upload(&ctx);
assert!(cache.textures.is_empty());
}
/// TVDB pads a clear logo to a square; trimmed, it is only its lettering.
#[test]
fn a_padded_logo_is_trimmed_to_its_lettering() {
let mut padded = image::RgbaImage::new(100, 100);
for x in 20..80 {
for y in 40..60 {
padded.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
}
}
// Haze below the visibility threshold does not count.
padded.put_pixel(2, 2, image::Rgba([255, 255, 255, 3]));
let trimmed = trim_alpha(&image::DynamicImage::ImageRgba8(padded)).expect("trimmed");
assert_eq!((trimmed.width(), trimmed.height()), (60, 20));
}
#[test]
fn a_picture_with_nothing_to_trim_is_left_alone() {
let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::new(10, 10));
assert!(trim_alpha(&opaque).is_none());
let blank = image::DynamicImage::ImageRgba8(image::RgbaImage::new(10, 10));
assert!(trim_alpha(&blank).is_none());
}
}
#[cfg(test)]
+11 -9
View File
@@ -274,15 +274,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(theme::space::XXS);
ui.add(
egui::Label::new(
theme::Role::Display
.rich(art.name(title))
.strong()
.color(theme::text()),
)
.truncate(),
);
if ui::logo_heading(ui, art, title, 110.0).is_none() {
ui.add(
egui::Label::new(
theme::Role::Display
.rich(art.name(title))
.strong()
.color(theme::text()),
)
.truncate(),
);
}
},
);
+61
View File
@@ -132,6 +132,20 @@ impl Art<'_> {
self.of(title)
}
/// The title's name as lettering, trimmed to it, when the provider has
/// one and it has arrived. Drawn over wide art in place of the name.
pub fn logo(&mut self, title: &Title) -> Option<egui::TextureHandle> {
let url = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.logo_url.clone())?;
let key = logo_key(&title.key);
let engine = self.engine;
self.posters
.texture(&key, || engine.request_logo(key.clone(), url))
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
@@ -173,6 +187,46 @@ pub fn wide_key(title_key: &str) -> String {
format!("wide:{title_key}")
}
/// A title's logo in place of its name in Display type, when there is one to
/// draw; `None` leaves the caller to set the name as it does.
///
/// At most `max_height` tall and as wide as the space allows, up to 420 px —
/// a logo the width of the column shouts. A light palette keeps the name: a
/// clear logo is lettering made for dark art, white as often as not, and over
/// a near-white veil it is gone.
pub fn logo_heading(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
max_height: f32,
) -> Option<egui::Response> {
if theme::palette().light {
return None;
}
let texture = art.logo(title)?;
let aspect = texture.aspect_ratio().max(0.1);
let width = ui.available_width().min(420.0).min(max_height * aspect);
let size = egui::vec2(width, width / aspect);
Some(
ui.add(egui::Image::new((texture.id(), size)))
.on_hover_text(art.name(title)),
)
}
/// The texture key of a title's logo.
pub fn logo_key(title_key: &str) -> String {
format!("logo:{title_key}")
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
pub fn format_runtime(minutes: u32) -> String {
match (minutes / 60, minutes % 60) {
(0, minutes) => format!("{minutes} min"),
(hours, 0) => format!("{hours} h"),
(hours, minutes) => format!("{hours} h {minutes} min"),
}
}
/// "just now", "5 min ago", "3 h ago", "2 days ago": how long before `now`
/// the Unix time `at` was.
pub fn ago(at: i64, now: i64) -> String {
@@ -938,6 +992,13 @@ mod tests {
use super::*;
#[test]
fn a_running_time_reads_in_hours_and_minutes() {
assert_eq!(format_runtime(24), "24 min");
assert_eq!(format_runtime(120), "2 h");
assert_eq!(format_runtime(124), "2 h 4 min");
}
#[test]
fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00");
+22 -6
View File
@@ -323,7 +323,9 @@ fn header(
// Text in a column a person can read, with the picture to the
// right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0));
ui.label(theme::Role::Display.rich(&shown).strong());
if ui::logo_heading(ui, art, title, 120.0).is_none() {
ui.label(theme::Role::Display.rich(&shown).strong());
}
// Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should
// be able to tell what the match actually matched, and find the
@@ -629,6 +631,15 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(year) = title.year {
parts.push(year.to_string());
}
// A film's running time is what it costs to start it. A series' is its
// episodes', which each row says.
if title.kind == TitleKind::Film
&& let Some(minutes) = found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.runtime)
{
parts.push(ui::format_runtime(minutes));
}
if title.kind == TitleKind::Series {
if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season"));
@@ -701,6 +712,7 @@ fn episode_row(
found.name.clone(),
found.overview.clone(),
found.air_date.clone(),
found.runtime,
)
});
let watched = episode.is_watched();
@@ -732,7 +744,7 @@ fn episode_row(
.unwrap_or_default();
let name = found
.as_ref()
.and_then(|(name, _, _)| name.clone())
.and_then(|(name, ..)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let mut job = egui::text::LayoutJob::default();
if !numbering.is_empty() {
@@ -762,9 +774,10 @@ fn episode_row(
ui.label(ui::muted(details(
episode,
found.as_ref().and_then(|f| f.2.as_deref()),
found.as_ref().and_then(|f| f.3),
)));
if let Some((_, Some(overview), _)) = &found {
if let Some((_, Some(overview), ..)) = &found {
let mut job = egui::text::LayoutJob::simple(
overview.clone(),
theme::Role::Caption.font(),
@@ -836,9 +849,9 @@ fn episode_row(
ui.add_space(theme::space::XXS);
}
/// The grey line under an episode's name: when it aired, how big it is, and
/// where it would resume.
fn details(episode: &Episode, air_date: Option<&str>) -> String {
/// The grey line under an episode's name: when it aired, how long it runs,
/// how big it is, and where it would resume.
fn details(episode: &Episode, air_date: Option<&str>, runtime: Option<u32>) -> String {
let mut parts = Vec::new();
if let Some(at) = episode.resume_at() {
let left = episode
@@ -853,6 +866,9 @@ fn details(episode: &Episode, air_date: Option<&str>) -> String {
if let Some(date) = air_date {
parts.push(date.to_owned());
}
if let Some(minutes) = runtime {
parts.push(ui::format_runtime(minutes));
}
match episode.node.size {
// A file the share says is empty is one that will not play — an upload
// that never finished, usually. Worth saying on the row rather than
+1
View File
@@ -312,6 +312,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: Some(rating),
genres: Vec::new(),
episodes: None,
+127 -15
View File
@@ -24,7 +24,7 @@ use crate::metadata::{
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 12;
pub const SCHEMA_VERSION: i64 = 13;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -242,6 +242,19 @@ CREATE TABLE sidecars (
CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id);
"#;
/// Title logos and running times.
///
/// ani.zip sent both all along and neither was kept. Every AniList title is
/// marked un-enriched so the next match run asks for them; nothing is deleted,
/// and the stored episodes and art stay on screen until the answer replaces
/// them. The title runtime lives in `details`, which is refetched by
/// `TitleDetails::revision` rather than blanked here.
const MIGRATION_V13: &str = r#"
ALTER TABLE title_metadata ADD COLUMN logo_url TEXT;
ALTER TABLE episode_metadata ADD COLUMN runtime INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// How old a share's last crawl may be before an app looks again on its own.
/// Long enough that a launch rarely costs a crawl; short enough that a new
/// episode turns up the same day.
@@ -497,6 +510,12 @@ impl Catalog {
tx.pragma_update(None, "user_version", 12)?;
tx.commit()?;
}
if version < 13 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V13)?;
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
Ok(())
}
@@ -1037,7 +1056,7 @@ impl Catalog {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual, details, chain
manual, details, chain, logo_url
FROM title_metadata",
)?;
let rows = statement.query_map([], row_to_metadata)?;
@@ -1058,7 +1077,7 @@ impl Catalog {
.query_row(
"SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes,
url, manual, details, chain
url, manual, details, chain, logo_url
FROM title_metadata WHERE title_key = ?1",
params![title_key],
row_to_metadata,
@@ -1103,9 +1122,9 @@ impl Catalog {
&"INSERT INTO title_metadata (
title_key, provider, matched, fetched_at, remote_id, name, original_name,
overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url,
manual, details
manual, details, logo_url
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
?17, ?18)
?17, ?18, ?19)
ON CONFLICT (title_key) DO UPDATE SET
provider = excluded.provider,
matched = excluded.matched,
@@ -1120,6 +1139,9 @@ impl Catalog {
backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url,
title_metadata.backdrop_url)
ELSE excluded.backdrop_url END,
logo_url = CASE WHEN same THEN COALESCE(excluded.logo_url,
title_metadata.logo_url)
ELSE excluded.logo_url END,
rating = excluded.rating,
genres = excluded.genres,
episodes = excluded.episodes,
@@ -1153,6 +1175,7 @@ impl Catalog {
data.and_then(|data| data.url.as_deref()),
record.manual as i64,
details,
data.and_then(|data| data.logo_url.as_deref()),
],
)?;
Ok(())
@@ -1220,7 +1243,8 @@ impl Catalog {
replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?;
tx.execute(
"UPDATE title_metadata
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5
SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5,
logo_url = COALESCE(?6, logo_url)
WHERE title_key = ?1 AND provider = ?2",
params![
title_key,
@@ -1228,6 +1252,7 @@ impl Catalog {
fetched_at,
enrichment.backdrop_url.as_deref(),
enrichment.chain.join("\n"),
enrichment.logo_url.as_deref(),
],
)?;
tx.commit()?;
@@ -1241,7 +1266,7 @@ impl Catalog {
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
let mut statement = self.conn.prepare(
"SELECT title_key, season, number, name, overview, still_url, air_date,
absolute, entry_season, entry_number
absolute, entry_season, entry_number, runtime
FROM episode_metadata",
)?;
let rows = statement.query_map([], |row| {
@@ -1259,6 +1284,7 @@ impl Catalog {
overview: row.get(4)?,
still_url: row.get(5)?,
air_date: row.get(6)?,
runtime: row.get(10)?,
},
))
})?;
@@ -1365,8 +1391,8 @@ fn replace_episodes(
"INSERT OR REPLACE INTO episode_metadata (
title_key, season, number, provider, fetched_at,
name, overview, still_url, air_date,
absolute, entry_season, entry_number
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
absolute, entry_season, entry_number, runtime
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)?;
for episode in episodes {
insert.execute(params![
@@ -1382,6 +1408,7 @@ fn replace_episodes(
episode.absolute,
episode.entry_season,
episode.entry_number,
episode.runtime,
])?;
}
Ok(())
@@ -1432,6 +1459,7 @@ fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataR
kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()),
poster_url: row.get(10)?,
backdrop_url: row.get(11)?,
logo_url: row.get(19)?,
rating: row.get(12)?,
genres: genres
.unwrap_or_default()
@@ -1981,6 +2009,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: Some("https://example.invalid/p.jpg".into()),
backdrop_url: None,
logo_url: None,
rating: Some(8.4),
genres: vec!["Action".into(), "Drama".into()],
episodes: Some(25),
@@ -2076,6 +2105,7 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}],
)
.expect("store episodes");
@@ -2168,6 +2198,7 @@ mod tests {
overview: Some("Ed and Al…".into()),
still_url: None,
air_date: Some("2010-11-14".into()),
runtime: None,
},
EpisodeMetadata {
season: Some(1),
@@ -2179,6 +2210,7 @@ mod tests {
overview: None,
still_url: Some("still.jpg".into()),
air_date: None,
runtime: None,
},
];
catalog
@@ -2221,6 +2253,7 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: Some(24),
};
catalog
.set_episode_metadata(
@@ -2264,6 +2297,7 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}],
)
.expect("store episodes");
@@ -2320,6 +2354,7 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}],
)
.expect("store episodes");
@@ -2336,16 +2371,18 @@ mod tests {
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
/// Enrichment adds a backdrop when it found one and otherwise leaves the
/// match's own alone; a new match makes the title un-enriched again.
/// Enrichment adds a backdrop or a logo when it found one and otherwise
/// leaves the match's own alone; a new match makes the title un-enriched
/// again, and refreshing the same one keeps both.
#[test]
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
fn enrichment_keeps_the_art_it_did_not_replace_and_a_different_match_resets_it() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
backdrop_url: Some("old.jpg".into()),
logo_url: Some("old.png".into()),
..found()
}),
fetched_at: 1,
@@ -2357,8 +2394,9 @@ mod tests {
.metadata("attack on titan")
.expect("read")
.and_then(|record| record.metadata)
.and_then(|found| found.backdrop_url)
.map(|found| (found.backdrop_url, found.logo_url))
};
let art = |backdrop: &str, logo: &str| Some((Some(backdrop.into()), Some(logo.into())));
catalog
.set_enrichment(
@@ -2368,16 +2406,26 @@ mod tests {
&Enrichment::default(),
)
.expect("enrich");
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg"));
assert_eq!(backdrop(&catalog), art("old.jpg", "old.png"));
let enrichment = Enrichment {
backdrop_url: Some("fanart.jpg".into()),
logo_url: Some("clearlogo.png".into()),
..Enrichment::default()
};
catalog
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
.expect("enrich again");
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
// A refresh of the same entry answers with no art of its own.
catalog
.set_metadata(&MetadataRecord {
metadata: Some(found()),
..record.clone()
})
.expect("refresh");
assert_eq!(backdrop(&catalog), art("fanart.jpg", "clearlogo.png"));
catalog
.set_metadata(&MetadataRecord {
@@ -2414,8 +2462,10 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}],
backdrop_url: None,
logo_url: None,
chain: Vec::new(),
};
catalog
@@ -2473,6 +2523,7 @@ mod tests {
overview: None,
still_url: None,
air_date: Some(air_date.into()),
runtime: None,
};
let enrichment = Enrichment {
episodes: vec![
@@ -2480,6 +2531,7 @@ mod tests {
episode(2, None, "2026-10-09"),
],
backdrop_url: None,
logo_url: None,
chain: Vec::new(),
};
catalog
@@ -2538,8 +2590,10 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}],
backdrop_url: Some("fanart.jpg".into()),
logo_url: None,
chain: vec!["457".into(), "20595".into()],
};
catalog
@@ -2699,6 +2753,63 @@ mod tests {
assert!(catalog.sidecars("s", "a").expect("read").is_empty());
}
/// Schema v13 asks every AniList title to be enriched again, for the
/// logos and running times it never kept — and deletes nothing: the
/// match, its details and its episode names stay on screen meanwhile.
#[test]
fn upgrading_to_v13_re_enriches_anilist_titles_and_keeps_everything_they_had() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
MIGRATION_V9,
MIGRATION_V10,
MIGRATION_V11,
MIGRATION_V12,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 12)
.expect("set version");
conn.execute_batch(
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
name, kind, backdrop_url, manual, details, enriched_at)
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
'fanart.jpg', 1, '{\"format\":\"TV\"}', 5),
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
'backdrop.jpg', 0, NULL, 5);
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
VALUES ('aot', 1, 1, 'anilist', 1, 'To You, in 2000 Years');",
)
.expect("seed");
let catalog = Catalog::from_connection(conn).expect("upgrade");
let records = catalog.all_metadata().expect("read");
assert!(records["aot"].manual);
let aot = records["aot"].metadata.as_ref().expect("still matched");
assert_eq!(aot.backdrop_url.as_deref(), Some("fanart.jpg"));
assert_eq!(aot.logo_url, None);
let details = aot.details.as_ref().expect("details kept");
assert_eq!(details.format.as_deref(), Some("TV"));
assert!(!details.is_current());
let ages = catalog.enrichment_ages().expect("ages");
assert!(!ages.contains_key("aot"));
assert!(ages.contains_key("got"));
let episodes = catalog.all_episode_metadata().expect("read");
assert_eq!(
episodes["aot"].get(Some(1), 1).and_then(|e| e.runtime),
None
);
assert_eq!(episodes["aot"].len(), 1);
}
/// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0.
#[test]
@@ -2797,6 +2908,7 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}],
)
.expect("store");
+1
View File
@@ -177,6 +177,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+34
View File
@@ -104,6 +104,11 @@ pub struct TitleMetadata {
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
/// grid cannot simply prefer one shape.
pub backdrop_url: Option<String>,
/// The title's name as its own lettering: a transparent PNG, drawn over
/// wide art in place of the name in type. Found by enrichment, like the
/// backdrop, and padded by its source — see `pstr-app`'s `trim_alpha`.
#[serde(default)]
pub logo_url: Option<String>,
/// Out of 10, however the provider scores.
pub rating: Option<f32>,
pub genres: Vec<String>,
@@ -161,6 +166,15 @@ pub struct TitleDetails {
/// side stories, recaps, retellings. What the library groups a franchise
/// by; see `crate::franchise`.
pub related: Vec<String>,
/// Minutes: an episode's length for a series, the whole running time for
/// a film.
pub runtime: Option<u32>,
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
/// older one lacks a field a newer build asks for, and is fetched again
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
/// migration: until the refetch lands, the old details still group the
/// library and fill its shelves.
pub revision: u32,
/// The entries enrichment walked through to find the title's episodes —
/// its own sequels, by provider id. Stored apart from the rest, because
/// it is enrichment's answer rather than the match's.
@@ -169,6 +183,18 @@ pub struct TitleDetails {
}
impl TitleDetails {
/// What a provider that fills these in today writes into
/// [`TitleDetails::revision`]. Raise it with a field that existing
/// records should be fetched again for. `1` added
/// [`TitleDetails::runtime`].
pub const REVISION: u32 = 1;
/// Whether these were written by this build's [`TitleDetails::REVISION`]
/// or a later one.
pub fn is_current(&self) -> bool {
self.revision >= Self::REVISION
}
/// `TV` → `TV`, `MOVIE` → `Film`, `TV_SHORT` → `TV short`.
pub fn format_label(&self) -> Option<&'static str> {
Some(match self.format.as_deref()? {
@@ -317,6 +343,9 @@ pub struct EpisodeMetadata {
/// sorted on, and a provider that answers `2009` should not be a parse
/// error.
pub air_date: Option<String>,
/// Minutes.
#[serde(default)]
pub runtime: Option<u32>,
}
/// What enriching a matched title found: its episodes, and art the search
@@ -331,6 +360,9 @@ pub struct Enrichment {
/// A 16:9 picture of the whole title, when one was found. `None` leaves
/// whatever backdrop the match already had.
pub backdrop_url: Option<String>,
/// The title's logo, when one was found. `None` leaves whatever the match
/// already had, as with the backdrop.
pub logo_url: Option<String>,
/// Every provider entry the episodes were taken from, the match's own
/// included. See [`TitleDetails::chain`].
pub chain: Vec<String>,
@@ -550,6 +582,7 @@ mod tests {
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: Some(26),
@@ -569,6 +602,7 @@ mod tests {
overview: None,
still_url: None,
air_date: None,
runtime: None,
}
}
+1
View File
@@ -747,6 +747,7 @@ pub(crate) mod tests {
kind: crate::library::TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+1
View File
@@ -168,6 +168,7 @@ mod tests {
kind: TitleKind::Film,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: genres.iter().map(|genre| genre.to_string()).collect(),
episodes: None,
+11 -1
View File
@@ -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)]));
+39 -9
View File
@@ -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);
}
}
+1
View File
@@ -204,6 +204,7 @@ mod tests {
kind,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
+49 -9
View File
@@ -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]
+5
View File
@@ -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
+20
View File
@@ -170,6 +170,26 @@ AniList, so a search that finds nothing is asked again without it.
Not done: TMDB fills no details.
**Logos and running times.** ani.zip sent both from the start and neither was
kept. Enrichment now keeps the series' TVDB *Clearlogo* — a transparent PNG —
in `title_metadata.logo_url`, kept across a refresh of the same entry like the
fanart, and each episode's running time (TVDB's, else AniDB's `length`) in
`episode_metadata.runtime`. AniList's `duration` lands in
`TitleDetails::runtime`: minutes per episode for a series, the whole running
time for a film. TMDB's episode runtimes are kept the same way.
A clear logo is padded to a square, so every client crops it to its visible
pixels before drawing it (`trim_alpha` on the desktop, `opaqueBounds` on
Android and iOS). It replaces the name over the library's banner and at the
top of the title page — except on a light theme's title page, where lettering
made for dark art could vanish. A film's running time joins its line of facts;
an episode row shows its own (on Android and iOS in place of the file size).
**Schema v13** adds the two columns and marks every AniList title un-enriched,
deleting nothing. `TitleDetails::revision` makes details written before
`runtime` existed refetch by id on the next run; they stay on screen until the
answer lands, rather than being blanked by the migration as schema v10 did.
## Phase 3 — reach
- **Local matching** against the offline database, so only ids leave the
+3
View File
@@ -72,6 +72,9 @@ Click a title to open its page. **Esc** goes back.
- 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.
- **In this franchise** lists the other parts of the series, when posters and descriptions are on.
- With posters and descriptions on, a show's logo takes the place of its name at the top of the
page and on the library's banner, and each episode says how long it runs. A film says how long
it runs in the line under its name.
An episode counts as watched once you have seen 90% of it.
+8
View File
@@ -316,6 +316,14 @@ extension View {
}
}
/// `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes.
func formatRuntime(_ minutes: UInt32) -> String {
let hours = minutes / 60, rest = minutes % 60
if hours == 0 { return "\(rest) min" }
if rest == 0 { return "\(hours) h" }
return "\(hours) h \(rest) min"
}
func formatBytes(_ bytes: UInt64) -> String {
let value = Double(bytes)
let gib = 1024.0 * 1024 * 1024
+6 -1
View File
@@ -505,7 +505,12 @@ private struct FeaturedBanner: View {
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 0) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
// Always over the dark scrim above, so a logo made for dark
// art reads here whatever the theme.
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 240, maxHeight: 72) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(.white).lineLimit(2)
}
.padding(.bottom, 6)
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
+126
View File
@@ -81,6 +81,117 @@ struct RemoteArtwork: View {
}
}
/// A title's logo — its name as lettering — in place of `fallback`, once it
/// has loaded. `fallback` stands until then, and for good when there is none,
/// so a title without a logo looks as it always did and one with a logo only
/// gains.
///
/// Trimmed to what is not transparent before it is drawn: TVDB pads every
/// clear logo to a square, and the padding would otherwise decide its size
/// and place.
///
/// `maxWidth` and `maxHeight` bound the logo only; the fallback takes the room
/// it always did.
struct RemoteLogo<Fallback: View>: View {
let url: String?
let description: String
let maxWidth: CGFloat
let maxHeight: CGFloat
let fallback: () -> Fallback
@State private var logo: UIImage?
init(
_ url: String?,
_ description: String,
maxWidth: CGFloat,
maxHeight: CGFloat,
@ViewBuilder fallback: @escaping () -> Fallback
) {
self.url = url
self.description = description
self.maxWidth = maxWidth
self.maxHeight = maxHeight
self.fallback = fallback
_logo = State(initialValue: ArtworkLoader.shared.cachedLogo(url))
}
var body: some View {
Group {
if let logo {
Image(uiImage: logo)
.resizable()
.scaledToFit()
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading)
.accessibilityLabel(description)
} else {
fallback()
}
}
.task(id: url) {
if logo != nil { return }
let loaded = await ArtworkLoader.shared.logo(url)
if Task.isCancelled { return }
logo = loaded
}
}
}
/// Inclusive pixel bounds, top-left origin.
struct PixelBounds: Equatable {
let left: Int
let top: Int
let right: Int
let bottom: Int
}
/// The bounds of the pixels in a row-major `alpha` plane of `width` that are
/// not (nearly) transparent; `nil` when none are. Below the threshold is
/// anti-aliasing haze, not lettering.
func opaqueBounds(alpha: [UInt8], width: Int) -> PixelBounds? {
var left = Int.max, top = Int.max, right = -1, bottom = -1
for (index, value) in alpha.enumerated() where value > 8 {
let x = index % width, y = index / width
left = min(left, x)
right = max(right, x)
top = min(top, y)
bottom = max(bottom, y)
}
return right < 0 ? nil : PixelBounds(left: left, top: top, right: right, bottom: bottom)
}
/// `image` cropped to `opaqueBounds`, or itself when there is nothing to crop.
func trimTransparent(_ image: UIImage) -> UIImage {
guard let cgImage = image.cgImage else { return image }
let width = cgImage.width, height = cgImage.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in
guard let context = CGContext(
data: buffer.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard drawn else { return image }
let alpha = stride(from: 3, to: pixels.count, by: 4).map { pixels[$0] }
guard let bounds = opaqueBounds(alpha: alpha, width: width),
bounds != PixelBounds(left: 0, top: 0, right: width - 1, bottom: height - 1)
else { return image }
let rect = CGRect(
x: bounds.left,
y: bounds.top,
width: bounds.right - bounds.left + 1,
height: bounds.bottom - bounds.top + 1
)
guard let cropped = cgImage.cropping(to: rect) else { return image }
return UIImage(cgImage: cropped, scale: image.scale, orientation: image.imageOrientation)
}
private enum ArtworkLoad {
case pending
case missing
@@ -196,6 +307,21 @@ final class ArtworkLoader: @unchecked Sendable {
return await thumbnail(fallback)
}
func cachedLogo(_ url: String?) -> UIImage? {
guard let url else { return nil }
return decoded.object(forKey: "logo:\(url)" as NSString)
}
/// A title's logo, trimmed to its lettering — see `trimTransparent`.
func logo(_ url: String?) async -> UIImage? {
guard let url else { return nil }
if let image = cachedLogo(url) { return image }
guard let image = await remote(url) else { return nil }
let trimmed = trimTransparent(image)
decoded.setObject(trimmed, forKey: "logo:\(url)" as NSString, cost: trimmed.cost)
return trimmed
}
/// Provider artwork, from the disk cache or the network.
func remote(_ requested: String?) async -> UIImage? {
guard let requested else { return nil }
+17 -4
View File
@@ -147,7 +147,16 @@ struct TitleView: View {
@ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
// A light page keeps the name: a clear logo is lettering made for
// dark art, white as often as not.
if scheme.light {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
} else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface)
}
.padding(.bottom, 4)
}
// The other names it goes by: the share's when the shown one is the
// provider's, and the original-language one.
let names: [String?] = [
@@ -219,7 +228,9 @@ struct TitleView: View {
let year: String? = (title.metadataYear ?? title.year).map { String($0) }
let rating: String? = title.rating.map { String(format: "★ %.1f", $0) }
let watched: String? = title.episodeCount > 1 ? "\(title.watchedCount) of \(title.episodeCount) watched" : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, rating, watched]
// A series' running time is its episodes', which each row says.
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched]
return parts.compactMap { $0 }.joined(separator: " · ")
}
@@ -404,8 +415,10 @@ private struct EpisodeRow: View {
@State private var holding = false
private var subline: String {
let size: String? = episode.size.map { formatBytes($0) }
let parts: [String?] = [numbering, size, episode.airDate]
// How long it runs says more than how big it is; the size is what
// there is when no provider said.
let length: String? = episode.runtime.map(formatRuntime) ?? episode.size.map { formatBytes($0) }
let parts: [String?] = [numbering, length, episode.airDate]
return parts.compactMap { $0 }.joined(separator: " · ")
}
+16
View File
@@ -278,4 +278,20 @@ final class ThemeTests: XCTestCase {
[.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge]
)
}
func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() {
let width = 10
var alpha = [UInt8](repeating: 0, count: width * 6)
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
for y in 1 ... 3 { for x in 2 ... 6 { alpha[y * width + x] = 255 } }
alpha[5 * width + 9] = 3
XCTAssertEqual(opaqueBounds(alpha: alpha, width: width), PixelBounds(left: 2, top: 1, right: 6, bottom: 3))
XCTAssertNil(opaqueBounds(alpha: [UInt8](repeating: 0, count: 16), width: 4))
}
func testARunningTimeReadsInHoursAndMinutes() {
XCTAssertEqual(formatRuntime(24), "24 min")
XCTAssertEqual(formatRuntime(120), "2 h")
XCTAssertEqual(formatRuntime(124), "2 h 4 min")
}
}