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") + } }