From bb5a6638c409f719e8ff3facb65995ef9315aad5 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 29 Oct 2026 16:11:24 +0100 Subject: [PATCH] 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. --- .../java/io/narl/protonstream/ui/Common.kt | 11 ++ .../io/narl/protonstream/ui/LibraryScreen.kt | 21 ++- .../io/narl/protonstream/ui/RemoteArtwork.kt | 78 ++++++++++ .../io/narl/protonstream/ui/TitleScreen.kt | 23 ++- .../protonstream/ui/LogoAndRuntimeTest.kt | 30 ++++ crates/pstr-android/src/lib.rs | 17 +++ crates/pstr-app/src/app.rs | 9 +- crates/pstr-app/src/engine.rs | 111 +++++++++++++- crates/pstr-app/src/ui/library.rs | 20 +-- crates/pstr-app/src/ui/mod.rs | 61 ++++++++ crates/pstr-app/src/ui/title.rs | 28 +++- crates/pstr-core/src/browse.rs | 1 + crates/pstr-core/src/catalog.rs | 142 ++++++++++++++++-- crates/pstr-core/src/franchise.rs | 1 + crates/pstr-core/src/metadata.rs | 34 +++++ crates/pstr-core/src/profile.rs | 1 + crates/pstr-core/src/shelves.rs | 1 + crates/pstr-meta/src/anilist.rs | 12 +- crates/pstr-meta/src/anizip.rs | 48 ++++-- crates/pstr-meta/src/matching.rs | 1 + crates/pstr-meta/src/service.rs | 58 +++++-- crates/pstr-meta/src/tmdb.rs | 5 + docs/METADATA.md | 20 +++ docs/USER_GUIDE.md | 3 + ios/ProtonStream/UI/Common.swift | 8 + ios/ProtonStream/UI/LibraryView.swift | 7 +- ios/ProtonStream/UI/RemoteArtwork.swift | 126 ++++++++++++++++ ios/ProtonStream/UI/TitleView.swift | 21 ++- ios/ProtonStreamTests/UiLogicTests.swift | 16 ++ 29 files changed, 845 insertions(+), 69 deletions(-) create mode 100644 android/app/src/test/java/io/narl/protonstream/ui/LogoAndRuntimeTest.kt diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 2dc337b..9d19a0f 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -137,6 +137,17 @@ internal fun nextUpIndex(playlist: List, 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 { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 3c42ef1..bddb55e 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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) }, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt index d230d6c..8a5df88 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt @@ -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(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 diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index d91e0d6..92e212a 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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, diff --git a/android/app/src/test/java/io/narl/protonstream/ui/LogoAndRuntimeTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/LogoAndRuntimeTest.kt new file mode 100644 index 0000000..8ae1203 --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/LogoAndRuntimeTest.kt @@ -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)) + } +} diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index eb82583..cc54ef3 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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, } #[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, + /// 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, + /// Minutes: an episode's length for a series, the running time of a film. + #[uniffi(default = None)] + pub runtime: Option, } /// 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, diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index b815cc6..e33da3d 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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 = 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, diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index e036f22..43ba7ea 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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 { /// [`decode_thumbnail`], to `max_edge` rather than a tile's size. fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result { - 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 { + 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 { }) } +/// `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 { + /// 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)] diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index c110e71..470541b 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -274,15 +274,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec { 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 { + 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 { + 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"); diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index da932ef..e9a1ed4 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -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) -> 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 diff --git a/crates/pstr-core/src/browse.rs b/crates/pstr-core/src/browse.rs index d71f5ce..cec51ab 100644 --- a/crates/pstr-core/src/browse.rs +++ b/crates/pstr-core/src/browse.rs @@ -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, diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 7e7fbcf..47d3cfc 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -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> { 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>(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"); diff --git a/crates/pstr-core/src/franchise.rs b/crates/pstr-core/src/franchise.rs index d38979a..44e6fa9 100644 --- a/crates/pstr-core/src/franchise.rs +++ b/crates/pstr-core/src/franchise.rs @@ -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, diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index b856a16..48cbe77 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -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, + /// 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, /// Out of 10, however the provider scores. pub rating: Option, pub genres: Vec, @@ -161,6 +166,15 @@ pub struct TitleDetails { /// side stories, recaps, retellings. What the library groups a franchise /// by; see `crate::franchise`. pub related: Vec, + /// Minutes: an episode's length for a series, the whole running time for + /// a film. + pub runtime: Option, + /// 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, + /// Minutes. + #[serde(default)] + pub runtime: Option, } /// 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, + /// The title's logo, when one was found. `None` leaves whatever the match + /// already had, as with the backdrop. + pub logo_url: Option, /// Every provider entry the episodes were taken from, the match's own /// included. See [`TitleDetails::chain`]. pub chain: Vec, @@ -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, } } diff --git a/crates/pstr-core/src/profile.rs b/crates/pstr-core/src/profile.rs index a2a9bb4..6672555 100644 --- a/crates/pstr-core/src/profile.rs +++ b/crates/pstr-core/src/profile.rs @@ -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, diff --git a/crates/pstr-core/src/shelves.rs b/crates/pstr-core/src/shelves.rs index 98cc27c..ca86ab6 100644 --- a/crates/pstr-core/src/shelves.rs +++ b/crates/pstr-core/src/shelves.rs @@ -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, diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index b69503a..15f224d 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -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, episodes: Option, + /// Minutes per episode, or the film's running time. + duration: Option, format: Option, season: Option, status: Option, @@ -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)])); diff --git a/crates/pstr-meta/src/anizip.rs b/crates/pstr-meta/src/anizip.rs index 6ec4cff..aecd4ae 100644 --- a/crates/pstr-meta/src/anizip.rs +++ b/crates/pstr-meta/src/anizip.rs @@ -36,6 +36,8 @@ pub struct Mapping { pub episodes: Vec, /// TVDB's fanart for the whole series: 16:9, usually 1920×1080. pub fanart: Option, + /// TVDB's clear logo: the series' name as lettering, a transparent PNG. + pub logo: Option, } pub struct AniZip { @@ -108,6 +110,12 @@ struct Episode { summary: Option, image: Option, air_date: Option, + /// TVDB's running time, in minutes. + #[serde(default, deserialize_with = "lenient_number")] + runtime: Option, + /// AniDB's, which is there when TVDB's is not. + #[serde(default, deserialize_with = "lenient_number")] + length: Option, } #[derive(Deserialize)] @@ -119,11 +127,14 @@ struct Image { impl Payload { fn into_mapping(self, language: &str, entry_season: Option) -> 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::("{}") @@ -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); } } diff --git a/crates/pstr-meta/src/matching.rs b/crates/pstr-meta/src/matching.rs index 6fac0eb..b867ea8 100644 --- a/crates/pstr-meta/src/matching.rs +++ b/crates/pstr-meta/src/matching.rs @@ -204,6 +204,7 @@ mod tests { kind, poster_url: None, backdrop_url: None, + logo_url: None, rating: None, genres: Vec::new(), episodes: None, diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index 3baa37e..ffbdbdc 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -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), - /// 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::>() + }; + 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] diff --git a/crates/pstr-meta/src/tmdb.rs b/crates/pstr-meta/src/tmdb.rs index dbbe197..083ea32 100644 --- a/crates/pstr-meta/src/tmdb.rs +++ b/crates/pstr-meta/src/tmdb.rs @@ -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, still_path: Option, air_date: Option, + /// Minutes. + runtime: Option, } #[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 diff --git a/docs/METADATA.md b/docs/METADATA.md index 80c45cb..30ad0ed 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2ac3a54..d8a0e73 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 5e6fbf5..bf6c023 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -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 diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 1aeef21..6695a49 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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)) diff --git a/ios/ProtonStream/UI/RemoteArtwork.swift b/ios/ProtonStream/UI/RemoteArtwork.swift index e0e6d62..b356495 100644 --- a/ios/ProtonStream/UI/RemoteArtwork.swift +++ b/ios/ProtonStream/UI/RemoteArtwork.swift @@ -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: 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 } diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index f11609f..a4cf6cf 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -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: " · ") } diff --git a/ios/ProtonStreamTests/UiLogicTests.swift b/ios/ProtonStreamTests/UiLogicTests.swift index 25297ea..3a61dd6 100644 --- a/ios/ProtonStreamTests/UiLogicTests.swift +++ b/ios/ProtonStreamTests/UiLogicTests.swift @@ -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") + } }