From e340d470c19a6b7480ac203431ef639d24251aac Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 23:54:16 +0100 Subject: [PATCH] Reduce card image transfers and list metadata payloads --- .../wolfskin/tv/LibraryReadFacadeTest.kt | 2 +- .../wolfskin/tv/TitleReadFacadeTest.kt | 39 ++++++++++++ .../wolfskin/tv/read/LibraryReadFacade.kt | 2 +- .../wolfskin/tv/read/TitleReadFacade.kt | 11 ++-- docker/README.md | 20 +++++- .../wolfskin/design/WolfskinImages.kt | 28 +++++---- .../wolfskin/design/WolfskinTokens.kt | 8 +-- .../wolfskin/design/WolfskinImagesTest.kt | 61 +++++++++++++++++++ wolfskin-design/tokens.json | 8 +-- wolfskin-design/tokens.sha256 | 4 +- 10 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt index 92afbe07..a2fb8b0d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -51,7 +51,7 @@ class LibraryReadFacadeTest { val offsets = mutableListOf() coEvery { items.getItems(userId = user, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { assertEquals(200, offsets.last()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index fb7bf06e..7ea534e6 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -33,6 +33,8 @@ import com.github.damontecres.wholphin.test.TestTracks import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.After import org.junit.Before @@ -219,6 +221,43 @@ class TitleReadFacadeTest { assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } + @Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest { + val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42")) + val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1) + coEvery { library.getItem(seriesId) } returns successResponse(series) + coEvery { library.getItem(firstId) } returns successResponse(episode) + coEvery { shows.getNextUp(any()) } coAnswers { + assertTrue(firstArg().fields.orEmpty().isEmpty()) + successQueryResult(emptyList()) + } + coEvery { items.getItems(any()) } returns successQueryResult( + listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3))) + coEvery { shows.getEpisodes(any()) } coAnswers { + val fields = firstArg().fields.orEmpty() + assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) }) + successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis"))) + } + val page = facade.read(seriesId, 3, preferences) + assertEquals(firstId.toString(), page.playTarget?.id) + assertEquals("MKV", page.versions.single().container) + assertEquals(listOf(3), page.versions.single().audio.map { it.index }) + val saved = requireNotNull(facade.readSaved(seriesId, preferences)) + assertEquals(page.versions, saved.versions) + } + + @Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest { + coEvery { library.getItem(firstId) } returns successResponse( + BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor")) + coEvery { items.getItems(any()) } coAnswers { + val fields = firstArg().fields.orEmpty() + assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) }) + assertTrue(ItemFields.PEOPLE in fields) + successQueryResult(listOf(file(secondId).copy(people = listOf( + org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef"))))) + } + assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character) + } + @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { playback.savePlayVersion(any(), any()) } returns null diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 775235f2..3c1d01ce 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { val response = api.itemsApi.getItems( userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), ).content diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index ff727717..1530b8df 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -53,7 +53,8 @@ class TitleReadFacade @Inject constructor( @param:ApplicationContext private val context: Context, ) { private val json = Json { ignoreUnknownKeys = true } - private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) + private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW) + private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE) /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ suspend fun findHeld(tmdb: Long, media: String): UUID? { @@ -88,12 +89,12 @@ class TitleReadFacade @Inject constructor( val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { - api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } + api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) } } val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() - val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() - val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -165,7 +166,7 @@ class TitleReadFacade @Inject constructor( } else emptyList() val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200, sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") diff --git a/docker/README.md b/docker/README.md index fa36fcc0..a994e46a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -570,15 +570,29 @@ retain the approved frames except for the requested feedback changes: - Every Discover heading uses a 104 px band, including a 60 px title-to-card gap. Home heading geometry is unchanged. - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - 420×630. The shared request builder removes conflicting Jellyfin size caps, - preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB - memory cache, 128 MiB disk cache and four image-read limit remain unchanged. + 420×630. The shared request builder replaces conflicting Jellyfin size/encoding + parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG + quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and + 400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and + authentication query parameters remain intact. TMDB uses w342 for posters and + portraits, w780 for landscape cards and w1280 for hero backdrops, never original. + Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks + decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory + cache, 128 MiB disk cache and four image-read limit remain unchanged. `HomeViewportTest` covers short/long poster, tile and wide rows, both row directions, first focus and native Back from the actual title surface. Native proof records real Bear season-three metadata through the title read facade; the proof-only harness is not part of the shipped tests or APK. +Episode lists request only ProviderIds and Overview. Next up reads only identity, +then hydrates the selected playback target through the existing single-item read, +including when Next up is empty. Media sources/streams and cast remain on detail +reads for header track/version controls. Performer credits omit media sources, +streams and ParentId but retain People because their cards show character roles. +Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName +ordering does not require returning SortName on every item. + ## Title pages and people diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 349fd2d2..e9b63760 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,6 +1,8 @@ package org.justinvisser.wolfskin.design import android.content.Context +import android.graphics.ImageDecoder +import android.os.Build import android.net.Uri import coil3.ComponentRegistry import coil3.ImageLoader @@ -23,6 +25,8 @@ object WolfskinImages { private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 + private const val CARD_QUALITY = "80" + private const val HERO_QUALITY = "96" private val readPermits = Semaphore(IMAGE_READS) /** The app supplies authenticated networking without exposing its server/session to design code. */ @@ -78,32 +82,34 @@ object WolfskinImages { private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE) private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/") - private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight") + private val imageParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight", "quality", "format") + // Lossy WebP is mandatory from API 14; minSdk is 23. Newer platforms expose a decoder capability query. + private val supportsWebp by lazy { Build.VERSION.SDK_INT < 28 || ImageDecoder.isMimeTypeSupported("image/webp") } private fun sourceForSize(source: Any, width: Int, height: Int): Any { if (source !is String) return source val uri = Uri.parse(source) if (uri.scheme != "https" && uri.scheme != "http") return source if (uri.host == "image.tmdb.org") { - val size = if (height > width) when { - width <= 500 -> "w500" - width <= 780 -> "w780" - else -> "original" - } else when { - width <= 780 -> "w780" - width <= 1280 -> "w1280" - else -> "original" + val size = when { + height >= width -> "w342" + width > WolfskinTokens.Image.wideRequestWidth -> "w1280" + else -> "w780" } return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString() } if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source + val hero = width == WolfskinTokens.Image.heroRequestWidth && height == WolfskinTokens.Image.heroRequestHeight || + width == WolfskinTokens.Image.heroPosterRequestWidth && height == WolfskinTokens.Image.heroPosterRequestHeight val builder = uri.buildUpon().clearQuery() for (name in uri.queryParameterNames) { - if (name.lowercase() !in dimensionParameters) { + if (name.lowercase() !in imageParameters) { uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) } } } return builder.appendQueryParameter("fillWidth", width.toString()) - .appendQueryParameter("fillHeight", height.toString()).build().toString() + .appendQueryParameter("fillHeight", height.toString()) + .appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp") + .appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString() } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index fa730cd2..6b77b2ef 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -220,10 +220,10 @@ object WolfskinTokens { object Image { const val posterRequestWidth = 256 const val posterRequestHeight = 384 - const val tileRequestWidth = 416 - const val tileRequestHeight = 234 - const val wideRequestWidth = 560 - const val wideRequestHeight = 315 + const val tileRequestWidth = 400 + const val tileRequestHeight = 225 + const val wideRequestWidth = 544 + const val wideRequestHeight = 306 const val heroRequestWidth = 1920 const val heroRequestHeight = 1080 const val heroPosterRequestWidth = 420 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt new file mode 100644 index 00000000..dfefe549 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt @@ -0,0 +1,61 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.net.Uri +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** Card URLs bound server work and transfer size, while hero quality and cache identity stay intact. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class WolfskinImagesTest { + private data class Surface(val name: String, val width: Int, val height: Int, val tier: String, val hero: Boolean = false) + private val surfaces = listOf( + Surface("grid/Home/Discover/person poster", 256, 384, "w342"), + Surface("Home tile", 400, 225, "w780"), + Surface("Home wide", 544, 306, "w780"), + Surface("episode still", 400, 280, "w780"), + Surface("cast portrait", 200, 200, "w342"), + Surface("person portrait", 240, 240, "w342"), + Surface("hero poster", 420, 630, "w342", true), + Surface("Home/Discover/title hero", 1920, 1080, "w1280", true), + ) + + private fun request(source: Any, surface: Surface): Any = WolfskinImages.request( + RuntimeEnvironment.getApplication(), source, surface.width, surface.height, surface.height > surface.width).data + + @Test fun jellyfinSurfacesReplaceStaleDimensionsAndEncodingWithoutLosingIdentity() { + for (surface in surfaces) { + val source = "https://jelly.example/Items/123/Images/Backdrop/0?tag=stable&api_key=kept&MaxWidth=1280&height=720&FillHeight=720&Quality=96&Format=Png" + val rewritten = request(source, surface).toString() + val uri = Uri.parse(rewritten) + assertEquals(surface.name, surface.width.toString(), uri.getQueryParameter("fillWidth")) + assertEquals(surface.name, surface.height.toString(), uri.getQueryParameter("fillHeight")) + assertEquals(surface.name, if (surface.hero) "Jpg" else "Webp", uri.getQueryParameter("format")) + assertEquals(surface.name, if (surface.hero) "96" else "80", uri.getQueryParameter("quality")) + assertEquals("stable", uri.getQueryParameter("tag")) + assertEquals("kept", uri.getQueryParameter("api_key")) + assertEquals(setOf("tag", "api_key", "fillWidth", "fillHeight", "format", "quality"), uri.queryParameterNames) + assertEquals(rewritten, request(rewritten, surface)) + } + } + + @Test fun tmdbSurfacesNeverDownloadOriginalArtwork() { + for (surface in surfaces) { + for (oldTier in listOf("original", "w500", "w780")) { + assertEquals(surface.name, "https://image.tmdb.org/t/p/${surface.tier}/art.jpg?identity=kept", + request("https://image.tmdb.org/t/p/$oldTier/art.jpg?identity=kept", surface)) + } + } + } + + @Test fun localAndUnrelatedSourcesRemainUntouched() { + for (source in listOf("file:///android_asset/poster.webp", "https://cdn.example/poster.jpg?width=800", "https://jelly.example/Users/123/Images/Primary", Uri.parse("content://art/1"))) { + assertEquals(source, request(source, surfaces.first())) + } + } +} diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 70571dc3..cc1a845b 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1492,8 +1492,8 @@ }, "tileRequest": { "px": [ - 416, - 234 + 400, + 225 ], "decode": "ARGB_8888", "source": "index.html:549", @@ -1501,8 +1501,8 @@ }, "wideRequest": { "px": [ - 560, - 315 + 544, + 306 ], "decode": "ARGB_8888", "source": "index.html:401", diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 713ae2f4..405b0abe 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json 36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css -3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json +226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json