Reduce card image transfers and list metadata payloads
This commit is contained in:
parent
f4fb6e22c9
commit
e340d470c1
10 changed files with 152 additions and 31 deletions
|
|
@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
|
|||
val offsets = mutableListOf<Int>()
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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<GetNextUpRequest>()) } coAnswers {
|
||||
assertTrue(firstArg<GetNextUpRequest>().fields.orEmpty().isEmpty())
|
||||
successQueryResult(emptyList())
|
||||
}
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||
listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3)))
|
||||
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } coAnswers {
|
||||
val fields = firstArg<GetEpisodesRequest>().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<GetItemsRequest>()) } coAnswers {
|
||||
val fields = firstArg<GetItemsRequest>().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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue