From 04b1bdd6aa75b0889b3a3a44e566c598235cd9ff Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 02:15:09 +0100 Subject: [PATCH 01/26] Align TV layouts and close recovery gaps --- .../tv/ConductarrAudienceRecoveryTest.kt | 35 ++++++++ .../wolfskin/tv/ConductarrHomeSurfaceTest.kt | 2 +- .../wolfskin/tv/HomeRecoveryTest.kt | 24 ++++- .../tv/LibraryProgressiveSurfaceTest.kt | 81 +++++++++++++++++ .../wolfskin/tv/LibraryReadFacadeTest.kt | 89 +++++++++++++++++++ .../wolfskin/tv/LibrarySnapshotStoreTest.kt | 27 +++++- .../wolfskin/tv/HomeDestination.kt | 14 +-- .../justinvisser/wolfskin/tv/TitleRequests.kt | 2 +- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 3 +- .../wolfskin/tv/WolfskinLibrary.kt | 9 +- .../wolfskin/tv/read/LibraryReadFacade.kt | 7 +- .../wolfskin/tv/read/LibrarySnapshotStore.kt | 12 ++- docker/README.md | 42 +++++++++ .../wolfskin/design/DiscoverOverlay.kt | 40 ++++++--- .../wolfskin/design/DiscoverState.kt | 12 +-- .../wolfskin/design/HomeRatingCache.kt | 36 ++++++-- .../wolfskin/design/LibraryScreen.kt | 9 +- .../wolfskin/design/SettingsList.kt | 4 +- .../wolfskin/design/DiscoverKeysTest.kt | 16 ++++ .../wolfskin/design/HomeRatingCacheTest.kt | 28 ++++++ .../wolfskin/design/LibraryKeysTest.kt | 17 ++++ .../wolfskin/design/OwnedSourceAuditTest.kt | 66 ++++++++++++++ 22 files changed, 522 insertions(+), 53 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index 00f970b1..b748367e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -67,4 +67,39 @@ class ConductarrAudienceRecoveryTest { } } } + + @Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previousUrl = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { session -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + session.authenticate() + val libraries = LibraryReadFacade(mockk()) + val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + try { + val identity = session.identity.value!! + val intent = org.justinvisser.wolfskin.design.DiscoverRequest( + org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared") + fake.postStatus = 503 + assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed, + identity.provider.operations(identity.operator!!).request(intent).phase) + model.retryConductarr(server, user) + val refreshed = session.identity.value!! + assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed, + refreshed.provider.operations(refreshed.operator!!).request(intent).phase) + assertEquals(1, fake.calls.count { it.method == "POST" }) + } finally { + model.viewModelScope.cancel() + ConductarrPreference.updateBaseUrl(previousUrl) + } + } + } + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index 13ac36d4..c4e63d27 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -60,7 +60,7 @@ class ConductarrHomeSurfaceTest { try { val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } rule.setContent { WolfskinTheme { - LaunchedEffect(model) { model.loadLibrary(server, user, "shared") } + LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) } val snapshot = model.snapshot CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) { ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index 37b665ce..dc0b780d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -32,6 +32,8 @@ class HomeRecoveryTest { private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel") + private val viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString()) + private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString()) private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400) private val library = HomeLibrary(resume = listOf(film)) @@ -43,7 +45,7 @@ class HomeRecoveryTest { val facade = mockk() coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() } val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) } + val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) response.completeExceptionally(IOException("fixture offline")) @@ -63,10 +65,10 @@ class HomeRecoveryTest { coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } coEvery { facade.read(personB.id) } throws IOException("fixture offline") val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") } + val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) - model.loadLibrary(server, personB, "mindel") + model.loadLibrary(server, personB, viewerB) assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) assertNull(model.snapshot.savedAt) assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable }) @@ -83,9 +85,23 @@ class HomeRecoveryTest { every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - model.loadLibrary(server, personA, "justin") + model.loadLibrary(server, personA, viewerA) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) assertNull(model.toast) } + + @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { + val facade = mockk() + coEvery { facade.read(personA.id) } returns library + val cache = mockk(relaxed = true) + every { cache.read(any()) } returns null + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) + assertTrue(model.snapshot.jellyfinAvailable) + assertEquals(film.id, model.snapshot.rows.first().cards.first().id) + model.loadLibrary(server, personA, viewerB.copy(key = "justin")) + assertFalse(model.snapshot.jellyfinAvailable) + assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt new file mode 100644 index 00000000..e312a66d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.input.key.Key +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.flow +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The real host exposes usable remote focus before the remaining library chunks arrive. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LibraryProgressiveSurfaceTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() { + ConductarrPreference.initialize(rule.activity) + ConductarrPreference.updateFixtures(false) + val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply { + pickerVisible = false + enterDestination("movies") + focusNav(currentTab) + } + val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null) + val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") } + val release = CompletableDeferred() + val facade = mockk() + every { facade.audiences } returns null + every { facade.readLibrary(user.id, "movies") } returns flow { + emit(first) + release.await() + emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie")) + } + val scope = "progressive:${UUID.randomUUID()}" + val navigation = NavigationManager() + val seeAll = WolfskinSeeAllLists() + try { + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> + WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier) + } } } + rule.waitUntil(timeoutMillis = 10_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + shell.view.library?.source?.size == 200 + } + rule.mainClock.advanceTimeBy(500) + assertFalse(shell.navFocused) + rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath) + release.complete(Unit) + rule.waitUntil(timeoutMillis = 10_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + shell.view.library?.source?.size == 201 + } + assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) + assertFalse(shell.navFocused) + } finally { release.complete(Unit) } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt new file mode 100644 index 00000000..92afbe07 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.justinvisser.wolfskin.tv.read.LibrarySnapshot +import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class LibraryReadFacadeTest { + @Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking { + val api = mockk() + val views = mockk() + val items = mockk() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + val user = UUID(0, 1) + val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) + coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library)) + val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) } + val nextRead = CompletableDeferred() + val release = CompletableDeferred() + 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), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { + if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { + assertEquals(200, offsets.last()) + nextRead.complete(Unit) + release.await() + successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202) + } + } + val facade = LibraryReadFacade(api) + val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } + val published = CompletableDeferred() + val snapshots = mutableListOf() + val job = launch { + store.read("server:justin", "movies") { + facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) } + }.collect { + snapshots += it + if (!published.isCompleted) published.complete(it) + } + } + val initial = published.await() + assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id }) + nextRead.await() + assertTrue(job.isActive) + release.complete(Unit) + job.join() + val final = snapshots.last() + assertEquals(201, final.items.size) + assertEquals("Updated film", final.items.first().title) + assertEquals("Last film", final.items.last().title) + assertFalse(final.unreachable) + val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single() + assertEquals(final.items, saved.items) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt index 5363848c..279c3966 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv import android.app.Application import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Test @@ -20,7 +21,7 @@ class LibrarySnapshotStoreTest { var now = 1000L val store = LibrarySnapshotStore(MemoryCacheStore()) { now } val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true)) - store.read("server:justin", "movies") { items }.toList() + store.read("server:justin", "movies") { flowOf(items) }.toList() val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList() assertEquals(items, fresh.single().items) now += 11 * 60_000 @@ -39,11 +40,31 @@ class LibrarySnapshotStoreTest { @Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking { val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } - store.read("server:justin", "movies") { emptyList() }.toList() + store.read("server:justin", "movies") { flowOf(emptyList()) }.toList() val current = listOf(LibraryTitle("b", "New Film", "movie")) - val reads = store.read("server:justin", "movies", force = true) { current }.toList() + val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList() assertTrue(reads.first().items.isEmpty()) assertEquals(current, reads.last().items) assertFalse(reads.last().unreachable) } + + @Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking { + val partial = listOf(LibraryTitle("partial", "First film", "movie")) + val complete = partial + LibraryTitle("last", "Last film", "movie") + for (hasSaved in listOf(false, true)) { + val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } + if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList() + val reads = store.read("server:justin", "movies", force = true) { + kotlinx.coroutines.flow.flow { + emit(partial) + throw java.io.IOException("Later chunk failed") + } + }.toList() + assertEquals(if (hasSaved) complete else partial, reads.last().items) + assertTrue(reads.last().unreachable) + assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis) + val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList() + assertEquals(if (hasSaved) complete else emptyList(), reopened.last().items) + } + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 034f46a7..7687e595 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -48,9 +48,9 @@ class WolfskinHomeViewModel @Inject constructor( private var catalogueSnapshot = DiscoverSnapshot() private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null - suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() - withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } + withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) } @@ -58,9 +58,11 @@ class WolfskinHomeViewModel @Inject constructor( } /** The authenticated read path is shared by release and offline fixture-driven tests. */ - internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { val revision = ++loadRevision - if (person != user.name.orEmpty().lowercase() && !(conductarr.identity.value?.person == "${server.id}:${user.id}" && person == conductarr.identity.value?.operator?.requester)) { + val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?: + (person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester)) + if (!matchesUser) { snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false) return } @@ -130,7 +132,7 @@ class WolfskinHomeViewModel @Inject constructor( fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { when (action) { HomeAction.Discover -> openRoot(shell, "discover") - HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) } + HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Play -> viewModelScope.launch { @@ -200,7 +202,7 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { val identity by model.conductarrState.collectAsState() - LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person.key) } + LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt index fda961a9..f4188e3f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -53,7 +53,7 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She is TitleAction.Requests -> shell.openRequests() is TitleAction.Hide -> if (current != null && requests != null) { val item = current.data.requestTitle(shell.person) - if (action.hidden) requests.hide(item) else requests.restore(item) + if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) requests.overlay = null // TitleState already owns the native hide receipt and Undo. } else -> error("Not a Conductarr title action") diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 781f55dd..3d182180 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -142,7 +142,8 @@ class WolfskinShellViewModel @Inject constructor( } suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, + retry = conductarr.identity.value?.signedIn != true) loadHousehold(user) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 525f2b41..fec2c537 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -75,19 +75,24 @@ fun WolfskinLibraryContent( LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { val wasLoading = grid.status == LibraryStatus.Loading + if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) + val entryRevision = shell.inputRevision if (fixture != null) { grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } grid.status = LibraryStatus.Ready } else { snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> + val focusedId = grid.focusedTitle?.id grid.source = snapshot.items - grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready + focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } + grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable + if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false } } - if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false + if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false } } LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view) 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 2f176581..cc9497f1 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 @@ -3,6 +3,8 @@ package org.justinvisser.wolfskin.tv.read import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import com.github.damontecres.wholphin.ui.nav.Destination import org.jellyfin.sdk.api.client.ApiClient @@ -47,7 +49,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { } /** A destination spans every matching accessible library; containers never become titles. */ - suspend fun readLibrary(userId: UUID, destination: String): List { + fun readLibrary(userId: UUID, destination: String): Flow> = flow { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> destination(library.name.orEmpty(), library.collectionType) == destination } @@ -76,8 +78,9 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { ) } offset += chunk.size + if (chunk.isNotEmpty()) emit(titles.values.toList()) } while (chunk.isNotEmpty() && offset < response.totalRecordCount) } - return titles.values.toList() + if (titles.isEmpty()) emit(emptyList()) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt index 263922eb..85395ba0 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt @@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List, val savedAtMillis: Lon /** Library snapshots share the existing atomic disk store, partitioned by server and person. */ class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { - fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> List): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow>): Flow = flow { val key = "library:$destination" val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow + var current: List = emptyList() try { - val current = load() + load().collect { chunk -> + current = chunk + if (titles == null) emit(LibrarySnapshot(chunk, null, false)) + } val time = now() + if (titles != null) emit(LibrarySnapshot(current, time, false)) try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ } - emit(LibrarySnapshot(current, time, false)) } catch (exception: Exception) { if (exception is CancellationException) throw exception - emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true)) + emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true)) } }.flowOn(Dispatchers.IO) diff --git a/docker/README.md b/docker/README.md index b1ea4087..aeba3db5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -533,6 +533,48 @@ person's title/person projection. Title and performer disk copies remain isolate by both server and authenticated user; native retry and cache-isolation regressions exercise these boundaries without a live service. +## TV fidelity and source gates + +Library/Discover page headings use the existing exact CSS line-box layout. +Request sheets reserve the inherited inline text descent around their ratings; +person rows and receipt panels therefore share the locked frame's vertical +coordinates. Settings row title/facts also retain their CSS line boxes. These +are local component fixes: shared typography tokens, Home and caption styling +do not shift. + +Home ratings observations use independent six-hour, 300-entry title and +series/season caches. A season retains each observed episode's own score and +timestamp; changing a Jellyfin episode UUID does not lose the observation, and +another episode or season never borrows that score. Episodes without a complete +catalogue identity remain isolated by their Jellyfin identity. + +Cold library grids publish each cumulative 200-item SDK chunk before the next +read completes. An existing complete saved copy stays visible throughout a +refresh, and only a completed read replaces it on disk. A cold later-chunk +failure keeps the already-readable titles with a retry tail, without saving an +incomplete copy. Appended titles preserve the focused title even when the +destination's sort inserts them before it. Catalogue row keys use the same +media-type/TMDB identity as their cards. + +Undo restores a dismissed pick silently; Show again announces “Back in your +picks.” Title pages own their own toast so the request adapter never duplicates +it. The saved Discover retry tail is labelled More picks, not Try again twice. + +People retry retains an already signed-in Conductarr client and its uncertain +submission ledger; only a signed-out identity is rebuilt. Home authorizes its +Jellyfin read by the selected person's saved Jellyfin user ID, so a lost +Conductarr operator or a renamed household profile does not hide that user's +own library. A different saved user ID still fails closed. + +`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite. +It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and +`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color +constructors outside generated tokens, token font sizes below 12 sp, raw +household library labels, and page-counter/navigation wording. Its negative +cases plant each violation independently. Existing native capture checks also +verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures +the usable first chunk while the final read is still suspended. + ## Toolchain and disk use `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index a70e5097..df050bc3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.platform.testTag -import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.Layout import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D @@ -49,7 +50,7 @@ fun DiscoverOverlay(state: DiscoverState) { is DiscoverOverlayState.Hidden -> { ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) } + DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } } } @@ -59,7 +60,7 @@ fun DiscoverOverlay(state: DiscoverState) { Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), color = C.muted, style = DiscoverType.body) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) } + DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } } } @@ -72,15 +73,10 @@ fun DiscoverOverlay(state: DiscoverState) { @Composable private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { - Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle) + Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", - Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body) - if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) { - placeable.placeRelative(0, D.sheetRatingTop.roundToPx()) - } - }) + Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) + if (state.isSignedIn) SheetRatings(sheet.title.ratings) when (sheet.phase) { RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.LoadingRecipients -> { @@ -100,6 +96,28 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { } } +/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */ +@Composable +private fun SheetRatings(ratings: List) { + if (ratings.isEmpty()) return + Layout(content = { + DiscoverRatings(ratings) + Text(" ", color = C.muted, style = DiscoverType.body) + }) { children, constraints -> + val row = children[0].measure(constraints.copy(minHeight = 0)) + val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) + val mark = DiscoverType.ratings.fontSize.toPx() + val baseline = (row.height + mark) / 2f + val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat()) + val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent + val top = D.sheetRatingTop.roundToPx() + val bottom = D.sheetRatingBottom.roundToPx() + layout(row.width, kotlin.math.round(height).toInt() + top + bottom) { + row.placeRelative(0, top) + } + } +} + @Composable private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 47ec35ff..65143485 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -300,7 +300,7 @@ class DiscoverState( ShellKey.Ok -> when (titleButton) { 0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false) 1 -> openRequest(true) - 2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) } + 2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) } } else -> Unit } @@ -328,13 +328,13 @@ class DiscoverState( ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Right, ShellKey.Down -> sheet.focus = 1 ShellKey.Back -> overlay = null - ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() } + ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() } } is DiscoverOverlayState.Restore -> when (key) { ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Right, ShellKey.Down -> sheet.focus = 1 ShellKey.Back -> overlay = null - ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null + ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null } null -> Unit } @@ -380,9 +380,9 @@ class DiscoverState( } } - fun restore(item: DiscoverTitle) { + fun restore(item: DiscoverTitle, showToast: Boolean) { if (!canRequest) { refresh(); return } - hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks." + hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null updateCollectionCards() val profile = shell.person.key val version = (dismissalVersions[item.key] ?: 0) + 1 @@ -412,7 +412,7 @@ class DiscoverState( val lanes = service?.let { serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) - return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes + return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt index 89ea3093..d36d0f85 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt @@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) { private data class Entry(val ratings: List, val readAt: Long) private val entries = linkedMapOf() + private val seasons = linkedMapOf>() fun read(title: HomeTitle): List? { - val key = key(title) - val entry = entries[key] ?: return null - if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null } + val season = seasonKey(title) + val bucket = season?.let { seasons[it] } + val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)] + if (entry == null) return null + if (clock() - entry.readAt >= lifetimeMillis) { + if (season == null) entries.remove(key(title)) else { + bucket?.remove(title.episode) + if (bucket.isNullOrEmpty()) seasons.remove(season) + } + return null + } return entry.ratings } fun save(title: HomeTitle, ratings: List) { - val key = key(title) - entries.remove(key) - entries[key] = Entry(ratings, clock()) - while (entries.size > capacity) entries.remove(entries.keys.first()) + val entry = Entry(ratings, clock()) + val season = seasonKey(title) + if (season != null) { + val bucket = seasons.remove(season) ?: mutableMapOf() + bucket[requireNotNull(title.episode)] = entry + seasons[season] = bucket + while (seasons.size > capacity) seasons.remove(seasons.keys.first()) + } else { + val key = key(title) + entries.remove(key) + entries[key] = entry + while (entries.size > capacity) entries.remove(entries.keys.first()) + } } private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}" + private fun seasonKey(title: HomeTitle): String? = + if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null) + "${title.tmdbId}:${title.season}" else null + companion object { private const val capacity = 300 private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 9b5bf22e..07082c4f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -84,7 +84,7 @@ fun LibraryScreen( } LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt } LaunchedEffect(grid.status) { - if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab) + if (grid.status == LibraryStatus.Loading && !shell.navFocused) shell.focusNav(shell.currentTab) } val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap @@ -128,7 +128,10 @@ fun LibraryScreen( LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } else { - items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row -> + items((items.size + grid.columns - 1) / grid.columns, key = { row -> + val title = items[row * grid.columns] + "grid-${if (grid.catalogue) title.catalogueKey else title.id}" + }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { @@ -174,7 +177,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav Box(Modifier.fillMaxWidth().height(L.row_lhead)) { Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) { Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle) + Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle) if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle) savedAt?.let { SavedPill(it) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 48a8220b..0bc4d335 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -98,8 +98,8 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Column(Modifier.weight(1f)) { val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" - Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) - Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) + Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } if (group.wolfskin) SettingsPill("Wolfskin", focused) SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index 8d95e376..ae66b19b 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -91,6 +91,22 @@ class DiscoverKeysTest { assertNull(state.overlay) assertEquals("The Amazing Spider-Man", state.hero?.title) assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }) + assertNull("Undo restores silently; only Show again announces the return", state.toast) + } + + @Test fun showAgainRestoresTheHiddenPickAndAnnouncesItsReturn() { + replay("Round 3 · Not interested on a pick (Discover hero)") + val title = (state.overlay as DiscoverOverlayState.Hidden).title + press(Key.DirectionRight, Key.DirectionCenter) + rule.runOnIdle { + val lane = state.lanes.first { it.hidden } + state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == title.key }) + } + press(Key.DirectionCenter) + assertTrue(state.overlay is DiscoverOverlayState.Restore) + press(Key.DirectionCenter) + assertFalse(state.isHidden(title)) + assertEquals("Back in your picks.", state.toast) } @Test fun heldOkOpensSheetButNeverSubmits() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt index 0a93e74e..fdab628e 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt @@ -30,6 +30,34 @@ class HomeRatingCacheTest { assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399))) } + @Test fun episodeScoresShareSeriesSeasonIdentityWithoutCrossingEpisodesOrSeasons() { + var now = 0L + val cache = HomeRatingCache { now } + val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) + val pilot = listOf(HomeRating("tmdb", 8.1, episode = true)) + val second = listOf(HomeRating("tmdb", 7.2, episode = true)) + cache.save(episode, pilot) + cache.save(episode.copy(id = "episode-b", episode = 2), second) + assertEquals(pilot, cache.read(episode.copy(id = "another-jellyfin-copy"))) + assertEquals(second, cache.read(episode.copy(id = "another-second-copy", episode = 2))) + assertNull(cache.read(episode.copy(season = 2))) + assertNull(cache.read(episode.copy(tmdbId = 43))) + now = 6L * 60 * 60 * 1000 + assertNull(cache.read(episode)) + } + + @Test fun seasonCapacityCountsSeasonsNotEpisodesAndIsIndependentOfTitleCapacity() { + val cache = HomeRatingCache { 0L } + val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) + repeat(301) { index -> cache.save(episode.copy(id = "episode-$index", episode = index + 1), ratings) } + repeat(300) { index -> cache.save(title.copy(tmdbId = index + 100), ratings) } + assertEquals(ratings, cache.read(episode)) + repeat(300) { index -> cache.save(episode.copy(season = index + 2), ratings) } + assertNull(cache.read(episode)) + assertEquals(ratings, cache.read(episode.copy(season = 301))) + assertEquals(ratings, cache.read(title.copy(tmdbId = 100))) + } + @Test fun decimalScoresFollowLocaleButPercentagesDoNot() { assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL"))) assertEquals("7.8", ratings.single().format(Locale.US)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt index b8d27262..c4130d39 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -172,6 +172,23 @@ class LibraryKeysTest { assertEquals("Title 18", grid.focusedTitle!!.title) } + @Test fun mixedCatalogueRowsKeepMovieAndSeriesWithTheSameNumericIdentityDistinct() { + val titles = List(12) { index -> + val type = if (index < 6) "movie" else "tv" + LibraryTitle((index % 6).toString(), "$type title ${index % 6}", type, tmdbId = (index % 6).toString()) + } + val grid = LibraryGridState("Trending", titles, catalogue = true, onTitle = { opened = it }) + shell.pickerVisible = false + shell.enterDestination("discover") + rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } + press(Key.DirectionDown) + assertEquals(6, grid.index) + press(Key.DirectionCenter) + assertEquals("tv", opened!!.type) + assertEquals("0", opened!!.tmdbId) + assertEquals("tv title 0", opened!!.title) + } + @Test fun scoresFollowLocaleAndUnratedSortsLast() { assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL"))) assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt new file mode 100644 index 00000000..ec217257 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */ +class OwnedSourceAuditTest { + @Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() { + val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) + roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) } + val violations = roots.flatMap { root -> + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source -> + audit(source.name, source.readText()).map { "$source:$it" } + }.toList() + } + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + @Test fun plantedViolationsFailEachDoneBarRule() { + val cases = listOf( + Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"), + Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"), + Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"), + Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"), + Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"), + Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"), + Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"), + Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"), + Triple("Screen.kt", "Text(\"DGiC\")", "wording"), + Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"), + Triple("Screen.kt", "Text(\"Next page\")", "wording"), + Triple("Screen.kt", "Text(\"Previous page\")", "wording"), + Triple("Screen.kt", "Text(\"Page 2\")", "wording"), + ) + cases.forEach { (name, source, rule) -> + assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") }) + } + assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty()) + assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty()) + } + + private fun audit(name: String, source: String): List { + val generated = name == "WolfskinTokens.kt" + return source.lineSequence().mapIndexedNotNull { index, line -> + val code = line.trimStart() + if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null + val rules = buildList { + if (!generated && units.containsMatchIn(line)) add("unit") + if (!generated && colors.containsMatchIn(line)) add("color") + if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor") + if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording") + } + rules.map { "${index + 1}:$it" } + }.flatten().toList() + } + + companion object { + private val units = Regex("\\.(?:dp|sp)\\b") + private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)") + private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b") + private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"") + private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE) + } +} From 81073c410d8118d892e60e9cf6c0d0c97ef710cf Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 02:20:13 +0100 Subject: [PATCH 02/26] Synchronize progressive focus assertions with UI publication --- .../wolfskin/tv/LibraryProgressiveSurfaceTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt index e312a66d..976f9588 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt @@ -74,8 +74,10 @@ class LibraryProgressiveSurfaceTest { org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() shell.view.library?.source?.size == 201 } - assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) - assertFalse(shell.navFocused) + rule.runOnIdle { + assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) + assertFalse(shell.navFocused) + } } finally { release.complete(Unit) } } } From 4e6377b628cf4cfff402dd1384bb36b26bc53f39 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 10:50:56 +0100 Subject: [PATCH 03/26] Keep shell state reads safe in read-only snapshots --- .../wolfskin/tv/DiscoverSnapshotReadTest.kt | 56 +++++++++++++++ docker/README.md | 7 ++ .../wolfskin/design/ShellState.kt | 23 +++++- .../wolfskin/design/ShellSnapshotReadTest.kt | 70 +++++++++++++++++++ .../wolfskin/design/SnapshotReadAuditTest.kt | 46 ++++++++++++ 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt new file mode 100644 index 00000000..cd7d4208 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** No drawn screen may prewarm memory before the production Discover observer starts. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class DiscoverSnapshotReadTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun signedInHostObservesFreshShellThroughTheRealAsyncProvider() { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val identity = requireNotNull(session.identity.value) + assertTrue(identity.signedIn) + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared") + lateinit var current: DiscoverState + rule.setContent { + current = rememberDiscoverState(shell, shell::enterDestination, {}, identity = identity, fixtures = false, + loadFixture = { error("Production observer must not load fixtures") }) + } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + current.snapshot.status == DiscoverStatus.Ready + } + rule.waitForIdle() + rule.runOnIdle { + assertTrue(fake.calls.any { it.url.encodedPath.endsWith("/feed") }) + assertEquals(0, current.focusedRow) + assertEquals(0, current.column) + current.focus(current.firstShelf) + } + rule.waitForIdle() + rule.runOnIdle { + assertNotNull(current.focusedLane) + assertEquals(current.firstShelf, current.focusedRow) + assertEquals(0, current.column) + } + } + } + } +} diff --git a/docker/README.md b/docker/README.md index aeba3db5..db9676ad 100644 --- a/docker/README.md +++ b/docker/README.md @@ -78,6 +78,13 @@ resolves consistently in native captures; the approved JSON weights stay unchang Nav, picker names, labels and pill weights use that same mapping. Nav label colour fades for 200 ms ease-out; the blob retains its critically damped spring. +Shell view accessors are read-only, including the first read from Discover's +`snapshotFlow`. Construction and explicit person/navigation events retain root +and destination memory; reading an unknown destination returns an unsaved default. +Per-person focus, cards and scrollers still survive route changes, while reselecting +the current root resets it. JVM regressions exercise both read-only snapshots and +the signed-in asynchronous Discover host without a screen prewarming its memory. + Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 3e82a286..4afd8415 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -57,6 +57,7 @@ class ShellState( people = value if (selected != null) person = selected pickerIndex = people.indexOf(person).coerceAtLeast(0) + preparePersonMemory() } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set @@ -73,7 +74,7 @@ class ShellState( var requestsVisible by mutableStateOf(false) var toast by mutableStateOf(null) private val personRequests = mutableMapOf() - val requests: ComingInState get() = personRequests.getOrPut(person.key) { ComingInState() } + val requests: ComingInState get() = personRequests.getValue(person.key) private var requestReturnRoute: String? = null var handOff by mutableStateOf(null) var settingsRow by mutableStateOf(0) @@ -85,17 +86,30 @@ class ShellState( var contentFocus: ShellContentFocus? = null var inputRevision = 0L private set + + init { preparePersonMemory() } + + private fun preparePersonMemory() { + personRequests.getOrPut(person.key) { ComingInState() } + tabs.forEach(::retainView) + retainView(route) + } + + private fun retainView(destination: String) { + views.getOrPut(person.key to destination) { RememberedView() } + } val tabs: List get() = if (person.hasAnime) ANIME_TABS else BASE_TABS val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) val view: RememberedView get() = rememberedView(route) val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt - /** Screen options share the same per-person memory as their root's focus and scroller. */ + /** Reads never register defaults: snapshotFlow evaluates accessors in a read-only snapshot. */ fun rememberedView(destination: String): RememberedView = - views.getOrPut(person.key to destination) { RememberedView() } + views[person.key to destination] ?: RememberedView() /** External intents and upstream pops remain authoritative over the screen. */ fun enterDestination(destination: String) { + retainView(destination) route = destination if (destination in tabs) rootRoute = destination handOff = null @@ -159,6 +173,7 @@ class ShellState( pickerVisible = false navFocused = false settingsRow = 0 + retainView("settings") route = "settings" onAction(ShellAction.Destination(route)) } @@ -187,6 +202,7 @@ class ShellState( rootRoute = "home" requestReturnRoute = null requestsVisible = false + preparePersonMemory() views[person.key to route] = RememberedView() onAction(ShellAction.Person(selected)) } @@ -226,6 +242,7 @@ class ShellState( } else { val target = tabs[navIndex] requestReturnRoute = null + retainView(target) if (target == route) views[person.key to target] = RememberedView() route = target rootRoute = target diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt new file mode 100644 index 00000000..a1762334 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.test.junit4.createComposeRule +import kotlinx.coroutines.flow.first +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** A first read must work without composition prewarming the person's route memory. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellSnapshotReadTest { + @get:Rule val rule = createComposeRule() + + @Test fun unseenViewCanBeReadInAReadOnlySnapshot() { + val shell = ShellState(fixturePeople(), "justin") + val snapshot = Snapshot.takeSnapshot() + try { + snapshot.enter { + assertEquals(0, shell.view.row) + assertTrue(shell.view.cards.isEmpty()) + assertNull(shell.view.savedAt) + assertEquals(0, shell.rememberedView("never-seen").row) + assertNotSame(shell.rememberedView("never-seen"), shell.rememberedView("never-seen")) + } + } finally { snapshot.dispose() } + } + + @Test fun launchedSnapshotFlowReadsDefaultsAndThenRetainsExplicitNavigationMemory() { + val shell = ShellState(fixturePeople(), "justin") + var observed: RememberedView? = null + rule.setContent { + LaunchedEffect(shell) { observed = snapshotFlow { shell.view }.first() } + } + rule.runOnIdle { + val home = requireNotNull(observed) + assertEquals(0, home.row) + assertTrue(home.cards.isEmpty()) + home.row = 3 + home.cards["shelf"] = 2 + shell.enterDestination("movies") + assertEquals(0, shell.view.row) + shell.view.row = 5 + shell.enterDestination("home") + assertSame(home, shell.view) + assertEquals(3, shell.view.row) + assertEquals(2, shell.view.cards["shelf"]) + shell.enterDestination("movies") + assertEquals(5, shell.view.row) + shell.focusPerson(1) + shell.key(ShellKey.Ok) + shell.enterDestination("movies") + assertEquals(0, shell.view.row) + shell.view.row = 7 + shell.pickerVisible = true + shell.focusPerson(0) + shell.key(ShellKey.Ok) + shell.enterDestination("movies") + assertEquals(5, shell.view.row) + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt new file mode 100644 index 00000000..ab5fdd4d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import java.io.File +import org.junit.Assert.* +import org.junit.Test + +/** Guard known state writers; runtime tests cover transitive property reads. */ +class SnapshotReadAuditTest { + @Test fun snapshotObserversDoNotCallKnownStateWriters() { + val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) + val violations = roots.flatMap { root -> + assertTrue("Missing owned source root: $root", root.isDirectory) + root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { file -> + observerBodies(file.readText()).filter { writingCalls.containsMatchIn(it) }.map { "$file: $it" } + }.toList() + } + assertTrue(violations.joinToString("\n"), violations.isEmpty()) + } + + @Test fun shellReadAccessorDoesNotInsertMemory() { + val source = File("src/main/java/org/justinvisser/wolfskin/design/ShellState.kt").readText() + val accessor = source.substringAfter("fun rememberedView(").substringBefore("/**") + assertFalse("rememberedView must not insert into snapshot state", Regex("getOrPut|\\.put\\s*\\(|\\]\\s*=").containsMatchIn(accessor)) + } + + @Test fun knownWriterIsRejectedWithoutRejectingItsCollector() { + val unsafe = observerBodies("snapshotFlow { state.focus(2) }").single() + assertTrue(writingCalls.containsMatchIn(unsafe)) + val safe = observerBodies("snapshotFlow { state.focusedLane?.id to state.column }.collect { state.focus(2) }").single() + assertFalse(writingCalls.containsMatchIn(safe)) + } + + private fun observerBodies(source: String): List = Regex("snapshotFlow\\s*\\{").findAll(source).map { match -> + val start = match.range.last + 1 + var depth = 1 + var end = start + while (end < source.length && depth > 0) { + when (source[end]) { '{' -> depth++; '}' -> depth-- } + end++ + } + source.substring(start, end - 1) + }.toList() + + private val writingCalls = Regex("\\b(?:retainView|preparePersonMemory|enterDestination|focus|updateSnapshot|updateLanes|invalidateLanes|updateRatings|recordCenter|getOrPut|put|putAll|clear|remove)\\s*\\(") +} From d401ebc35541eace57dc3feb9f647c8e91adee7a Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 11:27:35 +0100 Subject: [PATCH 04/26] Fix TV startup navigation and complete title pages --- .../wolfskin/tv/DiscoverLoadingFocusTest.kt | 1 - .../wolfskin/tv/DiscoverTitleTimingTest.kt | 98 +++++++++++ .../wolfskin/tv/FakeConductarr.kt | 2 + .../wolfskin/tv/NavBandOcclusionTest.kt | 54 ++++++ .../wolfskin/tv/ShellStartupTimingTest.kt | 163 ++++++++++++++++++ .../wolfskin/tv/TitleReadFacadeTest.kt | 24 +++ .../justinvisser/wolfskin/tv/DiscoverHost.kt | 12 -- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 8 +- .../wolfskin/tv/read/TitleReadFacade.kt | 18 +- docker/README.md | 16 ++ .../wolfskin/design/DiscoverState.kt | 4 +- .../justinvisser/wolfskin/design/NavBar.kt | 7 +- .../wolfskin/design/DiscoverKeysTest.kt | 2 +- 13 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt index b18f6a22..d2530d4f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt @@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest { rule.runOnIdle { assertNotNull(current.title) assertEquals("The Amazing Spider-Man", current.title!!.title) - assertEquals("discover-title/${current.title!!.key}", shell.route) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt new file mode 100644 index 00000000..9fbbeaa0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -0,0 +1,98 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import io.mockk.coEvery +import io.mockk.mockk +import java.util.concurrent.CountDownLatch +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverTitleTimingTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() { + FakeConductarr().use { fake -> + fake.localArt = true + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val identity = session.identity.value!! + val facade = mockk() + coEvery { facade.findHeld(any(), any()) } returns null + val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination("discover") + } + lateinit var discover: DiscoverState + val release = CountDownLatch(1) + try { + rule.setContent { + val route = (navigation.backStack.last() as Destination.Wolfskin).route + LaunchedEffect(route) { shell.enterDestination(route) } + discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false) + WolfskinTheme { + if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false) + else ShellSurface(shell) { DiscoverScreen(discover, it) } + } + } + rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready } + rule.runOnIdle { + fake.beforeResponse = { if (it.endsWith("/cast")) release.await() } + discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning")) + } + rule.waitForIdle() + assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/")) + rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } } + rule.runOnIdle { release.countDown() } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true + } + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + assertEquals("cast", model.title!!.row) + rule.onNodeWithText("Sam Rockwell").assertIsDisplayed() + val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + assertEquals("related", model.title!!.row) + rule.onNodeWithText("More like this").assertIsDisplayed() + rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) + rule.onRoot().performKeyInput { pressKey(Key.Back) } + rule.waitForIdle() + assertNull(discover.title) + assertEquals("discover", shell.route) + } finally { + release.countDown() + model.viewModelScope.cancel() + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index b682b34c..c751b56e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -26,6 +26,7 @@ internal class FakeConductarr : Closeable { @Volatile var emptyAudiences = false @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false + @Volatile var beforeResponse: ((String) -> Unit)? = null private val imageBytes = java.util.Base64.getDecoder().decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") @@ -37,6 +38,7 @@ internal class FakeConductarr : Closeable { if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder() .body(okio.Buffer().write(imageBytes)).build() val path = request.url.encodedPath + beforeResponse?.invoke(path) if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus) if (request.method == "POST") return response(wire.getValue("hide").toString()) if (request.method == "DELETE") return response(wire.getValue("restore").toString()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt new file mode 100644 index 00000000..5f2aa9e7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt @@ -0,0 +1,54 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Scrolled cards must be occluded across the entire persistent navigation band. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavBandOcclusionTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun scrolledRowsNeverShowThroughTheNavBand() { + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { + pickerVisible = false; pickerLaunch = false; enterDestination("discover") + } + rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> + LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) { + items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) } + } + } } } + rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } } + rule.waitForIdle() + val pixels = rule.onRoot().captureToImage().toPixelMap() + val background = WolfskinTokens.Color.bg + for (y in listOf(5, 50, 135)) { + val actual = pixels[20, y] + assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f) + assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f) + assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt new file mode 100644 index 00000000..c3bf121a --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -0,0 +1,163 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID +import kotlinx.coroutines.* +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellStartupTimingTest { + @Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) } + @After fun resetMain() { Dispatchers.resetMain() } + + @Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previous = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + conductarr.authenticate() + val activityEntered = CompletableDeferred() + val releaseActivity = java.util.concurrent.CountDownLatch(1) + fake.beforeResponse = { + if (it == "/api/v1/activity") { + activityEntered.complete(Unit) + releaseActivity.await() + } + } + val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() } + withTimeout(5_000) { activityEntered.await() } + val savedPeople = CompletableDeferred() + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null } + val navigation = mockk(relaxed = true) + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val load = launch { model.load(server, user) } + try { + yield() + val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" } + assertEquals(PeopleStatus.Ready, shell.peopleStatus) + assertEquals(user.id.toString(), shell.person.jellyfinUserId) + shell.key(ShellKey.Ok) + for (route in listOf("movies", "shows", "discover")) { + shell.focusNav(shell.tabs.indexOf(route)) + shell.key(ShellKey.Ok) + verify { navigation.replace(Destination.Wolfskin(route)) } + assertEquals(route, shell.route) + } + savedPeople.complete(Unit) + load.join() + assertSame(shell, session.state) + assertEquals("discover", shell.route) + assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted) + } finally { + releaseActivity.countDown() + activity.cancelAndJoin() + savedPeople.complete(Unit) + load.cancelAndJoin() + model.viewModelScope.cancel() + } + } + } + ConductarrPreference.updateBaseUrl(previous) + } + + @Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + val previous = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns + com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel)) + val switched = CompletableDeferred() + coEvery { repository.changeUser(server, mindel) } coAnswers { + switched.complete(Unit) + com.github.damontecres.wholphin.data.CurrentUser(server, mindel) + } + val navigation = mockk(relaxed = true) + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val pending = CompletableDeferred() + val release = java.util.concurrent.CountDownLatch(1) + val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) + fake.beforeResponse = { + if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) { + pending.complete(Unit) + release.await() + } + } + try { + model.load(server, shared) + withTimeout(5_000) { pending.await() } + val shell = requireNotNull(session.state) + shell.key(ShellKey.Ok) + shell.enterDestination("discover") + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(1) + shell.key(ShellKey.Ok) + assertEquals("home", shell.route) + verify { navigation.replace(Destination.Home()) } + withTimeout(5_000) { switched.await() } + model.load(server, mindel) + requireNotNull(model.onForeground(server, mindel)).join() + release.countDown() + assertSame(shell, session.state) + assertEquals("mindel", model.signedInPerson) + assertEquals(mindel.id.toString(), shell.person.jellyfinUserId) + assertEquals("Mindel", shell.person.name) + assertEquals("mindel", conductarr.identity.value!!.operator!!.requester) + assertEquals("home", shell.route) + } finally { + release.countDown() + model.viewModelScope.cancel() + } + } + } + ConductarrPreference.updateBaseUrl(previous) + } +} 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 cc4d75d6..fde2ff9c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -81,6 +81,7 @@ class TitleReadFacadeTest { every { repository.currentServer } returns null every { repository.currentUser } returns null coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null + coEvery { items.getItems(any()) } returns successQueryResult(emptyList()) val context = mockk { every { filesDir } returns directory.root } facade = TitleReadFacade(api, mockk(relaxed = true), playback, repository, mockk(), context) } @@ -103,6 +104,29 @@ class TitleReadFacadeTest { mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams))) } + @Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { items.getItems(any()) } returns successQueryResult( + listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42")))) + val page = facade.read(firstId, null, preferences) + assertEquals(42L, page.item.tmdbId) + assertEquals(firstId.toString(), page.watchTarget.id) + assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id) + FakeConductarr().use { fake -> + ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {} + ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId), + org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) } + assertEquals(listOf("header", "cast", "related"), screen.rows) + assertEquals("Sam Rockwell", screen.data.cast.single().name) + assertEquals(firstId.toString(), screen.target?.id) + assertTrue(screen.data.item.held) + } + } + } + @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) facade.read(firstId, null, preferences) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index 8b11ee3a..ee29266b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -95,18 +95,6 @@ internal fun rememberDiscoverState( } } } - LaunchedEffect(state.title?.key, state) { - val title = state.title ?: return@LaunchedEffect - if (!fixtures && identity?.signedIn == true) { - val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV - identity.provider.detail(title.tmdb, media).collect { result -> - val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value } - if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy( - runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(), - seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title))) - } - } - } val result = if (fixtures) fixtureState ?: state else state LaunchedEffect(shell.route, result) { result.enterRoute(shell.route) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 3d182180..a9948c2a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -95,14 +95,14 @@ class WolfskinShellViewModel @Inject constructor( if (ConductarrPreference.fixtures) conductarr.close() else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() + session.onAction = { action -> handleAction(action, server, user) } if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { - val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } - val people = readShellPeople(context, saved.ifEmpty { listOf(user) }) session.serverId = server.id.toString() fixtureMode = ConductarrPreference.fixtures - session.state = ShellState(people, signedInPerson) { session.onAction(it) } + session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) } + val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } + session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) })) } - session.onAction = { action -> handleAction(action, server, user) } identityJob = viewModelScope.launch { if (!ConductarrPreference.fixtures) loadHousehold(user) } 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 73ebcd07..4b3a3a4a 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 @@ -83,8 +83,8 @@ class TitleReadFacade @Inject constructor( /** An episode opens its series with that exact episode as both play and watched target. */ suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { val file = cacheFile(id) - val opened = api.userLibraryApi.getItem(id).content - val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened + val opened = readItem(id) + 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 } @@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor( result.copy(trailerUrl = trailer) } + /** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */ + private suspend fun readItem(id: UUID): BaseItemDto { + val item = api.userLibraryApi.getItem(id).content + if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item + val metadata = try { + api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id, + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id } + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } ?: return item + return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) + } + /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } diff --git a/docker/README.md b/docker/README.md index db9676ad..fba9a5d9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -473,6 +473,15 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin logins; that does not create selectable TV logins. Late name reads preserve the open chooser's selected recipient and footer focus. +The launch picker publishes the current Jellyfin user and working navigation before +the saved-login database read. Saved people and Conductarr household metadata fill +that same shell later; slow activity or catalogue reads cannot hold its first usable +frame. Discover details navigate to the shared title page, including cast, performer +navigation and related titles, rather than the header-only design preview. Held +items whose detail omits ProviderIds or People get an explicit Jellyfin field read +before catalogue enrichment, without changing their playback identity. Scrolled +navigation draws an opaque band; the unscrolled hero treatment is unchanged. + Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from every non-Jellyfin artwork origin (scheme, host and port), including redirects; @@ -504,6 +513,13 @@ and a same-host/different-port origin with an authenticated upstream parent, and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers operator success plus failed/empty audiences, name-rule retention and foreground healing. +`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending +while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest` +covers omitted held-film provider ids with authenticated cast/related enrichment. +`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back +and writes native cast/related captures to `walk-fixes/`. +`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. + Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. Unheld trailers remain absent because the server contract has no trailer field. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 65143485..f0e2dabb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -169,7 +169,7 @@ class DiscoverState( fun openTitle(item: DiscoverTitle) { title = item; titleButton = 0 if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key) - onPush("discover-title/${item.key}") + onPush("title/${android.net.Uri.encode(item.key)}") } fun openCollection(lane: DiscoverLane) { var titles = lane.fullTitles.associateBy { it.key } @@ -237,7 +237,7 @@ class DiscoverState( /** Card hold/release handling belongs to the shell, including the shared catalogue grid. */ override fun key(key: ShellKey): Boolean { if (overlay != null) { overlayKey(key); return true } - if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false + if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true } if (title == null) collection?.let { if (key == ShellKey.Back) { collection = null; onPop(); return true } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 9042e3fe..1fedb8c7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") val density = LocalDensity.current.density - val band = remember(scrolled) { - if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f)) - else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) + val band = remember { + Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) } Box(modifier.fillMaxWidth().height(L.navBand)) { Canvas(Modifier.matchParentSize()) { - drawRect(band) + if (scrolled) drawRect(C.bg) else drawRect(band) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) } Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index ae66b19b..e5bd3c3a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -36,7 +36,7 @@ class DiscoverKeysTest { rule.setContent { WolfskinTheme { ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> - if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) + if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) } } } From afff383b75cbd2a8e5ebde32d7ebe9ccbc1671db Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 11:52:48 +0100 Subject: [PATCH 05/26] Read live shell state inside cached navigation entries --- .../wolfskin/tv/FakeConductarr.kt | 2 + .../wolfskin/tv/FirstSignInNavigationTest.kt | 122 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- docker/README.md | 9 ++ 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index c751b56e..04a8ac97 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -27,6 +27,7 @@ internal class FakeConductarr : Closeable { @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false @Volatile var beforeResponse: ((String) -> Unit)? = null + @Volatile var audiencesBody: String? = null private val imageBytes = java.util.Base64.getDecoder().decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") @@ -66,6 +67,7 @@ internal class FakeConductarr : Closeable { } if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) + if (key == "audiences") audiencesBody?.let { return response(it) } if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") var body = wire.getValue(key).jsonObject diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt new file mode 100644 index 00000000..b0102a5b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -0,0 +1,122 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.* +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class FirstSignInNavigationTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { + ConductarrPreference.initialize(rule.activity) + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.initialize(rule.activity) + WolfskinScreenPreference.enabled = true + val previousUrl = ConductarrPreference.baseUrl + FakeConductarr().use { fake -> + fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + val api = mockk() + val views = mockk() + val items = mockk() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) + val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) + for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { + coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, + includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( + List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) + } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( + HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) + try { + rule.setContent { + WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model, + homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + conductarr.identity.value?.signedIn == true && model.libraries.audiences != null + } + rule.onNodeWithText("Mindel").assertIsDisplayed() + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { + pressKey(Key.DirectionUp) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionRight) + pressKey(Key.DirectionCenter) + } + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Desperate Housewives").assertDoesNotExist() + assertEquals(6, session.state!!.view.library!!.source.size) + assertFalse(session.state!!.person.hasAnime) + rule.onNodeWithText("Shows").performClick() + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + session.state!!.view.library?.source?.size == 29 + } + rule.onNodeWithTag("library-grid").assertIsDisplayed() + rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() + rule.onNodeWithText("Home").performClick() + rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() + assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) + } finally { model.viewModelScope.cancel() } + } + } + ConductarrPreference.updateBaseUrl(previousUrl) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index a9948c2a..cfc1617b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -230,6 +230,8 @@ fun WolfskinApp( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinShellViewModel = hiltViewModel(), + homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = + { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } @@ -274,13 +276,16 @@ fun WolfskinApp( entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + // Cached entries must not retain the launch shell after sign-in completes. + val state = LocalShellState.current + val discover = LocalDiscoverState.current val route = when (destination) { is Destination.Home -> "home" is Destination.Wolfskin -> destination.route is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) else -> "pushed" } - LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } + LaunchedEffect(destination, state) { if (!destination.fullScreen) state.enterDestination(route) } if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { @@ -297,7 +302,7 @@ fun WolfskinApp( when { (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) - destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) + destination is Destination.Home -> homeContent(state, server, user, contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) diff --git a/docker/README.md b/docker/README.md index fba9a5d9..5e2b488e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -481,6 +481,10 @@ navigation and related titles, rather than the header-only design preview. Held items whose detail omits ProviderIds or People get an explicit Jellyfin field read before catalogue enrichment, without changing their playback identity. Scrolled navigation draws an opaque band; the unscrolled hero treatment is unchanged. +Cached `NavDisplay` entries read the current shell and Discover composition locals, +not the temporary launch shell captured when the entry was first created. Otherwise +the screen can retain skeleton people, disconnected tab actions and an open picker +even after the real session is ready. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from @@ -519,6 +523,11 @@ covers omitted held-film provider ids with authenticated cast/related enrichment `DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back and writes native cast/related captures to `walk-fixes/`. `NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. +`FirstSignInNavigationTest` starts the full app host with no existing shell and +immediate signed-in replies, then continues as Mindel and renders her six-film +Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also +checks that Continue closes the real session picker and selecting Home does not +reopen it. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. From 22850632ec1e355cf096f35b56709e70af833c23 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:03:20 +0100 Subject: [PATCH 06/26] Restrict shell route and Back handling to the current entry --- .../wolfskin/tv/FirstSignInNavigationTest.kt | 14 +++++++++++--- .../org/justinvisser/wolfskin/tv/WolfskinApp.kt | 7 +++++-- docker/README.md | 2 ++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index b0102a5b..63442159 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -105,9 +105,17 @@ class FirstSignInNavigationTest { assertEquals(6, session.state!!.view.library!!.source.size) assertFalse(session.state!!.person.hasAnime) rule.onNodeWithText("Shows").performClick() - rule.waitUntil(10_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - session.state!!.view.library?.source?.size == 29 + try { + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + session.state!!.view.library?.source?.size == 29 + } + } catch (timeout: ComposeTimeoutException) { + val shell = session.state!! + throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " + + "movies=${shell.rememberedView("movies").library?.source?.size}, " + + "shows=${shell.rememberedView("shows").library?.source?.size}/" + + "${shell.rememberedView("shows").library?.status}", timeout) } rule.onNodeWithTag("library-grid").assertIsDisplayed() rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index cfc1617b..52a68beb 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -285,11 +285,14 @@ fun WolfskinApp( is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) else -> "pushed" } - LaunchedEffect(destination, state) { if (!destination.fullScreen) state.enterDestination(route) } + val isCurrent = navigationManager.backStack.lastOrNull() == destination + LaunchedEffect(destination, state, isCurrent) { + if (isCurrent && !destination.fullScreen) state.enterDestination(route) + } if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { - BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } + BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null diff --git a/docker/README.md b/docker/README.md index 5e2b488e..bd1667dc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -485,6 +485,8 @@ Cached `NavDisplay` entries read the current shell and Discover composition loca not the temporary launch shell captured when the entry was first created. Otherwise the screen can retain skeleton people, disconnected tab actions and an open picker even after the real session is ready. +Only the current navigation entry may set the shared route or handle Back; +outgoing entries can remain composed during a transition. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from From 5a84666074cb68a5c9e901b1bb6f4ccfd7402ba7 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:11:42 +0100 Subject: [PATCH 07/26] Synchronize fresh-sign-in navigation and restore test globals --- .../wolfskin/tv/FirstSignInNavigationTest.kt | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 63442159..aa1faf95 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -26,6 +26,8 @@ import org.jellyfin.sdk.api.operations.ItemsApi import org.jellyfin.sdk.api.operations.UserViewsApi import org.jellyfin.sdk.model.api.* import org.junit.Assert.* +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -41,13 +43,29 @@ import org.robolectric.annotation.Config @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") class FirstSignInNavigationTest { @get:Rule val rule = createAndroidComposeRule() + private lateinit var previousUrl: String + private var previousFixtures = false + private var previousScreens = true + + @Before fun captureGlobalPreferences() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + rule.mainClock.autoAdvance = true + } + + @After fun restoreGlobalPreferences() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + rule.mainClock.autoAdvance = true + } @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { - ConductarrPreference.initialize(rule.activity) ConductarrPreference.updateFixtures(false) - WolfskinScreenPreference.initialize(rule.activity) WolfskinScreenPreference.enabled = true - val previousUrl = ConductarrPreference.baseUrl FakeConductarr().use { fake -> fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" ConductarrPreference.updateBaseUrl(fake.url) @@ -95,6 +113,7 @@ class FirstSignInNavigationTest { pressKey(Key.DirectionRight) pressKey(Key.DirectionCenter) } + rule.waitForIdle() rule.waitUntil(10_000) { Shadows.shadowOf(Looper.getMainLooper()).idle() rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() @@ -105,10 +124,12 @@ class FirstSignInNavigationTest { assertEquals(6, session.state!!.view.library!!.source.size) assertFalse(session.state!!.person.hasAnime) rule.onNodeWithText("Shows").performClick() + rule.waitForIdle() try { - rule.waitUntil(10_000) { + rule.waitUntil(timeoutMillis = 15_000) { Shadows.shadowOf(Looper.getMainLooper()).idle() - session.state!!.view.library?.source?.size == 29 + rule.onAllNodes(hasTestTag("library-grid") and + hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty() } } catch (timeout: ComposeTimeoutException) { val shell = session.state!! @@ -118,13 +139,14 @@ class FirstSignInNavigationTest { "${shell.rememberedView("shows").library?.status}", timeout) } rule.onNodeWithTag("library-grid").assertIsDisplayed() + assertEquals(29, session.state!!.view.library!!.source.size) rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() rule.onNodeWithText("Home").performClick() + rule.waitForIdle() rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) } finally { model.viewModelScope.cancel() } } } - ConductarrPreference.updateBaseUrl(previousUrl) } } From 53b8358317c4f8697624edb92472ce9b30db96a9 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:25:51 +0100 Subject: [PATCH 08/26] Hide Anime destinations without visible library titles --- .../wolfskin/tv/AnimeAvailabilityTest.kt | 101 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- .../wolfskin/tv/read/LibraryReadFacade.kt | 12 +++ docker/README.md | 8 ++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt new file mode 100644 index 00000000..e5d0dfa5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -0,0 +1,101 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.successQueryResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeAvailabilityTest { + private var previousFixtures = false + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext()) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) { + for (count in listOf(0, 1)) FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token) + conductarr.select("${server.id}:${user.id}", token, fake.url) + conductarr.authenticate() + val identity = conductarr.identity.value!! + val audiences = identity.provider.audiences()!!.filter { + it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty()) + } + val names = audiences.flatMap { it.libraries.orEmpty() }.distinct() + assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty()) + val views = mockk() + val items = mockk() + val api = mockk() + every { api.userViewsApi } returns views + every { api.itemsApi } returns items + coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map { + BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) + }) + val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) } + coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true, + includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles) + val libraries = LibraryReadFacade(api) + val session = WolfskinSession().apply { + state = ShellState(listOf(ShellPerson(key, key, key.take(1), false, user.id.toString())), key) + } + val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + requireNotNull(model.onForeground(server, user)).join() + assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) + val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }), + destinationTotals = mapOf("anime" to count)) + assertEquals("An empty Anime audience must not create a Home shelf", count > 0, + buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" }) + } finally { model.viewModelScope.cancel() } + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 52a68beb..b654df48 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -123,12 +123,19 @@ class WolfskinShellViewModel @Inject constructor( if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } + val hasAnime = try { + libraries.hasAnimeTitles(user.id) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false + } + if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return val updated = session.state?.people.orEmpty().map { person -> val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } val current = person.jellyfinUserId == user.id.toString() person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, name = if (current) identity.operator!!.name else profile?.name ?: person.name, - hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime) + hasAnime = if (current) hasAnime else person.hasAnime) } session.state?.updatePeople(updated) signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson 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 cc9497f1..775235f2 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 @@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { } } + /** Audience metadata names a grouping; only this user's visible titles make Anime available. */ + suspend fun hasAnimeTitles(userId: UUID): Boolean { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() + for (library in libraries) { + if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue + val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, + includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty() + if (titles.isNotEmpty()) return true + } + return false + } + /** A destination spans every matching accessible library; containers never become titles. */ fun readLibrary(userId: UUID, destination: String): Flow> = flow { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> diff --git a/docker/README.md b/docker/README.md index bd1667dc..b1a5993d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -494,6 +494,11 @@ every non-Jellyfin artwork origin (scheme, host and port), including redirects; current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps the existing mapping or Jellyfin name rule. Returning to the foreground retries household metadata without rebuilding the selected identity. +An Anime audience grants a library grouping, not proof of owned titles. Household +refresh enables Anime only after a one-title Jellyfin read confirms visible series +in an accessible Anime library for the signed-in user. This runs after the live +picker is published; failures retain that user's last confirmed availability. +Empty Anime Home shelves remain omitted. | Screen seam | Production supplier | | --- | --- | @@ -530,6 +535,9 @@ immediate signed-in replies, then continues as Mindel and renders her six-film Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also checks that Continue closes the real session picker and selecting Home does not reopen it. +`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared +and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or +Home shelf, while a real visible series enables both. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. From 9e15dbee8c97dede2fba370693f09eaba736f403 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:33:10 +0100 Subject: [PATCH 09/26] Preserve navigation focus when Anime availability changes --- .../org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt | 7 ++++++- docker/README.md | 2 ++ .../java/org/justinvisser/wolfskin/design/ShellState.kt | 5 ++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index e5d0dfa5..35e45892 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -82,13 +82,18 @@ class AnimeAvailabilityTest { includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles) val libraries = LibraryReadFacade(api) val session = WolfskinSession().apply { - state = ShellState(listOf(ShellPerson(key, key, key.take(1), false, user.id.toString())), key) + state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply { + pickerVisible = false + focusNav(tabs.size) + } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) + assertEquals("The avatar must remain focused when Anime appears or disappears", + session.state!!.tabs.size, session.state!!.navIndex) val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }), destinationTotals = mapOf("anime" to count)) assertEquals("An empty Anime audience must not create a Home shelf", count > 0, diff --git a/docker/README.md b/docker/README.md index b1a5993d..1996dd3d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -499,6 +499,8 @@ refresh enables Anime only after a one-title Jellyfin read confirms visible seri in an accessible Anime library for the signed-in user. This runs after the live picker is published; failures retain that user's last confirmed availability. Empty Anime Home shelves remain omitted. +When availability changes, focus follows the avatar or a surviving destination; +removing Anime never leaves an out-of-range nav selection. | Screen seam | Production supplier | | --- | --- | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 4afd8415..e54c7ed8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -50,14 +50,17 @@ class ShellState( var people by mutableStateOf(people) private set - /** Refresh household labels without discarding this person's navigation memory. */ + /** Refresh household data without losing valid nav focus or this person's view memory. */ fun updatePeople(value: List) { + val focusedTab = tabs.getOrNull(navIndex) + val avatarFocused = navIndex == tabs.size val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } ?: value.firstOrNull { it.key == person.key } people = value if (selected != null) person = selected pickerIndex = people.indexOf(person).coerceAtLeast(0) preparePersonMemory() + navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set From c17447c528ed66db480ca55654005cfc55071316 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 12:53:51 +0100 Subject: [PATCH 10/26] Give TV cards roomy spacing and edge-straddling focus rings --- .../wolfskin/tv/TitleCaptureTest.kt | 3 +- docker/README.md | 30 ++++- .../wolfskin/design/CatalogueRecoveryCard.kt | 2 +- .../wolfskin/design/DiscoverShelf.kt | 2 +- .../org/justinvisser/wolfskin/design/Focus.kt | 17 ++- .../wolfskin/design/LibraryGridState.kt | 2 +- .../wolfskin/design/LibraryScreen.kt | 6 +- .../justinvisser/wolfskin/design/TitleCard.kt | 2 +- .../wolfskin/design/TitleShelf.kt | 15 ++- .../wolfskin/design/WolfskinTokens.kt | 45 +++---- .../wolfskin/design/HomeCaptureTest.kt | 1 + .../wolfskin/design/LibraryCaptureTest.kt | 8 ++ .../wolfskin/design/LibraryKeysTest.kt | 11 +- .../src/test/resources/library-screen.json | 60 ++++----- wolfskin-design/title-tokens.json | 6 +- wolfskin-design/tokens.css | 15 +-- wolfskin-design/tokens.json | 119 ++++++++++-------- wolfskin-design/tokens.sha256 | 8 +- 18 files changed, 210 insertions(+), 142 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 90d6fcc1..55f9d747 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -87,7 +87,7 @@ class TitleCaptureTest { data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.episodes.forEach { item -> - (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) } + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } } } return requests.distinctBy { it.first }.associate { (path, width, height) -> @@ -215,4 +215,5 @@ class TitleCaptureTest { @Test fun st107_seriesEpisodeRatings() = capture(107, "Andor") @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") + @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } } diff --git a/docker/README.md b/docker/README.md index 1996dd3d..e156f2cf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -310,9 +310,10 @@ and device deployment remain separate. ## Libraries and See all Movies, Shows and Anime use the shell's retained view and a continuous six-column -lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves -the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail -exists only at 200 titles with A–Z selected. Card OK activates on key-up through +lazy grid, reduced to five columns while the letter rail is visible so scaled +posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav; +Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating. +The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through the same shell hold guard, without a long-press menu. Down clamps a short final title row before entering the separate Try again / Show more row; an empty slot cannot activate recovery. An unavailable library hides @@ -347,6 +348,29 @@ music keep their upstream destinations. failure/retry, stale copies, and person/server isolation. Library captures are written to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. +### Roomy card spacing + +The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf +heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080. +Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px, +preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px: +chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear). +Discover and season-chip rows retain their 104 px height. + +Cards and cast circles draw a single 4 px accent stroke centred on the art edge, +above the content, with no background gap and the existing card lift. The stroke +and art scale together at 1.08; buttons, avatars, chips and picker focus are +unchanged. Title shelves reserve vertical drawing room for the scaled ring. +Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px +for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. + +`LibraryCaptureTest.spacingShowsFirstFocused`, +`HomeCaptureTest.spacingNextUpFirstFocused` and +`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states +at native resolution. Their files are `library/spacing-shows.png`, +`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`. +Platform Roboto metrics and fixture ordering are not browser pixel parity. + ## Title pages and people `TitleSurface` owns the film/series/episode header, season chips, episode tiles, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt index db0a22f6..b2bb7ca4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) .focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 4492a69c..7af7ec22 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) - .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card) + .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 147409ea..a4a69580 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp /** Only transforms animate: focus never changes measured geometry. */ @@ -37,9 +39,9 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { /** Content lifts are distinct from the white person/gear rings. */ enum class FocusLift { None, Card, Button } -/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */ +/** Card rings straddle the art edge above content; control rings retain their outside gap. */ @Composable -fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier { +fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier { val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") val shadow = if (lift == FocusLift.None) this else { @@ -54,6 +56,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } + if (straddle) return shadow.drawWithCache { + val stroke = Stroke(width.toPx()) + val corner = CornerRadius(radius.toPx()) + onDrawWithContent { + drawContent() + if (ringAlpha > 0f) { + drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), + cornerRadius = corner, style = stroke) + } + } + } return shadow.drawBehind { if (ringAlpha > 0f) { val outer = (gap + width).toPx() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index bd36f97f..2b6bcc1a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -91,7 +91,7 @@ class LibraryGridState( var inRail by mutableStateOf(false) var railLetter by mutableStateOf('#') private var railReturn = 0 - val columns = WolfskinTokens.Behaviour.gridColumns.toInt() + val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0 val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" } val hasToolbar: Boolean get() = !catalogue || mixed val items: List by derivedStateOf { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 07082c4f..93fb47a0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -94,7 +94,7 @@ fun LibraryScreen( if (grid.toolbar || row == 0 && active) { animateLibraryScroll(view, headerCount, 0f, density) } else if (row > 0) { - val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } + val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } animateLibraryScroll(view, headerCount, target, density) } } @@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav @Composable private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { - Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { + Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All") labels.forEachIndexed { index, label -> val focused = active && grid.toolbar && grid.tool == index @@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, val current = with(density) { (if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead + if (first < headerCount) WolfskinTokens.Shell.zero else - (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() } + view.listState.firstVisibleItemScrollOffset animateFrameScroll(view.listState, target - current) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index 81779ed3..fa51ac7c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) { + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index dcfaadc7..0e3e8315 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -51,9 +51,10 @@ fun TitleCast(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } - Column(Modifier.height(D.castHeight)) { + Box(Modifier.height(D.castHeight)) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { if (state.data.castStatus == TitleReadStatus.Loading) { items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) @@ -63,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) { Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { - PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape)) + PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true).clip(CircleShape)) Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) @@ -85,9 +86,11 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } - Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { + Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) + .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2), + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) @@ -112,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) 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 9cf06f03..58f5a3df 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 @@ -112,9 +112,9 @@ object WolfskinTokens { val navSolidAfter = 20.0.dp val pinnedLine = 84.0.dp val windowAhead = 350.0.dp - val rowHeadingBand = 30.0.dp - val rowGapAfter = 22.0.dp - val gridRowGap = 14.0.dp + val rowHeadingBand = 42.0.dp + val rowGapAfter = 34.0.dp + val gridRowGap = 22.0.dp val row_hero = 410.0.dp val row_phero = 370.0.dp val row_section = 75.0.dp @@ -146,7 +146,7 @@ object WolfskinTokens { val settingsRowWidth = 650.0.dp val settingsRowHeight = 52.0.dp val stateWidth = 550.0.dp - val captionOffset = 9.0.dp + val captionOffset = 19.0.dp val badge = 24.0.dp val badgeInset = 7.0.dp val progressBar = 4.0.dp @@ -159,36 +159,37 @@ object WolfskinTokens { val personPortrait = 120.0.dp val thumbWidth = 48.0.dp val thumbHeight = 72.0.dp + val row_chipsGrid = 68.0.dp } object Card { val posterW = 128.0.dp val posterH = 192.0.dp - val posterGap = 12.0.dp - val posterPitch = 140.0.dp - val posterCaptionBand = 38.0.dp + val posterGap = 20.0.dp + val posterPitch = 148.0.dp + val posterCaptionBand = 48.0.dp const val posterSlots = 6 - val tileW = 208.0.dp - val tileH = 117.0.dp - val tileGap = 12.0.dp + val tileW = 200.0.dp + val tileH = 112.5.dp + val tileGap = 20.0.dp val tilePitch = 220.0.dp val tileCaptionBand = 0.0.dp const val tileSlots = 4 - val wideW = 280.0.dp - val wideH = 157.5.dp - val wideGap = 12.0.dp + val wideW = 272.0.dp + val wideH = 153.0.dp + val wideGap = 20.0.dp val widePitch = 292.0.dp val wideCaptionBand = 0.0.dp const val wideSlots = 3 val entryW = 128.0.dp val entryH = 72.0.dp - val entryGap = 12.0.dp - val entryPitch = 140.0.dp + val entryGap = 20.0.dp + val entryPitch = 148.0.dp val entryCaptionBand = 0.0.dp const val entrySlots = 6 val personW = 100.0.dp - val personH = 145.0.dp - val personGap = 14.0.dp - val personPitch = 114.0.dp + val personH = 155.0.dp + val personGap = 22.0.dp + val personPitch = 122.0.dp val personCaptionBand = 0.0.dp const val personSlots = 7 } @@ -196,7 +197,7 @@ object WolfskinTokens { const val scaleCard = 1.08f const val scaleControl = 1.05f const val scaleRow = 1.02f - val ringCardGap = 2.0.dp + val ringCardGap = 0.0.dp val ringCardRing = 2.0.dp val ringButtonGap = 2.0.dp val ringButtonRing = 2.0.dp @@ -429,8 +430,8 @@ object WolfskinTokens { val progressBottom = 15.0.dp val progressGap = 9.0.dp val progressLift = -7.0.dp - val castHeight = 197.0.dp - val castNameTop = 108.0.dp + val castHeight = 231.0.dp + val castNameTop = 118.0.dp val castNameSide = 6.0.dp val personTop = 85.0.dp val personGap = 24.0.dp @@ -456,7 +457,7 @@ object WolfskinTokens { val infoRowGap = 3.0.dp val infoColumnGap = 10.0.dp val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - val gridHeight = 244.0.dp + val gridHeight = 262.0.dp val minimumEndPadding = 40.0.dp val ratingGap = 10.0.dp val ratingMarkGap = 4.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 04a5e866..647bf533 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -115,5 +115,6 @@ class HomeCaptureTest { @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st103_heroRatings() = capture("st-103") + @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt index af8387f9..36fb48d3 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt @@ -82,6 +82,14 @@ class LibraryCaptureTest { @Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true) @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) + @Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows") + @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid -> + val original = grid.source + grid.source = List(200) { index -> + original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index") + } + grid.index = 4 + } } fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/") diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt index c4130d39..5eae18b5 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -112,18 +112,25 @@ class LibraryKeysTest { val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") } val grid = LibraryGridState("Movies", titles) assertTrue(grid.railVisible) - grid.index = 5 + grid.index = 4 grid.key(ShellKey.Right) assertTrue(grid.inRail) grid.key(ShellKey.Down) assertEquals('B', grid.railLetter) grid.key(ShellKey.Back) - assertEquals(5, grid.index) + assertEquals(4, grid.index) assertFalse(grid.inRail) + grid.key(ShellKey.Down) + assertEquals(9, grid.index) + grid.key(ShellKey.Up) + assertEquals(4, grid.index) grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok) assertEquals('B', grid.focusedTitle!!.letter) grid.source = titles.take(199) assertFalse(grid.railVisible) + grid.index = 4 + grid.key(ShellKey.Right) + assertEquals(5, grid.index) grid.source = titles; grid.sort = 1 assertFalse(grid.railVisible) } diff --git a/wolfskin-design/src/test/resources/library-screen.json b/wolfskin-design/src/test/resources/library-screen.json index 0d1c8c2a..07f33c34 100644 --- a/wolfskin-design/src/test/resources/library-screen.json +++ b/wolfskin-design/src/test/resources/library-screen.json @@ -1,6 +1,6 @@ { "id": "library", - "title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)", + "title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)", "specSection": "6.4", "source": "index.html:1403-1426, 1563-1611", "layout": [ @@ -22,7 +22,7 @@ "token": null, "title": "", "yPx": 236, - "heightPx": 104, + "heightPx": 136, "cards": null, "focusable": true, "endsInSeeAll": false, @@ -33,8 +33,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 340, - "heightPx": 488, + "yPx": 372, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -45,8 +45,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 828, - "heightPx": 488, + "yPx": 896, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -57,8 +57,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 1316, - "heightPx": 488, + "yPx": 1420, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -69,8 +69,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 1804, - "heightPx": 488, + "yPx": 1944, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -81,8 +81,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 2292, - "heightPx": 488, + "yPx": 2468, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -93,8 +93,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 2780, - "heightPx": 488, + "yPx": 2992, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -105,8 +105,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 3268, - "heightPx": 488, + "yPx": 3516, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -117,8 +117,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 3756, - "heightPx": 488, + "yPx": 4040, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -129,8 +129,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 4244, - "heightPx": 488, + "yPx": 4564, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -141,8 +141,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 4732, - "heightPx": 488, + "yPx": 5088, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -153,8 +153,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 5220, - "heightPx": 488, + "yPx": 5612, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -165,8 +165,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 5708, - "heightPx": 488, + "yPx": 6136, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -177,8 +177,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 6196, - "heightPx": 488, + "yPx": 6660, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -189,8 +189,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 6684, - "heightPx": 488, + "yPx": 7184, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 9f7ad2e6..5138e020 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -14,8 +14,8 @@ "progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"}, "progressGap": {"dp": 9, "px": 18, "source": "index.html:217"}, "progressLift": {"dp": -7, "px": -14, "source": "index.html:217"}, - "castHeight": {"dp": 197, "px": 394, "source": "index.html:1018"}, - "castNameTop": {"dp": 108, "px": 216, "source": "index.html:289"}, + "castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"}, + "castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"}, "castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"}, "personTop": {"dp": 85, "px": 170, "source": "index.html:293"}, "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, @@ -41,7 +41,7 @@ "infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"}, "infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"}, "infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"}, - "gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"}, + "gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"}, "minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"}, "ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"}, "ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 4d3f3488..9c7f17f1 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -81,9 +81,9 @@ --layout-navSolidAfter: 40.0px; --layout-pinnedLine: 168.0px; --layout-windowAhead: 700.0px; - --layout-rowHeadingBand: 60.0px; - --layout-rowGapAfter: 44.0px; - --layout-gridRowGap: 28.0px; + --layout-rowHeadingBand: 84.0px; + --layout-rowGapAfter: 68.0px; + --layout-gridRowGap: 44.0px; --layout-row_hero: 820.0px; --layout-row_phero: 740.0px; --layout-row_section: 150.0px; @@ -111,7 +111,7 @@ --layout-sheetWidth: 800.0px; --layout-sheetRow: 100.0px; --layout-stateWidth: 1100.0px; - --layout-captionOffset: 18.0px; + --layout-captionOffset: 38.0px; --layout-badge: 48.0px; --layout-badgeInset: 14.0px; --layout-progressBar: 8.0px; @@ -122,6 +122,7 @@ --layout-navAvatar: 64.0px; --layout-castCircle: 200.0px; --layout-personPortrait: 240.0px; + --layout-row_chipsGrid: 136.0px; --motion-fast: 140ms; --motion-base: 160ms; --motion-scroll: 180ms; @@ -273,8 +274,8 @@ --title-progressBottom: 30.0px; --title-progressGap: 18.0px; --title-progressLift: -14.0px; - --title-castHeight: 394.0px; - --title-castNameTop: 216.0px; + --title-castHeight: 462.0px; + --title-castNameTop: 236.0px; --title-castNameSide: 12.0px; --title-personTop: 170.0px; --title-personGap: 48.0px; @@ -300,7 +301,7 @@ --title-infoRowGap: 6.0px; --title-infoColumnGap: 20.0px; --title-infoKey: 24.0px; - --title-gridHeight: 488.0px; + --title-gridHeight: 524.0px; --title-minimumEndPadding: 80.0px; --title-ratingGap: 20.0px; --title-ratingMarkGap: 8.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 95fa62f2..30bdacb8 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it).", + "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (originally data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...; now amended in data/tv-v2/spacing-2026-11-06/index.html, sha256 fcc5d879...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it). Amended 2026-11-06 (spec revision 3, Level 2 spacing + card ring; data/tv-card-spacing/answers.md): card, layout.row*/gridRowGap and focus.ringCard changed; every other token is as extracted. The prototype source line numbers in `source` fields predate the amendment and drift by a few lines after index.html:8.", "meta": { "density": 2.0, "stagePx": [ @@ -10,8 +10,8 @@ 960, 540 ], - "source": "data/tv-v2/opus-final-2026-11-05/index.html", - "sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d", + "source": "data/tv-v2/spacing-2026-11-06/index.html", + "sourceSha256": "fcc5d879c34da868b817f8d9d306193743d3933db3096da605bda659d8e633b2", "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" }, "color": { @@ -773,27 +773,27 @@ }, "rowHeadingBand": { "$type": "dimension", - "$value": "30dp", - "px": 60, - "dp": 30.0, + "$value": "42dp", + "px": 84, + "dp": 42.0, "source": "index.html:1018", - "usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60" + "usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)" }, "rowGapAfter": { + "$type": "dimension", + "$value": "34dp", + "px": 68, + "dp": 34.0, + "source": "index.html:1018", + "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68" + }, + "gridRowGap": { "$type": "dimension", "$value": "22dp", "px": 44, "dp": 22.0, - "source": "index.html:1018", - "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 60 + card h + caption + 44" - }, - "gridRowGap": { - "$type": "dimension", - "$value": "14dp", - "px": 28, - "dp": 14.0, "source": "index.html:1019", - "usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)" + "usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)" }, "row_hero": { "$type": "dimension", @@ -825,7 +825,7 @@ "px": 104, "dp": 52.0, "source": "index.html:1021", - "usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)" + "usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px" }, "row_note": { "$type": "dimension", @@ -1041,10 +1041,10 @@ }, "captionOffset": { "$type": "dimension", - "px": 18, - "dp": 9, + "px": 38, + "dp": 19, "source": "index.html:1242", - "usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640" + "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" }, "plateInset": { "$type": "dimension", @@ -1171,6 +1171,14 @@ }, "source": "index.html:204", "usedBy": "letter rail (only for destinations with >= 200 titles)" + }, + "row_chipsGrid": { + "$type": "dimension", + "$value": "68dp", + "px": 136, + "dp": 68.0, + "source": "index.html:rowH", + "usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)" } }, "card": { @@ -1179,16 +1187,16 @@ "px": { "w": 256, "h": 384, - "gap": 24, - "pitch": 280, - "captionBand": 76 + "gap": 40, + "pitch": 296, + "captionBand": 96 }, "dp": { "w": 128.0, "h": 192.0, - "gap": 12.0, - "pitch": 140.0, - "captionBand": 38.0 + "gap": 20.0, + "pitch": 148.0, + "captionBand": 48.0 }, "ratio": "2:3", "fullSlotsPerRow": 6, @@ -1198,16 +1206,16 @@ "tile": { "$type": "card", "px": { - "w": 416, - "h": 234, - "gap": 24, + "w": 400, + "h": 225, + "gap": 40, "pitch": 440, "captionBand": 0 }, "dp": { - "w": 208.0, - "h": 117.0, - "gap": 12.0, + "w": 200.0, + "h": 112.5, + "gap": 20.0, "pitch": 220.0, "captionBand": 0.0 }, @@ -1219,16 +1227,16 @@ "wide": { "$type": "card", "px": { - "w": 560, - "h": 315, - "gap": 24, + "w": 544, + "h": 306, + "gap": 40, "pitch": 584, "captionBand": 0 }, "dp": { - "w": 280.0, - "h": 157.5, - "gap": 12.0, + "w": 272.0, + "h": 153.0, + "gap": 20.0, "pitch": 292.0, "captionBand": 0.0 }, @@ -1242,15 +1250,15 @@ "px": { "w": 256, "h": 144, - "gap": 24, - "pitch": 280, + "gap": 40, + "pitch": 296, "captionBand": 0 }, "dp": { "w": 128.0, "h": 72.0, - "gap": 12.0, - "pitch": 140.0, + "gap": 20.0, + "pitch": 148.0, "captionBand": 0.0 }, "ratio": "16:9", @@ -1262,22 +1270,22 @@ "$type": "card", "px": { "w": 200, - "h": 290, - "gap": 28, - "pitch": 228, + "h": 310, + "gap": 44, + "pitch": 244, "captionBand": 0 }, "dp": { "w": 100.0, - "h": 145.0, - "gap": 14.0, - "pitch": 114.0, + "h": 155.0, + "gap": 22.0, + "pitch": 122.0, "captionBand": 0.0 }, "ratio": "1:1 circle", "fullSlotsPerRow": 7, "source": "index.html:660", - "usedBy": "Cast row: 200 px circle + name + character, 290 px tall" + "usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)" } }, "focus": { @@ -1318,16 +1326,17 @@ }, "ringCard": { "$type": "shadow", - "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)", - "gapPx": 4, + "$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)", + "gapPx": 0, "ringPx": 4, - "gapDp": 2, + "gapDp": 0, "ringDp": 2, - "gapColor": "color.bg", "ringColor": "color.accent", "liftShadow": "0 18px 50px rgb(0 0 0 / .65)", "source": "index.html:134", - "usedBy": "focused card: a 4 px bg-coloured gap then a 4 px lilac ring OUTSIDE the art (never covers it), plus a lift shadow; drawn on the circle for cast cards" + "usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")", + "outsidePx": 2, + "insidePx": 2 }, "ringButton": { "$type": "shadow", @@ -1589,4 +1598,4 @@ "usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus" } } -} \ No newline at end of file +} diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index ba5f6201..dfc777fb 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json -3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css -5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json +4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json +b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css +6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json From 6e0f9952cf13225635a70767812c03674de5a652 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 13:11:08 +0100 Subject: [PATCH 11/26] Reconcile removed Anime roots without discarding pushed pages --- .../wolfskin/tv/AnimeRootRemovalTest.kt | 111 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 9 +- docker/README.md | 10 +- .../wolfskin/design/ShellState.kt | 8 +- 4 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt new file mode 100644 index 00000000..1af82b5c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AnimeRootRemovalTest { + private var previousFixtures = false + private var previousUrl = "" + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(ApplicationProvider.getApplicationContext()) + previousFixtures = ConductarrPreference.fixtures + previousUrl = ConductarrPreference.baseUrl + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + } + + @Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking { + val title = Destination.Wolfskin("title/tv%3A123") + val person = Destination.Wolfskin("person/456") + val scenarios = listOf(emptyList() to false, emptyList() to true, + listOf(title) to false, listOf(title, person) to false) + for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val context = ApplicationProvider.getApplicationContext() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns null + var hasTitles = true + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles } + val navigation = NavigationManager() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + model.load(server, user) + requireNotNull(model.onForeground(server, user)).join() + val shell = requireNotNull(session.state) + assertTrue(shell.person.hasAnime) + shell.key(ShellKey.Ok) + shell.focusNav(shell.tabs.indexOf("anime")) + shell.key(ShellKey.Ok) + assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList()) + for (page in pushed) { + navigation.navigateTo(page) + shell.enterDestination(page.route) + } + if (avatarFocused) shell.focusNav(shell.tabs.size) + + hasTitles = false + requireNotNull(model.onForeground(server, user)).join() + assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs) + assertEquals("Home must replace the missing root, not the current pushed page", + listOf(Destination.Home()) + pushed, navigation.backStack.toList()) + assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route) + assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab) + if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex) + + shell.key(ShellKey.Back) + if (pushed.isEmpty()) { + assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused) + assertEquals("home", shell.route) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + if (!avatarFocused) assertEquals(0, shell.navIndex) + } else { + assertEquals("Back pops only the current page and retains the replacement Home root", + listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList()) + } + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index b654df48..a4859d21 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -181,7 +181,14 @@ class WolfskinShellViewModel @Inject constructor( private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { when (action) { - is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)) + is ShellAction.Destination -> { + val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) + when { + action.route == "settings" -> navigation.navigateTo(target) + action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target + else -> navigation.replace(target) + } + } ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) diff --git a/docker/README.md b/docker/README.md index 1996dd3d..d7463830 100644 --- a/docker/README.md +++ b/docker/README.md @@ -485,8 +485,9 @@ Cached `NavDisplay` entries read the current shell and Discover composition loca not the temporary launch shell captured when the entry was first created. Otherwise the screen can retain skeleton people, disconnected tab actions and an open picker even after the real session is ready. -Only the current navigation entry may set the shared route or handle Back; -outgoing entries can remain composed during a transition. +Only the current navigation entry may set the shared route or enable the outer +host's Back handler; outgoing entries can remain composed during a transition. +Nested title handlers retain their existing lifecycle-based behaviour. Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's authenticated fetcher. The app's network interceptor removes Authorization from @@ -500,7 +501,10 @@ in an accessible Anime library for the signed-in user. This runs after the live picker is published; failures retain that user's last confirmed availability. Empty Anime Home shelves remain omitted. When availability changes, focus follows the avatar or a surviving destination; -removing Anime never leaves an out-of-range nav selection. +removing Anime never leaves an out-of-range nav selection. If Anime was the root, +both the shell and navigation stack replace it with Home while retaining pushed +title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus, +and the real navigation stack with one or two pushed pages. | Screen seam | Production supplier | | --- | --- | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index e54c7ed8..c770a3ab 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -11,7 +11,7 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v /** The shell owns keys; the integration owns navigation and authenticated user changes. */ sealed interface ShellAction { - data class Destination(val route: String) : ShellAction + data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction data class Person(val person: ShellPerson) : ShellAction data class HandOff(val group: String) : ShellAction data object Pop : ShellAction @@ -59,8 +59,14 @@ class ShellState( people = value if (selected != null) person = selected pickerIndex = people.indexOf(person).coerceAtLeast(0) + val removedRoot = rootRoute !in tabs + if (removedRoot) { + if (route == rootRoute) route = "home" + rootRoute = "home" + } preparePersonMemory() navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab + if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true)) } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set From 92c5661be0605fc8836eaf6c079ffd6a916d29f8 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 13:21:06 +0100 Subject: [PATCH 12/26] Preserve poster progress and focus across library rail changes --- docker/README.md | 18 +++++-- .../wolfskin/design/LibraryPoster.kt | 4 +- .../wolfskin/design/LibraryScreen.kt | 12 ++--- .../design/LibrarySpacingRegressionTest.kt | 53 +++++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt diff --git a/docker/README.md b/docker/README.md index f9c0cc17..583d4d4b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -358,18 +358,28 @@ chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear) Discover and season-chip rows retain their 104 px height. Cards and cast circles draw a single 4 px accent stroke centred on the art edge, -above the content, with no background gap and the existing card lift. The stroke -and art scale together at 1.08; buttons, avatars, chips and picker focus are -unchanged. Title shelves reserve vertical drawing room for the scaled ring. +above the content, with no background gap. Card lift parameters are unchanged; +cast portraits and episode tiles now receive that lift too. The stroke and art +scale together at 1.08; buttons, avatars, chips and picker focus are unchanged. +Title shelves reserve vertical drawing room for the scaled ring. Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. +Poster progress uses the independent 18 px title-card inset, not caption spacing: +the track remains 220 px wide on a 256 px poster. Library focus-follow restarts +when the effective column count, focused row or header geometry changes, retaining +deep focus across live snapshots that add or remove the letter rail. +`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged +focused index across the 199↔200-title boundary. + `LibraryCaptureTest.spacingShowsFirstFocused`, `HomeCaptureTest.spacingNextUpFirstFocused` and `TitleCaptureTest.spacingCastFirstFocused` render the amended reference states at native resolution. Their files are `library/spacing-shows.png`, `home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`. -Platform Roboto metrics and fixture ordering are not browser pixel parity. +These captures are recordings, not automated reference-pixel assertions; pair +measurements are a separate check. Platform Roboto metrics and fixture ordering +are not browser pixel parity. ## Title pages and people diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index 27374461..41e1e053 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -41,8 +41,8 @@ fun LibraryPoster( if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) val progress = title.progress if (progress > 0f && progress < 1f) { - Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap) - .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) { + Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap) + .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) { Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 93fb47a0..fa339d43 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -88,13 +88,13 @@ fun LibraryScreen( } val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap - LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { - val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index - val row = index.coerceAtLeast(0) / grid.columns - if (grid.toolbar || row == 0 && active) { + val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns + LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density, + grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { + if (grid.toolbar || focusedRow == 0 && active) { animateLibraryScroll(view, headerCount, 0f, density) - } else if (row > 0) { - val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } + } else if (focusedRow > 0) { + val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() } animateLibraryScroll(view, headerCount, target, density) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt new file mode 100644 index 00000000..79e89277 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt @@ -0,0 +1,53 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class LibrarySpacingRegressionTest { + @get:Rule val rule = createComposeRule() + + @Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() { + val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f) + rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } } + val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot + val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot + assertEquals(256f, poster.width, .1f) + assertEquals(220f, track.width, .1f) + assertEquals(18f, track.left - poster.left, .1f) + assertEquals(18f, poster.right - track.right, .1f) + } + + @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { + val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } + val grid = LibraryGridState("Shows", titles.take(199)).apply { index = 198 } + rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val poster = rule.onNodeWithTag("poster-198") + poster.assertIsDisplayed() + val original = poster.fetchSemanticsNode().boundsInRoot + for (count in listOf(200, 199)) { + rule.runOnIdle { grid.source = titles.take(count) } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + poster.assertIsDisplayed() + val current = poster.fetchSemanticsNode().boundsInRoot + assertEquals(198, grid.index) + assertEquals("198", grid.focusedTitle!!.id) + assertEquals(original.top, current.top, 1f) + assertEquals(original.height, current.height, 1f) + } + } +} From db2541feffe7f74fbf848d5a707f8e9c8eafc7e1 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 14:05:01 +0100 Subject: [PATCH 13/26] Move person-switch client disposal off the main thread --- .../wolfskin/tv/FakeConductarr.kt | 4 + .../wolfskin/tv/PersonSwitchThreadingTest.kt | 252 ++++++++++++++++++ .../wolfskin/tv/ShellStartupTimingTest.kt | 5 +- .../wolfskin/tv/TitleAsyncFocusTest.kt | 7 +- .../wolfskin/tv/ConductarrPreference.kt | 17 +- .../wolfskin/tv/ConductarrSession.kt | 81 ++++-- .../wolfskin/tv/HomeDestination.kt | 4 +- .../wolfskin/tv/TitleDestination.kt | 4 +- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 64 +++-- .../wolfskin/tv/WolfskinLibrary.kt | 5 +- .../wolfskin/tv/read/HomeLibraryCache.kt | 2 +- docker/README.md | 16 ++ 12 files changed, 404 insertions(+), 57 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index 04a8ac97..f741223e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -11,6 +11,7 @@ import mockwebserver3.RecordedRequest internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef" internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210" +internal const val JUSTIN_TOKEN = "11111111111111111111111111111111" /** A loopback server supplies wire JSON, never app projections or live service state. */ internal class FakeConductarr : Closeable { @@ -75,6 +76,9 @@ internal class FakeConductarr : Closeable { if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) } + if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) { + body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin"))) + } if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") { body = JsonObject(body + ("media_type" to JsonPrimitive("tv"))) if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark"))))))) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt new file mode 100644 index 00000000..20ea6d9b --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -0,0 +1,252 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import io.mockk.* +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class PersonSwitchThreadingTest { + private val context = ApplicationProvider.getApplicationContext() + private lateinit var previousUrl: String + private var previousFixtures = false + + @Before fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + ConductarrPreference.initialize(context) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(false) + } + + @After fun tearDown() { + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + Dispatchers.resetMain() + unmockkConstructor(ConductarrClient::class) + } + + @Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking { + assertSame(Looper.getMainLooper(), Looper.myLooper()) + val closeThreads = CopyOnWriteArrayList() + val releaseCloses = CountDownLatch(1) + val closed = CountDownLatch(4) + val disposalErrors = CopyOnWriteArrayList() + val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) }) + val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true) + mockkConstructor(ConductarrClient::class) + every { anyConstructed().close() } answers { + val onMain = Looper.myLooper() == Looper.getMainLooper() + closeThreads.add(onMain) + check(!onMain) { "StrictMode: Conductarr socket close on main" } + check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" } + callOriginal() + closed.countDown() + if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure") + } + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope) + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) + val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN), + justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN)) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + val setup = mockk(relaxed = true) + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val clients = mutableListOf() + try { + model.load(server, justin) + requireNotNull(model.onForeground(server, justin)).join() + clients.add(conductarr.identity.value!!.client) + val shell = session.state!! + shell.key(ShellKey.Ok) + for (index in listOf(1, 2, 0)) { + val switched = CompletableDeferred() + every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) } + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(index) + shell.key(ShellKey.Ok) + assertEquals(users[index].id.toString(), shell.person.jellyfinUserId) + assertEquals("home", shell.route) + assertFalse(shell.pickerVisible) + assertNull("Old identity must not serve the newly selected person", conductarr.identity.value) + val current = withTimeout(5_000) { switched.await() } + assertEquals(users[index].id, current.user.id) + model.load(current.server, current.user) + requireNotNull(model.onForeground(server, current.user)).join() + assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester) + assertEquals(users[index].name!!.lowercase(), model.signedInPerson) + clients.add(conductarr.identity.value!!.client) + } + conductarr.close() + conductarr.close() + model.viewModelScope.cancel() + releaseCloses.countDown() + assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) }) + assertEquals(listOf(false, false, false, false), closeThreads.toList()) + for (client in clients) { + val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last() + assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error) + } + disposalScope.coroutineContext.job.children.toList().joinAll() + assertTrue("A failing socket close must be contained", disposalErrors.isEmpty()) + } finally { + releaseCloses.countDown() + model.viewModelScope.cancel() + withContext(Dispatchers.IO) { conductarr.close() } + disposalScope.coroutineContext.job.children.toList().joinAll() + disposalScope.cancel() + } + } + } + + @Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking { + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) + val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN), + justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN)) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + val pending = CompletableDeferred() + val release = CompletableDeferred() + coEvery { repository.changeUser(server, any()) } coAnswers { + val user = secondArg() + if (user.id == users[1].id) { + pending.complete(Unit) + withContext(NonCancellable) { release.await() } + } + CurrentUser(server, user) + } + val switched = CompletableDeferred() + val setup = mockk(relaxed = true) + every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) } + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + val session = WolfskinSession() + val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + try { + model.load(server, justin) + requireNotNull(model.onForeground(server, justin)).join() + val shell = session.state!! + shell.key(ShellKey.Ok) + for (index in listOf(1, 2, 0)) { + shell.focusNav(shell.tabs.size) + shell.key(ShellKey.Ok) + shell.focusPerson(index) + shell.key(ShellKey.Ok) + if (index == 1) withTimeout(5_000) { pending.await() } + } + release.complete(Unit) + val current = withTimeout(5_000) { switched.await() } + assertEquals(justin.id, current.user.id) + verify(exactly = 1) { setup.navigateTo(any()) } + model.load(server, current.user) + requireNotNull(model.onForeground(server, current.user)).join() + assertEquals(justin.id.toString(), shell.person.jellyfinUserId) + assertEquals("justin", model.signedInPerson) + assertEquals("justin", conductarr.identity.value!!.operator!!.requester) + } finally { + release.complete(Unit) + model.viewModelScope.cancel() + } + } + } + } + + @Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking { + val closeThreads = CopyOnWriteArrayList() + val closed = CountDownLatch(3) + val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + mockkConstructor(ConductarrClient::class) + every { anyConstructed().close() } answers { + val onMain = Looper.myLooper() == Looper.getMainLooper() + closeThreads.add(onMain) + check(!onMain) { "StrictMode: retry socket close on main" } + callOriginal() + closed.countDown() + } + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope) + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) + val repository = mockk() + every { repository.currentUser } returns user + every { repository.currentServer } returns server + val home = mockk() + coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( + org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) + val cache = mockk(relaxed = true) + every { cache.read(any()) } returns null + val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) + val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository) + try { + conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) + conductarr.authenticate() + fake.status = 401 + conductarr.authenticate() + assertFalse(conductarr.identity.value!!.signedIn) + fake.status = 200 + homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true) + withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } } + assertEquals("justin", conductarr.identity.value!!.operator!!.requester) + assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } }) + fake.status = 401 + conductarr.authenticate() + assertFalse(conductarr.identity.value!!.signedIn) + fake.status = 200 + titleModel.retrySignIn() + assertEquals("justin", conductarr.identity.value!!.operator!!.requester) + assertTrue(conductarr.identity.value!!.signedIn) + } finally { + homeModel.viewModelScope.cancel() + titleModel.viewModelScope.cancel() + conductarr.close() + disposalScope.coroutineContext.job.children.toList().joinAll() + disposalScope.cancel() + } + assertEquals(0L, closed.count) + assertEquals(listOf(false, false, false), closeThreads.toList()) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index c3bf121a..dbcffdb4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -58,8 +58,9 @@ class ShellStartupTimingTest { val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() } withTimeout(5_000) { activityEntered.await() } val savedPeople = CompletableDeferred() + val savedPeopleStarted = CompletableDeferred() val repository = mockk(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null } + coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null } val navigation = mockk(relaxed = true) val libraries = mockk(relaxed = true) coEvery { libraries.readDestinations(user.id) } returns emptyMap() @@ -68,7 +69,7 @@ class ShellStartupTimingTest { libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) val load = launch { model.load(server, user) } try { - yield() + withTimeout(5_000) { savedPeopleStarted.await() } val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" } assertEquals(PeopleStatus.Ready, shell.peopleStatus) assertEquals(user.id.toString(), shell.person.jellyfinUserId) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 12ffa9e8..9809967f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -1,6 +1,7 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import android.os.Looper import androidx.activity.ComponentActivity import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion import org.justinvisser.wolfskin.design.WolfskinTheme import org.justinvisser.wolfskin.tv.read.TitleReadFacade import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows import org.robolectric.annotation.Config import java.util.UUID @@ -152,7 +154,10 @@ class TitleAsyncFocusTest { assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id) rule.runOnIdle { viewModel.title!!.toast = null } press(Key.DirectionCenter) - rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null } + rule.waitUntil(timeoutMillis = 5000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + reads.get() == 2 && viewModel.title?.toast != null + } rule.waitForIdle() assertEquals("Saved title", viewModel.title!!.data.item.name) assertEquals("20:42", shell.navSavedAt) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt index f15fdef7..559b4365 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import androidx.core.content.edit import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,6 +17,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.io.File import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import org.justinvisser.wolfskin.conductarr.CacheEntry +import org.justinvisser.wolfskin.conductarr.CacheStore import org.justinvisser.wolfskin.conductarr.FileCacheStore /** Advanced-only endpoint and fixture controls contain no credentials. */ @@ -50,6 +54,15 @@ object ConductarrPreference { object ConductarrModule { @Provides @Singleton - fun session(@ApplicationContext context: Context): ConductarrSession = - ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr"))) + fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession = + ConductarrSession(ConductarrDiskCache(context), scope) +} + +/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */ +private class ConductarrDiskCache(context: Context) : CacheStore { + private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) } + override fun read(scope: String, key: String) = store.read(scope, key) + override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity) + override fun remove(scope: String, key: String) = store.remove(scope, key) + override fun clear(scope: String) = store.clear(scope) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt index 64cdba69..5ba52caa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -3,12 +3,17 @@ package org.justinvisser.wolfskin.tv import java.io.Closeable import java.security.MessageDigest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import org.justinvisser.wolfskin.conductarr.* import org.justinvisser.wolfskin.conductarr.model.Operator +import timber.log.Timber /** A selected Jellyfin login owns every provider and all in-memory observations. */ data class ConductarrIdentity( @@ -21,53 +26,85 @@ data class ConductarrIdentity( ) /** Tokens are borrowed from upstream, never persisted or exposed as observable state. */ -class ConductarrSession(private val cache: CacheStore) : Closeable { +class ConductarrSession( + private val cache: CacheStore, + private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) : Closeable { private val mutableIdentity = MutableStateFlow(null) val identity: StateFlow = mutableIdentity.asStateFlow() private var selection: ByteArray? = null @Volatile private var generation = 0L - @Synchronized fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) { val fingerprint = MessageDigest.getInstance("SHA-256") .digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray()) - if (!retry && selection?.contentEquals(fingerprint) == true) return - generation++ - val currentGeneration = generation - mutableIdentity.value?.client?.close() - mutableIdentity.value = null - selection = fingerprint - if (token.isNullOrBlank()) return - val client = try { - ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> - if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) { - mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) } - } - }) - } catch (_: IllegalArgumentException) { return } - mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client)) + val previous: ConductarrClient? + val currentGeneration: Long + synchronized(this) { + if (!retry && selection?.contentEquals(fingerprint) == true) return + generation++ + currentGeneration = generation + previous = mutableIdentity.value?.client + mutableIdentity.value = null + selection = fingerprint + } + try { + val client = if (token.isNullOrBlank()) null else try { + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> + if (event == ClientEvent.NEEDS_SIGN_IN) { + mutableIdentity.update { + if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it + } + } + }) + } catch (_: IllegalArgumentException) { null } + synchronized(this) { + if (generation == currentGeneration) { + mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) } + } else dispose(client) + } + } finally { + dispose(previous) + } } /** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */ suspend fun authenticate() { val selected = mutableIdentity.value ?: return selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result -> - if (mutableIdentity.value?.client !== selected.client) return@collect when (result) { - is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) } + is ReadResult.Value -> mutableIdentity.update { + if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it + } is ReadResult.Failure -> mutableIdentity.update { - if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error) - else it?.copy(error = result.error) + if (it?.client !== selected.client) it + else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error) + else it.copy(error = result.error) } } } } + /** Remove read authority synchronously; socket disposal finishes independently on IO. */ + @Synchronized override fun close() { generation++ - mutableIdentity.value?.client?.close() + val previous = mutableIdentity.value?.client mutableIdentity.value = null selection = null + dispose(previous) + } + + private fun dispose(client: ConductarrClient?) { + if (client == null) return + // The application scope outlives the screen replaced by a person switch. + disposalScope.launch(Dispatchers.IO) { + try { + client.close() + } catch (_: Exception) { + Timber.w("Conductarr client disposal failed") + } + } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 7687e595..c8ab24cc 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -52,7 +52,9 @@ class WolfskinHomeViewModel @Inject constructor( catalogueJob?.cancel() withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + withContext(Dispatchers.IO) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + } } loadLibrary(server, user, person, retry) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 691e660f..e457aa86 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -210,7 +210,9 @@ class WolfskinTitleViewModel @Inject constructor( internal suspend fun retrySignIn() { val user = repository.currentUser ?: return val server = repository.currentServer ?: return - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + withContext(Dispatchers.IO) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + } conductarr.authenticate() } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index a4859d21..abc0089c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -85,23 +88,29 @@ class WolfskinShellViewModel @Inject constructor( var signedInPerson by mutableStateOf("") private set private var identityJob: kotlinx.coroutines.Job? = null + private var personSwitchJob: kotlinx.coroutines.Job? = null + private val personSwitchMutex = Mutex() private var fixtureMode: Boolean? = null suspend fun load(server: JellyfinServer, user: JellyfinUser) { - WolfskinScreenPreference.initialize(context) - ConductarrPreference.initialize(context) + withContext(Dispatchers.IO) { + WolfskinScreenPreference.initialize(context) + ConductarrPreference.initialize(context) + } identityJob?.cancel() libraries.audiences = null if (ConductarrPreference.fixtures) conductarr.close() - else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) + else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) } signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() session.onAction = { action -> handleAction(action, server, user) } if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { session.serverId = server.id.toString() fixtureMode = ConductarrPreference.fixtures - session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) } + val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } + session.state = ShellState(people, signedInPerson) { session.onAction(it) } val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } - session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) })) + val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } + session.state?.updatePeople(savedPeople) } identityJob = viewModelScope.launch { if (!ConductarrPreference.fixtures) loadHousehold(user) @@ -149,8 +158,10 @@ class WolfskinShellViewModel @Inject constructor( } suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, - retry = conductarr.identity.value?.signedIn != true) + withContext(Dispatchers.IO) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, + retry = conductarr.identity.value?.signedIn != true) + } loadHousehold(user) } @@ -191,7 +202,7 @@ class WolfskinShellViewModel @Inject constructor( } ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) - is ShellAction.Person -> selectPerson(action.person, server, user) + is ShellAction.Person -> selectPerson(action.person, server) ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryRequests -> viewModelScope.launch { if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) @@ -201,26 +212,29 @@ class WolfskinShellViewModel @Inject constructor( } } - private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { + private fun selectPerson(person: ShellPerson, server: JellyfinServer) { navigation.replace(Destination.Home()) - if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return + if (person.key == signedInPerson) return + identityJob?.cancel() + personSwitchJob?.cancel() + signedInPerson = "" conductarr.close() libraries.audiences = null - viewModelScope.launch { - val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } - } - if (saved == null || saved.isProtected) { - signedInPerson = "" - return@launch - } - try { - val current = repository.changeUser(server, saved) - setupNavigation.navigateTo(SetupDestination.AppContent(current)) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - signedInPerson = "" - session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } + personSwitchJob = viewModelScope.launch { + personSwitchMutex.withLock { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } + } + if (saved == null || saved.isProtected) return@launch + try { + val current = repository.changeUser(server, saved) + coroutineContext.ensureActive() + setupNavigation.navigateTo(SetupDestination.AppContent(current)) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + coroutineContext.ensureActive() + session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } + } } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index fec2c537..0301d3dc 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -59,7 +59,7 @@ fun WolfskinLibraryContent( context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } } } - val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } + val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } } val grid = remember(shell.person.key, route, view) { view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState( title = route.replaceFirstChar { it.uppercase() }, items = emptyList(), @@ -81,7 +81,8 @@ fun WolfskinLibraryContent( grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } grid.status = LibraryStatus.Ready } else { - snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> + val store = withContext(Dispatchers.IO) { snapshots.value } + store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> val focusedId = grid.focusedTitle?.id grid.source = snapshot.items focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt index 6e69ac54..10422c09 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt @@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle /** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */ @Singleton class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) { - private val store = FileCacheStore(File(context.filesDir, "wolfskin-home")) + private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) } fun read(scope: String): Pair? { val entry = store.read(scope, KEY) ?: return null diff --git a/docker/README.md b/docker/README.md index 583d4d4b..fe872463 100644 --- a/docker/README.md +++ b/docker/README.md @@ -104,6 +104,22 @@ household labels, requester capabilities and library audiences after sign-in. The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used only when the Advanced debug fixture switch is enabled; release excludes it. +Person selection immediately clears the old Conductarr identity and Jellyfin +read gate before saved-login I/O. Detached clients are disposed exactly once on +the application's IO scope, after the replacement (or signed-out) identity is +published; a slow or failing socket close cannot block the picker or abort a +switch. Cleanup outlives the replaced screen. Retry and session replacement use +the same disposal owner. Superseded sign-ins are cancelled and serialized so a +late reply cannot navigate back to an earlier person. + +Preference and people-fixture reads, Conductarr client construction (including +Home/title retries), and private-cache directory initialization run on IO. +`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel → +Shared → Justin while all closes are held pending, verifies retired clients +cannot start reads, contains a failing close, and checks a burst of choices +against a late sign-in reply. The thread-recording close fake rejects the main +Looper; actual TLS/StrictMode verification remains a real-device check. + Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` destination is an explicit title-family integration stub exposing only Discover's From 1c21f50acb8b75a3c792627d02962a7bd7d8115e Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 14:48:36 +0100 Subject: [PATCH 14/26] Keep the person picker closed after switching users --- .../wolfskin/tv/PersonPickerSessionTest.kt | 157 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 6 +- docker/README.md | 8 + 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt new file mode 100644 index 00000000..8e13dca4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -0,0 +1,157 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import io.mockk.* +import java.util.UUID +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** A selected identity gets a new app ViewModel without opening a second launch picker. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class PersonPickerSessionTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var previousUrl: String + private var previousFixtures = false + private var previousScreens = true + private val models = mutableListOf() + private lateinit var session: WolfskinSession + + @Before fun capturePreferences() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousUrl = ConductarrPreference.baseUrl + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(false) + WolfskinScreenPreference.enabled = true + } + + @After fun restorePreferences() { + models.forEach { it.viewModelScope.cancel() } + ConductarrPreference.updateBaseUrl(previousUrl) + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + } + + private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List = emptyList()) { + FakeConductarr().use { fake -> + ConductarrPreference.updateBaseUrl(fake.url) + ConductarrSession(MemoryCacheStore()).use { conductarr -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) -> + JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) + } + var current by mutableStateOf(CurrentUser(server, users[initial])) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + val setup = mockk(relaxed = true) + every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current } + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(any()) } returns emptyMap() + coEvery { libraries.hasAnimeTitles(any()) } returns false + session = WolfskinSession() + try { + rule.setContent { + key(current.user.id) { + val navigation = remember { NavigationManager() } + val model = remember { + WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } + } + DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } + WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), + viewModel = model, homeContent = { _, _, user, modifier -> + androidx.tv.material3.Text("Home for ${user.name}", modifier) + }) + } + } + awaitLoaded(users[initial]) + if (fromAvatar) { + press(listOf(Key.DirectionCenter)) + press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter)) + } + val actions = mutableListOf() + rule.runOnIdle { + val dispatch = session.onAction + session.onAction = { action -> + if (action is ShellAction.Person) actions.add(action) + dispatch(action) + } + } + if (fast) press(moves + Key.DirectionCenter) else { + press(moves) + press(listOf(Key.DirectionCenter)) + } + awaitLoaded(users[selected]) + rule.runOnIdle { + assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible) + assertEquals("home", session.state!!.route) + assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId) + assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId }) + assertEquals(if (initial == selected) 1 else 2, models.size) + } + rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed() + } finally { models.forEach { it.viewModelScope.cancel() } } + } + } + } + + private fun awaitLoaded(user: JellyfinUser) { + try { + rule.waitUntil(10_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && + models.last().conductarr.identity.value?.signedIn == true && + models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true && + session.state?.people?.size == 3 && + models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive } + } + } catch (timeout: ComposeTimeoutException) { + throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " + + "identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " + + "person=${session.state?.person}, people=${session.state?.people?.size}", timeout) + } + rule.waitForIdle() + } + + private fun press(keys: List) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } } + } + + @Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight)) + @Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft)) + @Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight)) + @Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft)) + @Test fun initialPersonNeedsOnlyOneOk() = select(0, 0) + @Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp)) + @Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft)) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index abc0089c..aed8ec7b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -63,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen class WolfskinSession @Inject constructor() { var state by mutableStateOf(null) var serverId: String? = null + internal var fixtureMode: Boolean? = null var onAction: (ShellAction) -> Unit = {} } @@ -90,7 +91,6 @@ class WolfskinShellViewModel @Inject constructor( private var identityJob: kotlinx.coroutines.Job? = null private var personSwitchJob: kotlinx.coroutines.Job? = null private val personSwitchMutex = Mutex() - private var fixtureMode: Boolean? = null suspend fun load(server: JellyfinServer, user: JellyfinUser) { withContext(Dispatchers.IO) { @@ -103,9 +103,9 @@ class WolfskinShellViewModel @Inject constructor( else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) } signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() session.onAction = { action -> handleAction(action, server, user) } - if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { + if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) { session.serverId = server.id.toString() - fixtureMode = ConductarrPreference.fixtures + session.fixtureMode = ConductarrPreference.fixtures val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } session.state = ShellState(people, signedInPerson) { session.onAction(it) } val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } diff --git a/docker/README.md b/docker/README.md index fe872463..a40c5a4c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -112,6 +112,14 @@ switch. Cleanup outlives the replaced screen. Retry and session replacement use the same disposal owner. Superseded sign-ins are cancelled and serialized so a late reply cannot navigate back to an earlier person. +The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its +server and shell state, not to the user-scoped ViewModel. Switching people replaces +the upstream app entry and ViewModel but retains the same-server, same-mode shell: +the picker stays closed on Home after the first OK. A cold start still opens it, +and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real +shell and user-entry replacement for launch/avatar Left and Right, the initial +person, gear and back, and fast Left-Left-OK; each selection emits one person action. + Preference and people-fixture reads, Conductarr client construction (including Home/title retries), and private-cache directory initialization run on IO. `PersonSwitchThreadingTest` replays the real picker across Justin → Mindel → From 427b18cdc5fb7ba6443374dea3b78c26f1f75c95 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 15:10:52 +0100 Subject: [PATCH 15/26] Add a release-derived optimized TV comparison variant --- app/build.gradle.kts | 11 ++++ app/src/optimized/baseline-prof.txt | 8 +++ app/src/optimized/res/values/strings.xml | 4 ++ docker/README.md | 70 ++++++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 app/src/optimized/baseline-prof.txt create mode 100644 app/src/optimized/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49c0c66d..f0d7554e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -133,6 +133,14 @@ configure { isDebuggable = true applicationIdSuffix = ".debug" } + + create("optimized") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".opt" + versionNameSuffix = "-opt" + matchingFallbacks += "release" + } } flavorDimensions += "version" productFlavors { @@ -199,6 +207,9 @@ configure { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" kotlin.directories += "src/wolfskin/java" } + getByName("optimized") { + kotlin.directories += "src/release/java" + } } testOptions { diff --git a/app/src/optimized/baseline-prof.txt b/app/src/optimized/baseline-prof.txt new file mode 100644 index 00000000..ad4df0ae --- /dev/null +++ b/app/src/optimized/baseline-prof.txt @@ -0,0 +1,8 @@ +# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles. +Lcom/github/damontecres/wholphin/WholphinApplication; +HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V +Lcom/github/damontecres/wholphin/MainActivity; +HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V +Lorg/justinvisser/wolfskin/tv/WolfskinAppKt; +Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference; +Lorg/justinvisser/wolfskin/tv/ConductarrPreference; diff --git a/app/src/optimized/res/values/strings.xml b/app/src/optimized/res/values/strings.xml new file mode 100644 index 00000000..fa4e585f --- /dev/null +++ b/app/src/optimized/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Wolfskin (Opt) + diff --git a/docker/README.md b/docker/README.md index a40c5a4c..ada3625d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,6 +27,76 @@ Upstream enables JVM unit tests only for debug variants; there is no `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor task without changing upstream's test configuration. +## Optimized side-by-side comparison + +The `optimized` build type inherits `release`: R8 optimization/minification, +resource shrinking, non-debuggable code, dependency consumer keep rules and +dependency baseline profiles. It changes only signing (the Android debug key), +package (`org.justinvisser.wolfskin.tv.opt`), launcher label (`Wolfskin (Opt)`) +and version-name suffix (`-opt`). The launcher icon/banner are unchanged. +`make apk` remains the clean, release-key-signed production build; neither its +signing flow nor the existing debug build changes. + +Build all four default-flavor outputs in one invocation through the container +wrapper, without a release keystore. On a host whose Gradle cache already holds +the pinned native extensions and their metadata: + +```sh +bash docker/gradle.sh optimized --offline \ + -PWholphinExtensionsUsername=JustinZeus \ + -PWholphinExtensionsPassword=unused \ + :app:assembleDefaultOptimized +``` + +The unused password is not a credential: offline resolution never contacts +GitHub Packages. A cold cache instead needs a read-packages credential supplied +through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and +`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container, +or securely seeded cached artifacts. No release signing inputs are needed. +Without native extension configuration, the existing dependency selection uses +the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a +playback-complete comparison. Inspect native libraries before installing. + +Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, +armeabi-v7a and x86_64. Keep +`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to +deobfuscate crashes. This variant shares release library variants and the release +`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork. +`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode +or Compose diagnostic stack traces. + +This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal +server/user flow again; it does not inherit the debug/release app's logins or +preferences. **Wolfskin screens** defaults on, and the same Advanced switch can +restore upstream screens. The same Conductarr door +(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected +Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential +or embedded secret is added. All other settings retain release behavior, +including update checks; a production release APK is not an update for this +separate package/debug signer. + +`androidx.profileinstaller` and library-provided profiles already exist. There +is no recorded app baseline-profile source or Macrobenchmark module. +`src/optimized/baseline-prof.txt` adds a hand-maintained seed: application and +activity classes plus their `onCreate` methods, `WolfskinAppKt`, +`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library +profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and +`baseline.profm`. It is not a measured navigation profile or evidence of a speedup. +To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark +module, record cold launch, Quick Connect/sign-in, person selection, Home, +Libraries, Discover, title pages and playback on a suitable device, then rebuild +and compare startup/frame timings. Device-driven recording and runtime checks +remain separate from an APK build. + +Static verification: use `aapt2 dump badging` for package/label/SDKs and absence +of `application-debuggable`; `apksigner verify --print-certs` for the debug +signer; `unzip -l` for both dexopt assets, native ABI contents and absence of +`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective +consumer rules rather than adding blanket keeps. JVM tests do not prove +minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real +Conductarr reads, artwork, database reopen and each player on the comparison box. + + ## Upstream v1.1.0 integration The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and From 59f4a06815694dad4f93236a70f5d03bb4c0987a Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 15:21:19 +0100 Subject: [PATCH 16/26] Register the optimized baseline profile source explicitly --- app/build.gradle.kts | 1 + app/src/optimized/{ => baselineProfiles}/baseline-prof.txt | 0 docker/README.md | 7 ++++++- 3 files changed, 7 insertions(+), 1 deletion(-) rename app/src/optimized/{ => baselineProfiles}/baseline-prof.txt (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f0d7554e..7b4c8e02 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -209,6 +209,7 @@ configure { } getByName("optimized") { kotlin.directories += "src/release/java" + baselineProfiles.directories += "src/optimized/baselineProfiles" } } diff --git a/app/src/optimized/baseline-prof.txt b/app/src/optimized/baselineProfiles/baseline-prof.txt similarity index 100% rename from app/src/optimized/baseline-prof.txt rename to app/src/optimized/baselineProfiles/baseline-prof.txt diff --git a/docker/README.md b/docker/README.md index ada3625d..aa6e8bde 100644 --- a/docker/README.md +++ b/docker/README.md @@ -77,7 +77,7 @@ separate package/debug signer. `androidx.profileinstaller` and library-provided profiles already exist. There is no recorded app baseline-profile source or Macrobenchmark module. -`src/optimized/baseline-prof.txt` adds a hand-maintained seed: application and +`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and activity classes plus their `onCreate` methods, `WolfskinAppKt`, `WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and @@ -87,6 +87,8 @@ module, record cold launch, Quick Connect/sign-in, person selection, Home, Libraries, Discover, title pages and playback on a suitable device, then rebuild and compare startup/frame timings. Device-driven recording and runtime checks remain separate from an APK build. +The optimized source set explicitly registers that profile directory; a bare +`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration. Static verification: use `aapt2 dump badging` for package/label/SDKs and absence of `application-debuggable`; `apksigner verify --print-certs` for the debug @@ -95,6 +97,9 @@ signer; `unzip -l` for both dexopt assets, native ABI contents and absence of consumer rules rather than adding blanket keeps. JVM tests do not prove minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real Conductarr reads, artwork, database reopen and each player on the comparison box. +`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug +variant has no JVM unit-test task, just like release. Build logic is checked +through actual APK assembly and static artifact inspection, not mock unit tests. ## Upstream v1.1.0 integration From 5306f8ddc41ab1fbbc4625fdd8f9c9dece687de3 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 15:36:20 +0100 Subject: [PATCH 17/26] Keep optimized versions compatible with upgrade and update parsing --- app/build.gradle.kts | 6 +++++- .../damontecres/wholphin/test/VersionCompareTests.kt | 7 +++++++ docker/README.md | 11 +++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b4c8e02..98f03a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,7 +138,6 @@ configure { initWith(getByName("release")) signingConfig = signingConfigs.getByName("debug") applicationIdSuffix = ".opt" - versionNameSuffix = "-opt" matchingFallbacks += "release" } } @@ -240,6 +239,11 @@ androidComponents { "Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk" output.outputFileName = outputFileName } + if (variant.name == "defaultOptimized") { + tasks.withType().configureEach { + systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get()) + } + } if (variant.name == "defaultDebug") { val packagedApks = variant.artifacts.get(SingleArtifact.APK) val artifactLoader = variant.artifacts.getBuiltArtifactsLoader() diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt index e7e63acd..fdd45e6a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.util.Version import org.junit.Assert import org.junit.Test @@ -18,6 +19,12 @@ class VersionCompareTests { val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234") } + @Test + fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() { + val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName"))) + Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion) + } + @Test fun testIsAtLeast() { Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0)) diff --git a/docker/README.md b/docker/README.md index aa6e8bde..f014cc90 100644 --- a/docker/README.md +++ b/docker/README.md @@ -32,8 +32,9 @@ task without changing upstream's test configuration. The `optimized` build type inherits `release`: R8 optimization/minification, resource shrinking, non-debuggable code, dependency consumer keep rules and dependency baseline profiles. It changes only signing (the Android debug key), -package (`org.justinvisser.wolfskin.tv.opt`), launcher label (`Wolfskin (Opt)`) -and version-name suffix (`-opt`). The launcher icon/banner are unchanged. +package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`). +The launcher icon/banner and release-compatible version name are unchanged: +the shared upgrade/updater parser rejects extra suffixes such as `-opt`. `make apk` remains the clean, release-key-signed production build; neither its signing flow nor the existing debug build changes. @@ -98,8 +99,10 @@ consumer rules rather than adding blanket keeps. JVM tests do not prove minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real Conductarr reads, artwork, database reopen and each player on the comparison box. `help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug -variant has no JVM unit-test task, just like release. Build logic is checked -through actual APK assembly and static artifact inspection, not mock unit tests. +variant has no JVM unit-test task, just like release. The existing debug JVM +`VersionCompareTests` receives the actual optimized version name from the variant +API and checks it with the same `Version.fromString` used by upgrade/update handling. +Build logic is otherwise checked through actual APK assembly and static inspection. ## Upstream v1.1.0 integration From ef3ae3f21b12029c2b30fec35f35acc5c8bddfbd Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 15:52:02 +0100 Subject: [PATCH 18/26] Unify focus rings on the approved accent straddle style --- docker/README.md | 28 ++++++- .../wolfskin/design/CatalogueRecoveryCard.kt | 2 +- .../wolfskin/design/DiscoverScreen.kt | 2 +- .../wolfskin/design/DiscoverShelf.kt | 2 +- .../org/justinvisser/wolfskin/design/Focus.kt | 20 ++--- .../justinvisser/wolfskin/design/NavBar.kt | 2 +- .../justinvisser/wolfskin/design/TitleCard.kt | 2 +- .../wolfskin/design/TitleControl.kt | 2 +- .../wolfskin/design/TitleShelf.kt | 4 +- .../wolfskin/design/WhosWatching.kt | 6 +- .../wolfskin/design/WolfskinTokens.kt | 10 +-- .../wolfskin/design/DiscoverCaptureTest.kt | 1 + .../wolfskin/design/FocusRingTest.kt | 84 +++++++++++++++++++ .../wolfskin/design/HomeCaptureTest.kt | 1 + .../wolfskin/design/LibraryCaptureTest.kt | 1 + .../wolfskin/design/ShellCaptureTest.kt | 1 + wolfskin-design/tokens.json | 55 +++++++----- wolfskin-design/tokens.sha256 | 4 +- 18 files changed, 173 insertions(+), 54 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt diff --git a/docker/README.md b/docker/README.md index a40c5a4c..07e1b750 100644 --- a/docker/README.md +++ b/docker/README.md @@ -384,7 +384,7 @@ Discover and season-chip rows retain their 104 px height. Cards and cast circles draw a single 4 px accent stroke centred on the art edge, above the content, with no background gap. Card lift parameters are unchanged; cast portraits and episode tiles now receive that lift too. The stroke and art -scale together at 1.08; buttons, avatars, chips and picker focus are unchanged. +scale together at 1.08. Title shelves reserve vertical drawing room for the scaled ring. Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. @@ -405,6 +405,32 @@ These captures are recordings, not automated reference-pixel assertions; pair measurements are a separate check. Platform Roboto metrics and fixture ordering are not browser pixel parity. +### Shared focus rings + +The same `focusRing` stroke now covers pill/icon buttons (hero, title, state, +recovery and sheet actions), the nav avatar, picker people and gear. It is always +accent, 4 px / 2 dp wide, centred on the surface edge with no background gap. +The former outside-gap renderer is removed. The stroke draws after opaque +content, so neither half can be covered by the background or portrait. + +Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`; +avatar and picker tokens use the same geometry. Existing scales remain 1.05 for +buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card +and button lifts remain; avatars, people and gear gain no shadow. Existing +clearance accommodates the smaller outside reach without layout movement. + +Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail +are unchanged. Selected-only chip/icon/radio borders and decorative borders +are not focus rings. These distinctions are explicit in the inventory and +reference states rather than silently adding rings to filled controls. + +`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the +two outside and two inside pixels against opaque content for pill, circle and +card radii, including clearing the stroke on blur. The focused hero Details, +nav avatar, picker, Discover chip, Library toolbar chip and title Request +captures exercise the actual screens at 1920×1080. Browser and native glyph +metrics differ; their ring offsets are measured relative to each surface edge. + ## Title pages and people `TitleSurface` owns the film/series/episode header, season chips, episode tiles, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt index b2bb7ca4..4c7e5f8d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) .focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 0d836c4f..bd6c40ef 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -197,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) { @Composable fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 7af7ec22..92e8317e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) - .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) + .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index a4a69580..39e23ea1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -15,11 +15,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.Stroke @@ -36,12 +34,12 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { return graphicsLayer { scaleX = animated; scaleY = animated } } -/** Content lifts are distinct from the white person/gear rings. */ +/** Lift shadows remain specific to cards and buttons. */ enum class FocusLift { None, Card, Button } -/** Card rings straddle the art edge above content; control rings retain their outside gap. */ +/** Every focus ring straddles the surface edge above content, without a background gap. */ @Composable -fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier { +fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") val shadow = if (lift == FocusLift.None) this else { @@ -56,25 +54,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } - if (straddle) return shadow.drawWithCache { + return shadow.drawWithCache { val stroke = Stroke(width.toPx()) val corner = CornerRadius(radius.toPx()) onDrawWithContent { drawContent() if (ringAlpha > 0f) { - drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), + drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha), cornerRadius = corner, style = stroke) } } } - return shadow.drawBehind { - if (ringAlpha > 0f) { - val outer = (gap + width).toPx() - val inner = gap.toPx() - drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer)) - drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner)) - } - } } /** Pointing changes the same logical focus as the D-pad without activating an action. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 1fedb8c7..f88d4458 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -111,7 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) val avatar = boxes.last() Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Modifier.offset(avatar.left, avatar.top) - .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) + .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) .focusProperties { canFocus = false } .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index fa51ac7c..fd581f12 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) { + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index 4346eae2..ee5054c5 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -37,7 +37,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(background, shape) .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 0e3e8315..15a74880 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -64,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) { Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { - PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true).clip(CircleShape)) + PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardRing, FocusLift.Card).clip(CircleShape)) Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) @@ -115,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 1911e346..48d6c5e6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -46,7 +46,7 @@ private fun PeoplePicker(state: ShellState) { state.focusPerson(index); state.key(ShellKey.Ok) }, horizontalAlignment = Alignment.CenterHorizontally) { Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, - Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg)) + Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) Spacer(Modifier.height(S.pickerNameGap)) Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName) } @@ -55,7 +55,7 @@ private fun PeoplePicker(state: ShellState) { val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2 Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) + .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing) .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) if (state.gearFocused) { @@ -106,7 +106,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { 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 58f5a3df..4a9ec01c 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 @@ -199,12 +199,12 @@ object WolfskinTokens { const val scaleRow = 1.02f val ringCardGap = 0.0.dp val ringCardRing = 2.0.dp - val ringButtonGap = 2.0.dp - val ringButtonRing = 2.0.dp - val ringAvatarGap = 2.0.dp + val ringControlGap = 0.0.dp + val ringControlRing = 2.0.dp + val ringAvatarGap = 0.0.dp val ringAvatarRing = 2.0.dp - val ringPickerGap = 3.0.dp - val ringPickerRing = 3.0.dp + val ringPickerGap = 0.0.dp + val ringPickerRing = 2.0.dp const val fillInverse = "background color.fg, text color.onFocus" } object Motion { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index a913391e..68ee58f5 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -90,6 +90,7 @@ class DiscoverCaptureTest { } @Test fun st029_discover() = capture("st-029") + @Test fun focusFilterChip() = capture("st-120") { it.focus(1, 0) } @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } @Test fun st031_hidden() = capture("st-031") { hidden(it) } @Test fun st032_restore() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt new file mode 100644 index 00000000..9a2b6ae5 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt @@ -0,0 +1,84 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Opaque content must not cover either half of the shared accent focus stroke. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FocusRingTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + val focused = mutableStateOf(true) + val radius = mutableStateOf(WolfskinTokens.Radius.button) + rule.setContent { + Box(Modifier.requiredSize(120.dp).background(WolfskinTokens.Color.bg)) { + Box(Modifier.offset(40.dp, 40.dp).size(60.dp) + .focusRing(focused.value, radius.value, WolfskinTokens.Focus.ringControlRing) + .background(WolfskinTokens.Color.surface, RoundedCornerShape(radius.value))) + } + } + val file = File.createTempFile("focus-ring-", ".png") + try { + for (corner in listOf(WolfskinTokens.Radius.button, 30.dp, WolfskinTokens.Radius.card)) { + rule.runOnIdle { radius.value = corner } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + for ((x, expected) in listOf(77 to WolfskinTokens.Color.bg, 78 to WolfskinTokens.Color.accent, + 79 to WolfskinTokens.Color.accent, 80 to WolfskinTokens.Color.accent, + 81 to WolfskinTokens.Color.accent, 82 to WolfskinTokens.Color.surface)) { + assertPixel("Left edge x=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(x, 140)) + assertPixel("Top edge y=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(140, x)) + } + bitmap.recycle() + } + rule.runOnIdle { focused.value = false } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + assertEquals(WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(79, 140)) + assertEquals(WolfskinTokens.Color.surface.toArgb(), bitmap.getPixel(80, 140)) + bitmap.recycle() + } finally { file.delete() } + } + private fun assertPixel(label: String, expected: Int, actual: Int) { + // Circular edge coverage can differ by a few channel values at pixel centres. + for (shift in listOf(0, 8, 16, 24)) { + val difference = kotlin.math.abs((expected ushr shift and 255) - (actual ushr shift and 255)) + assertTrue("$label: channel $shift differs by $difference", difference <= 8) + } + } +} \ No newline at end of file diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 647bf533..323b52ff 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -116,5 +116,6 @@ class HomeCaptureTest { @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st103_heroRatings() = capture("st-103") @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") + @Test fun focusHeroDetails() = capture("focus-hero-details", column = 1) @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt index 36fb48d3..8dd2e7f4 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt @@ -83,6 +83,7 @@ class LibraryCaptureTest { @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) @Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows") + @Test fun focusToolbarChip() = capture("focus-toolbar-chip") { it.toolbar = true; it.tool = 0 } @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid -> val original = grid.source grid.source = List(200) { index -> diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt index 62d40759..3774962c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt @@ -54,6 +54,7 @@ class ShellCaptureTest { } @Test fun st001_launch() = capture("st-001") + @Test fun focusNavAvatar() = capture("focus-nav-avatar") { it.pickerVisible = false; it.focusNav(it.tabs.size) } @Test fun st002_gear() { capture("st-002") { it.pickerIndex = 2; it.gearFocused = true } val layouts = mutableListOf() diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 30bdacb8..f8121dc4 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (originally data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...; now amended in data/tv-v2/spacing-2026-11-06/index.html, sha256 fcc5d879...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it). Amended 2026-11-06 (spec revision 3, Level 2 spacing + card ring; data/tv-card-spacing/answers.md): card, layout.row*/gridRowGap and focus.ringCard changed; every other token is as extracted. The prototype source line numbers in `source` fields predate the amendment and drift by a few lines after index.html:8.", + "$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.", "meta": { "density": 2.0, "stagePx": [ @@ -10,8 +10,8 @@ 960, 540 ], - "source": "data/tv-v2/spacing-2026-11-06/index.html", - "sourceSha256": "fcc5d879c34da868b817f8d9d306193743d3933db3096da605bda659d8e633b2", + "source": "data/tv-v2/focus-2026-11-06/index.html", + "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" }, "color": { @@ -20,7 +20,7 @@ "$value": "#07070a", "css": "#07070a", "source": "index.html:10", - "usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid" + "usedBy": "app background; stage; hero/detail scrim end; nav band when solid" }, "surface": { "$type": "color", @@ -1338,33 +1338,48 @@ "outsidePx": 2, "insidePx": 2 }, - "ringButton": { + "ringControl": { "$type": "shadow", - "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 12px 40px rgb(0 0 0 / .5)", - "gapPx": 4, + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)", + "gapPx": 0, "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", "ringColor": "color.accent", "liftShadow": "0 12px 40px rgb(0 0 0 / .5)", - "source": "index.html:110", - "usedBy": "focused pill and icon buttons (primary stays white, secondary lightens to buttonSecondaryFocused)" + "source": "index.html:111", + "usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged" }, "ringAvatar": { "$type": "shadow", - "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--fg)", - "gapPx": 4, + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", + "gapPx": 0, "ringPx": 4, - "ringColor": "color.fg", - "source": "index.html:92", - "usedBy": "focused nav avatar and focused gear (white ring, not lilac)" + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "source": "index.html:92,312", + "usedBy": "focused nav avatar and gear; existing scale unchanged, no lift" }, "ringPicker": { "$type": "shadow", - "$value": "0 0 0 6px var(--bg),0 0 0 12px var(--fg)", - "gapPx": 6, - "ringPx": 6, - "ringColor": "color.fg", - "source": "index.html:263", - "usedBy": "focused person in Who's watching (white 6 + 6 ring; name turns fg)" + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", + "gapPx": 0, + "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "source": "index.html:265", + "usedBy": "focused person in Who's watching; scale 1.08, name turns fg, no lift" }, "fillInverse": { "$type": "rule", diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index dfc777fb..b3fabec1 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css -6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json +3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json From 556d633e8896b301cd90864f713e2f5a9fca2e8b Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 17:37:36 +0100 Subject: [PATCH 19/26] Persist the debug signing key across container builds --- docker/README.md | 44 ++++++++++++++++++++++++++++++++++++++++---- docker/gradle.sh | 31 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docker/README.md b/docker/README.md index e2e760c6..228da088 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,8 +1,8 @@ # Wolfskin TV container builds Run from a full Git checkout with the upstream `v*` and `p*` tags available. The -host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the -Android SDK. +host needs Docker, GNU Make, Bash, Git, `realpath` (GNU coreutils), and `flock` +(util-linux), not Java or the Android SDK. ```sh git fetch origin --tags @@ -35,8 +35,9 @@ dependency baseline profiles. It changes only signing (the Android debug key), package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`). The launcher icon/banner and release-compatible version name are unchanged: the shared upgrade/updater parser rejects extra suffixes such as `-opt`. -`make apk` remains the clean, release-key-signed production build; neither its -signing flow nor the existing debug build changes. +`make apk` remains the clean, release-key-signed production build. Debug and +optimized containers share the persistent debug key described below; release +signing is unchanged. Build all four default-flavor outputs in one invocation through the container wrapper, without a release keystore. On a host whose Gradle cache already holds @@ -814,6 +815,41 @@ whose parent directory already exists. All callers sharing a volume must use the same lock path on the Docker host. Do not delete the file while builds are running or waiting. The lock releases automatically when the invocation exits. +## Persistent debug signing + +`make apk-debug` and non-release `docker/gradle.sh` invocations (including +`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout: + +```text +/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore +``` + +Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside +the checkout when building on another host, for example: + +```sh +WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug +``` + +The wrapper creates a missing key once using the build image's `keytool`: RSA +2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`, +and subject `CN=Android Debug`. The containing directory is mode `0700`, the +file mode `0600`. Existing keys are reused, never regenerated; generation errors +abort the build rather than falling back to an ephemeral container key. +The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with +`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container. +Keep and back up this file across clean builds, disposable worktrees, container +rebuilds and Gradle cache resets. Never commit it. The standard debug passwords +are public; this key is for local debug/optimized builds, not release signing. + +**One-time reinstall:** the first build with the stable key still needs one +uninstall+reinstall on the box because the signer changes once; later ones +install in place with `adb install -r`, retaining sign-ins and settings. +The initial uninstall removes app data, so sign in again once after that switch. +Deleting/replacing the persistent key or choosing a different override changes +the signer again and requires another reinstall. + + ## Release credentials The default escrow is diff --git a/docker/gradle.sh b/docker/gradle.sh index b5624cbf..461018bc 100755 --- a/docker/gradle.sh +++ b/docker/gradle.sh @@ -35,5 +35,36 @@ if [[ $mode == release ]]; then exec ./gradlew "$@" ' bash "${gradle_arguments[@]}" "$@" else + debug_keystore=$(realpath -m "${WOLFSKIN_DEBUG_KEYSTORE:-/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore}") + case "$debug_keystore" in + "$root"|"$root"/*) + printf 'Debug keystore must be outside the checkout: %s\n' "$debug_keystore" >&2 + exit 1 + ;; + esac + signing_directory=$(dirname "$debug_keystore") + mkdir -p "$signing_directory" + chmod 700 "$signing_directory" + if [[ ! -e $debug_keystore ]]; then + temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX") + trap 'rm -rf -- "$temporary_directory"' EXIT + if ! docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$temporary_directory,dst=/run/signing" \ + "$image" keytool -genkeypair -noprompt -keystore /run/signing/debug.keystore \ + -storetype JKS -storepass android -keypass android -alias androiddebugkey \ + -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug"; then + printf 'Failed to generate persistent debug keystore: %s\n' "$debug_keystore" >&2 + exit 1 + fi + chmod 600 "$temporary_directory/debug.keystore" + mv "$temporary_directory/debug.keystore" "$debug_keystore" + fi + if [[ ! -s $debug_keystore ]]; then + printf 'Missing or empty persistent debug keystore: %s\n' "$debug_keystore" >&2 + exit 1 + fi + chmod 600 "$debug_keystore" + arguments+=(--env ANDROID_USER_HOME=/tmp/.android + --mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly") docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" fi From 278335f009b5871f29f95a569a6601c40c42aa15 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 20:12:06 +0100 Subject: [PATCH 20/26] Present watched recommendation seeds and request history truthfully --- .../tv/ActivityTruthProjectionTest.kt | 74 + .../wolfskin/tv/ConductarrProjectionTest.kt | 1 - .../wolfskin/tv/ConductarrTruthCaptureTest.kt | 105 + .../wolfskin/tv/DiscoverSeedProjectionTest.kt | 37 + .../wolfskin/tv/FakeConductarr.kt | 4 + .../conductarr/activity-history.json | 2568 ++++++ .../resources/conductarr/mindel-feed.json | 8143 +++++++++++++++++ .../wolfskin/tv/ConductarrProjection.kt | 58 +- .../wolfskin/tv/ConductarrProvider.kt | 12 +- .../wolfskin/tv/RequestsRefresh.kt | 2 +- docker/README.md | 43 +- .../wolfskin/design/ComingInSheet.kt | 10 +- .../wolfskin/design/ComingInState.kt | 23 +- .../wolfskin/design/DiscoverFixture.kt | 3 +- .../wolfskin/design/DiscoverLaneProjection.kt | 8 +- .../wolfskin/design/DiscoverModel.kt | 4 +- .../wolfskin/design/SettingsList.kt | 10 +- .../wolfskin/design/ShellSurface.kt | 4 +- .../design/DiscoverLaneProjectionTest.kt | 23 +- .../wolfskin/design/RequestsKeysTest.kt | 3 - .../wolfskin/design/ShellCaptureTest.kt | 6 +- 21 files changed, 11092 insertions(+), 49 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt create mode 100644 app/src/test/resources/conductarr/activity-history.json create mode 100644 app/src/test/resources/conductarr/mindel-feed.json diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt new file mode 100644 index 00000000..89afd29e --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.justinvisser.wolfskin.conductarr.model.ReadinessStage +import org.justinvisser.wolfskin.design.ComingInStage +import org.justinvisser.wolfskin.design.ShellPerson +import org.junit.Assert.assertEquals +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.model.ActivityFeed + +/** Recorded history separates acquisition, visibility proof, entitlement and file health. */ +class ActivityTruthProjectionTest { + private val rows = Json { ignoreUnknownKeys = true }.decodeFromString( + javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id } + @Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() { + val viewer = ShellPerson("justin", "Justin", "J", false) + val expected = mapOf( + "request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof. + "request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof. + "dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition. + "dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere. + "dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor. + "dispatch-723" to "Removed", // A historical play is not a current holding. + "dispatch-728" to "Not out yet", // The Body: unreleased. + "dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding. + "request-8" to "Waiting for a file", // Partition: no active transfer is established. + "dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed. + ) + assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer)) + expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) } + listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id -> + val source = rows.getValue(id) + val projected = source.comingIn() + val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED } + assertEquals(affected.summary, projected.summary) + assertFalse(projected.readiness[1] == "Failed") + } + } + + @Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() { + val row = rows.getValue("dispatch-721") + val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable." + val projected = row.copy(stages = row.stages.orEmpty().map { + when (it.name) { + ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary) + ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE) + else -> it + } + }).comingIn() + assertEquals(ComingInStage.FileHealth, projected.stage) + assertEquals(summary, projected.summary) + } + + @Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() { + val row = rows.getValue("request-8") + val projected = row.copy(stages = row.stages.orEmpty().map { + if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it + }).comingIn() + assertEquals(ComingInStage.Waiting, projected.stage) + } + + @Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() { + val row = rows.getValue("dispatch-725") + val absent = row.copy(stages = row.stages.orEmpty().map { + if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it + }) + assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage) + val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } } + assertEquals(ComingInStage.Ready, ready.comingIn().stage) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt index b5be3c2b..7fcdf8c4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -41,7 +41,6 @@ class ConductarrProjectionTest { assertEquals(ComingInStage.Downloading, request.stage) assertEquals("Mindel", request.requesterName) assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true))) - assertEquals(1, requests.openCount) } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt new file mode 100644 index 00000000..41d4fffb --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.ConductarrClient +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConductarrTruthCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false)) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @Test fun requestHistory() = requests("requests-history", "request-2") + @Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true) + @Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true) + @Test fun requestRemoved() = requests("requests-removed", "dispatch-725") + @Test fun discoverMovies() = discover("movie") + @Test fun discoverSeries() = discover("tv") + + private fun requests(name: String, focused: String, expanded: Boolean = false) { + FakeConductarr().use { fake -> + fake.activityBody = resource("activity-history.json") + ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client -> + val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) } + val shell = ShellState(people, "justin").apply { + pickerVisible = false + enterDestination("settings") + requests.update(snapshot) + openRequests() + requests.focus(focused) + if (expanded) requests.key(ShellKey.Right) + } + rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } + capture(name) + rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0) + rule.onNodeWithText("Requests · 56 requests").assertExists() + if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed() + } + } + } + + private fun discover(type: String) { + FakeConductarr().use { fake -> + fake.feedBody = resource("mindel-feed.json") + ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client -> + val provider = ConductarrProvider(client) + val snapshot = runBlocking { provider.discover("mindel").last() }.let { original -> + fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null) + original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) }) + } + val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") } + val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value } + val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate)) + state.filter = type + state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0) + rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } } + capture("discover-$type") + rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed() + } + } + } + + private fun capture(name: String) { + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt new file mode 100644 index 00000000..0bd5e342 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.* +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.ConductarrClient +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore + +/** Equal captions are not equal seed identities; repeated contributions union their evidence. */ +class DiscoverSeedProjectionTest { + @Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking { + FakeConductarr().use { fake -> + val feed = fake.wire.getValue("feed").jsonObject + val candidate = feed.getValue("movies").jsonArray.first().jsonObject + val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[ + {"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false}, + {"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true}, + {"media_type":"movie","tmdb_id":2,"watched":true,"added":false} + ]"""))) + val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[ + {"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true} + ]"""))) + fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString() + ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client -> + val seeds = ConductarrProvider(client).discover("mindel").last().seeds + assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key }) + assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb }) + assertTrue(seeds.first().watched && seeds.first().added) + assertFalse(seeds.last().watched) + assertTrue(seeds.last().added) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index f741223e..647b0388 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -29,6 +29,8 @@ internal class FakeConductarr : Closeable { @Volatile var localArt = false @Volatile var beforeResponse: ((String) -> Unit)? = null @Volatile var audiencesBody: String? = null + @Volatile var feedBody: String? = null + @Volatile var activityBody: String? = null private val imageBytes = java.util.Base64.getDecoder().decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") @@ -69,6 +71,8 @@ internal class FakeConductarr : Closeable { if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) if (key == "audiences") audiencesBody?.let { return response(it) } + if (key == "feed") feedBody?.let { return response(it) } + if (key == "activity") activityBody?.let { return response(it) } if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") var body = wire.getValue(key).jsonObject diff --git a/app/src/test/resources/conductarr/activity-history.json b/app/src/test/resources/conductarr/activity-history.json new file mode 100644 index 00000000..69a29b75 --- /dev/null +++ b/app/src/test/resources/conductarr/activity-history.json @@ -0,0 +1,2568 @@ +{ + "$schema": "https://wolfskin-tv.justinvisser.org/schemas/ActivityFeed.json", + "requester": "all", + "items": [ + { + "id": "request-11", + "tmdb_id": 14069, + "media_type": "movie", + "title": "The Girl Who Leapt Through Time", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "justin", + "role": "household", + "requester": "justin" + }, + "on_behalf": true, + "filed_by_you": true, + "seasons": [], + "requested_at": "2026-11-06T15:33:09Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "No Seerr request was needed: the media was already held.", + "source": "native request record", + "observed_at": "2026-11-06T15:33:10Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T15:41:46Z" + } + ] + }, + { + "id": "request-10", + "tmdb_id": 290720, + "media_type": "tv", + "title": "The Final Problem", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1 + ], + "requested_at": "2026-11-04T18:55:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 72.", + "source": "native request record", + "observed_at": "2026-11-04T18:55:21Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:52Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-9", + "tmdb_id": 289517, + "media_type": "tv", + "title": "Dead-End Job", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1 + ], + "requested_at": "2026-11-04T18:54:18Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 71.", + "source": "native request record", + "observed_at": "2026-11-04T18:54:19Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:51Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-8", + "tmdb_id": 110173, + "media_type": "tv", + "title": "Partition - The Day India Burned", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Justin Visser", + "role": "admin", + "requester": "justin" + }, + "on_behalf": true, + "filed_by_you": true, + "seasons": [ + 1 + ], + "requested_at": "2026-11-01T14:14:54Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 70.", + "source": "native request record", + "observed_at": "2026-11-01T14:14:55Z" + }, + { + "name": "acquisition", + "state": "in_progress", + "summary": "Dispatched; waiting for the Arr to import a file.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "shared-tv: There is no file to show yet.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-7", + "tmdb_id": 93290, + "media_type": "tv", + "title": "Breeders", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1, + 2, + 3, + 4 + ], + "requested_at": "2026-10-28T18:36:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 69.", + "source": "native request record", + "observed_at": "2026-10-28T18:36:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:10Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-6", + "tmdb_id": 243316, + "media_type": "tv", + "title": "The Four Seasons (2025)", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1, + 2 + ], + "requested_at": "2026-10-28T18:34:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 68.", + "source": "native request record", + "observed_at": "2026-10-28T18:34:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S01E02, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:43:54Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-5", + "tmdb_id": 260458, + "media_type": "tv", + "title": "Adults", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [ + 1, + 2 + ], + "requested_at": "2026-10-23T20:55:49Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 67.", + "source": "native request record", + "observed_at": "2026-10-23T20:55:49Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S01E02, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:25Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "request-4", + "tmdb_id": 445629, + "media_type": "movie", + "title": "Fighting with My Family", + "requester": { + "key": "shared", + "name": "shared" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": true, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-22T21:41:58Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 66.", + "source": "native request record", + "observed_at": "2026-10-22T21:41:59Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:53Z" + } + ] + }, + { + "id": "request-3", + "tmdb_id": 399057, + "media_type": "movie", + "title": "The Killing of a Sacred Deer", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "actor": { + "name": "Mindel Voogd", + "role": "household", + "requester": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-22T21:38:52Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 65.", + "source": "native request record", + "observed_at": "2026-10-22T21:38:53Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:52Z" + } + ] + }, + { + "id": "request-2", + "tmdb_id": 225745, + "media_type": "movie", + "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", + "requester": { + "key": "justin", + "name": "justin" + }, + "actor": { + "name": "Justin Visser", + "role": "admin", + "requester": "justin" + }, + "on_behalf": false, + "filed_by_you": true, + "seasons": [], + "requested_at": "2026-10-22T14:55:41Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 64.", + "source": "native request record", + "observed_at": "2026-10-22T14:55:41Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "failed", + "summary": "justin-movies: Jellyfin visibility needs attention.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "request-1", + "tmdb_id": 30669, + "media_type": "tv", + "title": "Yu Yu Hakusho", + "requester": { + "key": "justin", + "name": "justin" + }, + "actor": { + "name": "Justin Visser", + "role": "admin", + "requester": "justin" + }, + "on_behalf": false, + "filed_by_you": true, + "seasons": [ + 1, + 2, + 3, + 4 + ], + "requested_at": "2026-10-19T21:39:29Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Filed in Seerr as request 63.", + "source": "native request record", + "observed_at": "2026-10-19T21:39:29Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "failed", + "summary": "justin-anime: Jellyfin visibility needs attention.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 5 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-11-06T16:13:21Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-765", + "tmdb_id": 37735, + "media_type": "movie", + "title": "Easy A", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-17T19:57:25Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-17T19:57:25Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:30:33Z" + } + ] + }, + { + "id": "dispatch-764", + "tmdb_id": 329865, + "media_type": "movie", + "title": "Arrival", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-14T19:51:06Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-14T19:51:06Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T17:45:10Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T17:45:10Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:28:30Z" + } + ] + }, + { + "id": "dispatch-763", + "tmdb_id": 977942, + "media_type": "movie", + "title": "The Uprising", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-11T17:34:21Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-11T17:34:21Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:53Z" + } + ] + }, + { + "id": "dispatch-762", + "tmdb_id": 316152, + "media_type": "movie", + "title": "Free State of Jones", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-11T17:33:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-11T17:33:20Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:52Z" + } + ] + }, + { + "id": "dispatch-761", + "tmdb_id": 9040, + "media_type": "movie", + "title": "Serpico", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:35:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:35:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:15Z" + } + ] + }, + { + "id": "dispatch-760", + "tmdb_id": 479, + "media_type": "movie", + "title": "Shaft", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:35:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:35:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:04Z" + } + ] + }, + { + "id": "dispatch-759", + "tmdb_id": 197, + "media_type": "movie", + "title": "Braveheart", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:35:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:35:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:02Z" + } + ] + }, + { + "id": "dispatch-758", + "tmdb_id": 9361, + "media_type": "movie", + "title": "The Last of the Mohicans", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:34:14Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:34:14Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:15Z" + } + ] + }, + { + "id": "dispatch-757", + "tmdb_id": 2024, + "media_type": "movie", + "title": "The Patriot", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:34:13Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:34:13Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:07Z" + } + ] + }, + { + "id": "dispatch-756", + "tmdb_id": 715931, + "media_type": "movie", + "title": "Emancipation", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T20:33:13Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T20:33:13Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:43:55Z" + } + ] + }, + { + "id": "dispatch-755", + "tmdb_id": 290750, + "media_type": "tv", + "title": "Love is Blind: Netherlands", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T16:15:28Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T16:15:28Z" + }, + { + "name": "acquisition", + "state": "in_progress", + "summary": "Dispatched; waiting for the Arr to import a file.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "mindel-tv: There is no file to show yet.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-750", + "tmdb_id": 315635, + "media_type": "movie", + "title": "Spider-Man: Homecoming", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:05:49Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:05:49Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-749", + "tmdb_id": 429617, + "media_type": "movie", + "title": "Spider-Man: Far From Home", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:05:32Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:05:32Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-748", + "tmdb_id": 634649, + "media_type": "movie", + "title": "Spider-Man: No Way Home", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:04:54Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:04:54Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-747", + "tmdb_id": 969681, + "media_type": "movie", + "title": "Spider-Man: Brand New Day", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-10T14:04:33Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-10T14:04:33Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-movies.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "visibility" + }, + { + "id": "dispatch-746", + "tmdb_id": 258165, + "media_type": "tv", + "title": "East of Eden (2026)", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-04T09:22:33Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-04T09:22:33Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-745", + "tmdb_id": 76148, + "media_type": "tv", + "title": "Derry Girls", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-10-02T19:58:53Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-10-02T19:58:53Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:06Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-744", + "tmdb_id": 42994, + "media_type": "movie", + "title": "Memories", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-30T21:35:31Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-30T21:35:31Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:05Z" + } + ] + }, + { + "id": "dispatch-743", + "tmdb_id": 616, + "media_type": "movie", + "title": "The Last Samurai", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T20:11:11Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T20:11:11Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:28:31Z" + } + ] + }, + { + "id": "dispatch-742", + "tmdb_id": 1203329, + "media_type": "movie", + "title": "The Witcher: Sirens of the Deep", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:37:11Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:37:11Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:53Z" + } + ] + }, + { + "id": "dispatch-741", + "tmdb_id": 666243, + "media_type": "movie", + "title": "The Witcher: Nightmare of the Wolf", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:37:05Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:37:05Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:26Z" + } + ] + }, + { + "id": "dispatch-740", + "tmdb_id": 1274, + "media_type": "tv", + "title": "Six Feet Under", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:36:06Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:36:06Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:43:59Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-739", + "tmdb_id": 96488, + "media_type": "tv", + "title": "Interrogation", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:35:05Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:35:05Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "shared-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:10Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-738", + "tmdb_id": 47665, + "media_type": "tv", + "title": "Black Sails", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-25T19:34:04Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-25T19:34:04Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-25T19:34:04Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-10-12T16:24:20Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-737", + "tmdb_id": 2207, + "media_type": "tv", + "title": "Fawlty Towers", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-23T12:41:28Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-23T12:41:28Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-735", + "tmdb_id": 133908, + "media_type": "tv", + "title": "SkyMed", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:33:55Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:33:55Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T00:01:41Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-734", + "tmdb_id": 32504, + "media_type": "tv", + "title": "Hellcats", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:31:54Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:31:54Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:13:18Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-733", + "tmdb_id": 40231, + "media_type": "tv", + "title": "So You Think You Can Dance (BE/NL)", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:03:09Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:03:09Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S08E01, S08E02, S08E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:46:05Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-732", + "tmdb_id": 99353, + "media_type": "tv", + "title": "Love is Blind", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-22T17:02:58Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-22T17:02:58Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "failed", + "summary": "mindel-tv: Jellyfin visibility needs attention.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-731", + "tmdb_id": 90228, + "media_type": "tv", + "title": "Dune: Prophecy", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T20:43:46Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T20:43:46Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-11-04T18:22:43Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-728", + "tmdb_id": 291507, + "media_type": "tv", + "title": "The Body (2026)", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T16:07:00Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T16:07:00Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title is not released yet; there is nothing to acquire.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "mindel-tv: There is no file to show yet.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-727", + "tmdb_id": 1437, + "media_type": "tv", + "title": "Firefly", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:33:21Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:33:21Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:33Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-726", + "tmdb_id": 86831, + "media_type": "tv", + "title": "Love, Death & Robots", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:30:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:30:20Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T00:01:36Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-725", + "tmdb_id": 9357, + "media_type": "movie", + "title": "One Hour Photo", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:29:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:29:20Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-20T11:29:20Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-724", + "tmdb_id": 65315, + "media_type": "tv", + "title": "Flaked", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:25:20Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:25:20Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-20T11:25:20Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-09-20T12:15:36Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-723", + "tmdb_id": 9357, + "media_type": "movie", + "title": "One Hour Photo", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T11:22:19Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T11:22:19Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-09-20T11:29:20Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin records the viewer playing this movie. That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-09-20T12:15:36Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-722", + "tmdb_id": 111803, + "media_type": "tv", + "title": "The White Lotus", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:57Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:57Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-06T00:01:39Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-721", + "tmdb_id": 246, + "media_type": "tv", + "title": "Avatar: The Last Airbender", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:57Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:57Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:46:45Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "failed", + "summary": "A file is unreadable and has not been repaired.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-720", + "tmdb_id": 33880, + "media_type": "tv", + "title": "The Legend of Korra", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "justin-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S02E01, S03E01, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:30:33Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-719", + "tmdb_id": 95557, + "media_type": "tv", + "title": "INVINCIBLE (2021)", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-20T00:15:56Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-20T00:15:56Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:00:28Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: justin-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin records the viewer playing 1 episode(s). That is a client playback record, not a server probe.", + "source": "viewer watch record and media-health ledger", + "observed_at": "2026-11-04T20:23:49Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-716", + "tmdb_id": 285404, + "media_type": "tv", + "title": "Maximum Pleasure Guaranteed", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-09-07T20:58:52Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-09-07T20:58:52Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-tv: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "playback", + "state": "partial", + "summary": "Jellyfin admits server playback for sampled episodes S01E01, S01E02, S01E03, not every requested episode. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T23:14:51Z" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-526", + "tmdb_id": 124428, + "media_type": "tv", + "title": "B&B Vol Liefde", + "requester": { + "key": "justin", + "name": "justin" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-31T15:40:26Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-31T15:40:26Z" + }, + { + "name": "acquisition", + "state": "unknown", + "summary": "Dispatched; the Arr has represented files, but which seasons they cover is not established here; open readiness for per-season coverage.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:31:21Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is only in libraries this viewer does not see: shared-tv.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-519", + "tmdb_id": 78941, + "media_type": "tv", + "title": "Aggretsuko", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-17T17:38:47Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-518", + "tmdb_id": 486947, + "media_type": "movie", + "title": "The Guilty", + "requester": { + "key": "shared", + "name": "shared" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-17T17:38:47Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "acquisition", + "state": "none", + "summary": "The title was deleted after it was dispatched.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-07-17T17:38:47Z" + }, + { + "name": "visibility", + "state": "none", + "summary": "The title is not in a library this viewer sees.", + "source": "layer visibility observation" + }, + { + "name": "playback", + "state": "unknown", + "summary": "No playback record or current server playback evidence is available for this viewer.", + "source": "viewer watch record and media-health ledger" + } + ], + "blocking_stage": "acquisition" + }, + { + "id": "dispatch-514", + "tmdb_id": 220289, + "media_type": "movie", + "title": "Coherence", + "requester": { + "key": "mindel", + "name": "mindel" + }, + "on_behalf": false, + "filed_by_you": false, + "seasons": [], + "requested_at": "2026-07-17T17:38:46Z", + "stages": [ + { + "name": "request", + "state": "done", + "summary": "Seerr approved a request for this viewer. It was filed outside the cockpit, so who filed it is not recorded.", + "source": "dispatch log", + "observed_at": "2026-07-17T17:38:46Z" + }, + { + "name": "acquisition", + "state": "done", + "summary": "Dispatched; the Arr has represented files.", + "source": "dispatch log and layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "visibility", + "state": "done", + "summary": "mindel-movies: Jellyfin shows the represented files to the viewer.", + "source": "layer visibility observation", + "observed_at": "2026-11-06T18:15:52Z" + }, + { + "name": "playback", + "state": "done", + "summary": "Jellyfin admits server playback of this movie for the viewer. This is not a media byte-read or phone compatibility check.", + "source": "authenticated Jellyfin PlaybackInfo for the viewer", + "observed_at": "2026-11-05T22:59:24Z" + } + ] + } + ], + "truncated": false, + "sources": [ + { + "name": "native requests", + "state": "observed" + }, + { + "name": "dispatch log", + "state": "observed" + }, + { + "name": "library routes", + "state": "observed" + } + ] +} diff --git a/app/src/test/resources/conductarr/mindel-feed.json b/app/src/test/resources/conductarr/mindel-feed.json new file mode 100644 index 00000000..ffb957ab --- /dev/null +++ b/app/src/test/resources/conductarr/mindel-feed.json @@ -0,0 +1,8143 @@ +{ + "$schema": "https://wolfskin-tv.justinvisser.org/schemas/RecommendationFeed.json", + "profile": { + "key": "mindel", + "name": "mindel" + }, + "state": "current", + "outcome": "ranked", + "attempted_at": "2026-11-06T16:13:56.797980746Z", + "observed_at": "2026-11-06T16:14:14.67322323Z", + "expires_at": "2026-11-06T22:14:14.67322323Z", + "materialization": "current", + "movies": [ + { + "media_type": "movie", + "tmdb_id": 235271, + "tvdb_id": 0, + "title": "Addicted", + "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", + "poster_path": "/fAERrD6vvmznDoL99wlgjhSfPcH.jpg", + "backdrop_path": "/aqqoDJs8vYp2EZ74aQ1UMY0sjM7.jpg", + "year": 2014, + "score": 1.0710949439563022, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 8999, + "tvdb_id": 0, + "title": "Derailed", + "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", + "poster_path": "/bcCoOkwEBHUS5Y7Rm1u1oTLUHdb.jpg", + "backdrop_path": "/yhx8DtHySbvIm19wurVttyXCTTe.jpg", + "year": 2005, + "score": 1, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 317744, + "tvdb_id": 0, + "title": "Careful What You Wish For", + "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", + "poster_path": "/alRvln4lzGjdSExC37vr1spSTF7.jpg", + "backdrop_path": "/kyx15eUlLQy7HHm8HRuvi9QagO8.jpg", + "year": 2015, + "score": 0.745920536874323, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 13250, + "tvdb_id": 0, + "title": "Butterfly on a Wheel", + "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", + "poster_path": "/qcjjYcMaaSKD2C8xn0hy6o6yj2L.jpg", + "backdrop_path": "/7al64Hp3RPaMNB5AWTdFH7RggnG.jpg", + "year": 2007, + "score": 0.5848757652954433, + "genre_ids": [ + 80, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 376004, + "tvdb_id": 0, + "title": "Adulterers", + "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about to learn how far a jealous husband will go.", + "poster_path": "/7weaBxcimjsN9zi89Dfk4fZFCUE.jpg", + "backdrop_path": "/8q6AJYqJzZPrNSbttcwslXx7QgO.jpg", + "year": 2016, + "score": 0.48362143005756547, + "genre_ids": [ + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 13483, + "tvdb_id": 0, + "title": "Awake", + "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make life-altering decisions while wrestling with her own personal drama.", + "poster_path": "/xqooV6lR1Kdx4Q52Sim0aGbDTYP.jpg", + "backdrop_path": "/c5TiPhbggc1LWckfd93p8ZFu16X.jpg", + "year": 2007, + "score": 0.45896249898432395, + "genre_ids": [ + 53, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 18190, + "tvdb_id": 0, + "title": "Dream Lover", + "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman he married.", + "poster_path": "/epl5pprGt04gAJI8lzbKy83XkbE.jpg", + "backdrop_path": "/83hLixhpxMw2WpG72IzFYE28b3v.jpg", + "year": 1994, + "score": 0.30017994059534503, + "genre_ids": [ + 18, + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 210577, + "tvdb_id": 0, + "title": "Gone Girl", + "overview": "With his wife's disappearance having become the focus of an intense media circus, a man sees the spotlight turned on him when it's suspected that he may not be innocent.", + "poster_path": "/ts996lKsxvjkO2yiYG0ht4qAicO.jpg", + "backdrop_path": "/iWak7wT0j6ycCc8lKr4NBz9c7n5.jpg", + "year": 2014, + "score": 0.2353903390452386, + "genre_ids": [ + 9648, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 619979, + "tvdb_id": 0, + "title": "Deep Water", + "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", + "poster_path": "/6yRMyWwjuhKg6IU66uiZIGhaSc8.jpg", + "backdrop_path": "/lcITbesmy0IBO6TXTAgQHJE6Asx.jpg", + "year": 2022, + "score": 0.23033665255417463, + "genre_ids": [ + 18, + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1317288, + "tvdb_id": 0, + "title": "Marty Supreme", + "overview": "Marty Mauser, a young man with a dream no one respects, goes to hell and back in pursuit of greatness.", + "poster_path": "/lYWEXbQgRTR4ZQleSXAgRbxAjvq.jpg", + "backdrop_path": "/6h7w8R4BA7vubEmEEW5vYEjuoz6.jpg", + "year": 2025, + "score": 0.1659867561457205, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + }, + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 582014, + "tvdb_id": 0, + "title": "Promising Young Woman", + "overview": "A young woman, traumatized by a tragic event in her past, seeks out vengeance against those who crossed her path.", + "poster_path": "/2h4EkRxy36zSsBDkijXSUMJFDaz.jpg", + "backdrop_path": "/gESm6yc2MZpSXakgqLF0OlLaRn1.jpg", + "year": 2020, + "score": 0.15298749966144132, + "genre_ids": [ + 53, + 80, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 549514, + "tvdb_id": 0, + "title": "Fatale", + "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective Valerie Quinlan who entangles him in her latest investigation. As he desperately tries to put the pieces together, he falls deeper into her trap, risking his family, his career, and even his life.", + "poster_path": "/tGFXEyESXIuei5tZWXRkSRSFSxi.jpg", + "backdrop_path": "/tljkeU2y1dCvT51Sv4ciNf0VfmV.jpg", + "year": 2020, + "score": 0.1475523221245938, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 59231, + "tvdb_id": 0, + "title": "The Rain People", + "overview": "When a housewife finds out she is pregnant, she runs out of town looking for freedom to reevaluate her life decisions.", + "poster_path": "/dDg4I79yb2VNdM3Vkh9oDOUROTc.jpg", + "backdrop_path": "/k5VMD0fpCB8CunTjzpHdjOp6Lqv.jpg", + "year": 1969, + "score": 0.14285714285714285, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1249213, + "tvdb_id": 0, + "title": "Drop", + "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle as Violet begins being irritated and then terrorized by a series of anonymous drops to her phone.", + "poster_path": "/hSQPSW8aLjsMBfwqGjgJ6HozTkp.jpg", + "backdrop_path": "/akBGSguhkAj1TXOdZUcL11e1hpH.jpg", + "year": 2025, + "score": 0.11516832627708731, + "genre_ids": [ + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 624788, + "tvdb_id": 0, + "title": "Black Bear", + "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and jealousy, unaware of how dangerously intertwined their lives will soon become.", + "poster_path": "/nqZFjQRWfW6PjFTc17pzJ5LRAAv.jpg", + "backdrop_path": "/o87ErHJHx99C5B8IiW9RYK1uCdU.jpg", + "year": 2020, + "score": 0.1111111111111111, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 2252, + "tvdb_id": 0, + "title": "Eastern Promises", + "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", + "poster_path": "/nLwv1pxRCRZucQaqs6oPkq7HH3K.jpg", + "backdrop_path": "/1ckHUJ6ztq5XJRKM2nIAW0UjJNH.jpg", + "year": 2007, + "score": 0.1, + "genre_ids": [ + 53, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 311667, + "tvdb_id": 0, + "title": "Manhattan Night", + "overview": "Porter Wren is a Manhattan tabloid writer with an appetite for scandal. On the beat he sells murder, tragedy, and anything that passes for the truth. At home, he is a dedicated husband and father. But when Caroline, a seductive stranger asks him to dig into the unsolved murder of her filmmaker husband Simon, he is drawn into a very nasty case of sexual obsession and blackmail--one that threatens his job, his marriage, and his life.", + "poster_path": "/d40J0y6Ha1eakPrmfyEHoKFQrSB.jpg", + "backdrop_path": "/1eM6CsDTwRmxgnigzz8pitLlUKJ.jpg", + "year": 2016, + "score": 0.09678494183275951, + "genre_ids": [ + 18, + 9648, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 2990, + "tvdb_id": 0, + "title": "Pacific Heights", + "overview": "A couple works hard to renovate their dream house and become landlords to pay for it. Unfortunately one of their tenants has plans of his own.", + "poster_path": "/9iHD6xIrGSuuMEFWhwSVGXSddY2.jpg", + "backdrop_path": "/oifQqVSpK5fOqgCrYIEbLGkcIiS.jpg", + "year": 1990, + "score": 0.09619987082688097, + "genre_ids": [ + 53, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1965, + "tvdb_id": 0, + "title": "A Perfect Murder", + "overview": "Millionaire industrialist Steven Taylor is a man who has everything but what he craves most: the love and fidelity of his wife. A hugely successful player in the New York financial world, he considers her to be his most treasured acquisition. But she needs more than simply the role of dazzling accessory.", + "poster_path": "/wC0ax12N9GQ8vMXPEs4nES5AAiB.jpg", + "backdrop_path": "/dNW7ftcqEAvKmDkuRPaLC4gnhwJ.jpg", + "year": 1998, + "score": 0.09122515832853577, + "genre_ids": [ + 80, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 63493, + "tvdb_id": 0, + "title": "The Ledge", + "overview": "A thriller in which a battle of philosophies between a fundamentalist Christian and an atheist who shares an apartment with a gay friend escalates into a lethal battle of wills.", + "poster_path": "/cTaDS6CTIfZ5MH189ub6oE1FATk.jpg", + "backdrop_path": "/fWOfr0L1ISpP3KF8qY2qG1qrxMi.jpg", + "year": 2011, + "score": 0.09090909090909091, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 451156, + "tvdb_id": 0, + "title": "Infidelity in Suburbia", + "overview": "With her husband away at work, and her son at school, Laura dreams of a home-renovation project to supplant her restless days. Elliott, her irresistibly hot private contractor, soon fulfills her more forbidden dreams.", + "poster_path": "/gKyvJ8u2US61pHR56C2DoVbwnu.jpg", + "backdrop_path": "/wjnewOLmAYEfh1jeoXme7FO77K2.jpg", + "year": 2017, + "score": 0.08333333333333333, + "genre_ids": [ + 53, + 10770 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1920, + "tvdb_id": 0, + "title": "Crime of Passion", + "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a dangerous path.", + "poster_path": "/dDpstkYlTAE8n9jqcTYt3jJVNXm.jpg", + "backdrop_path": "/r3Ddxw4v780GzOZdjAT2CMgo0Gn.jpg", + "year": 1956, + "score": 0.0737761610622969, + "genre_ids": [ + 18, + 80, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1033148, + "tvdb_id": 0, + "title": "Die My Love", + "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace struggles with loneliness, creative frustration, and unresolved emotional wounds. What begins as an attempt at renewal gradually turns into an intense psychological descent, placing strain on their relationship and exposing the fragile balance between love, identity, and motherhood.", + "poster_path": "/kajpShbFhOdpl6yCrLezMrr9tB4.jpg", + "backdrop_path": "/dEkFlZzj2cb5F7SShvqkL54E4lr.jpg", + "year": 2025, + "score": 0.07142857142857142, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 7442, + "tvdb_id": 0, + "title": "Sleeping with the Enemy", + "overview": "A young woman fakes her own death in an attempt to escape her nightmarish marriage, but discovers it is impossible to elude her controlling husband.", + "poster_path": "/zaUkndJP8IClB1gMsbw13PoVeLS.jpg", + "backdrop_path": "/apUbdi4nGrD5VGIaxErtpsn7ST8.jpg", + "year": 1991, + "score": 0.07039553585925253, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + }, + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 8884, + "tvdb_id": 0, + "title": "Franklyn", + "overview": "Set between the parallel worlds of contemporary London and the futuristic faith dominated metropolis of Meanwhile City, Franklyn weaves a tale of four souls, whose lives are intertwined by fate, romance and tragedy. As these worlds collide, a single bullet determines the destiny of these four characters.", + "poster_path": "/5yns10om8I19j4OHy1zoVRilAnw.jpg", + "backdrop_path": "/6hQgTpZVNHrLafE6IeSAoThQwkH.jpg", + "year": 2008, + "score": 0.06666666666666667, + "genre_ids": [ + 14, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 14698, + "tvdb_id": 0, + "title": "Suddenly, Last Summer", + "overview": "The only son of wealthy widow Violet Venable dies while on vacation with his cousin Catherine. What the girl saw was so horrible that she went insane; now Mrs. Venable wants Catherine lobotomized to cover up the truth.", + "poster_path": "/igma0zn9J5srRvOMDRnxyUDQXJL.jpg", + "backdrop_path": "/pMIIOovLyyCr8W5JFOqsSO8gK1r.jpg", + "year": 1959, + "score": 0.06556607128347484, + "genre_ids": [ + 53, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 45938, + "tvdb_id": 0, + "title": "Sunday Bloody Sunday", + "overview": "Recently divorced career woman Alex Greville begins a romantic relationship with glamorous mod artist Bob Elkin, fully aware that he's also intimately involved with middle-aged doctor Daniel Hirsh. For both Alex and Daniel, the younger man represents a break with their repressive pasts, and though both know that Bob is seeing both of them, neither is willing to let go of the youth and vitality he brings to their otherwise stable lives.", + "poster_path": "/7q1h3M1vDlsHDKRhoPOzKjmFCVq.jpg", + "backdrop_path": "/xkIaVZo98JFCteffdXRgZfcW5A.jpg", + "year": 1971, + "score": 0.0625, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 16933, + "tvdb_id": 0, + "title": "They Drive by Night", + "overview": "Joe and Paul Fabrini are Wildcat, or independent, truck drivers who have their own small one-truck business. The Fabrini boys constantly battle distributors, rivals and loan collectors, while trying to make a success of their transport company.", + "poster_path": "/uF0X3IMhPIFgbwzlbayUsj4I6Tc.jpg", + "backdrop_path": "/4rd9SUlamdxZlLkCBl6A1z24L4e.jpg", + "year": 1940, + "score": 0.058823529411764705, + "genre_ids": [ + 18, + 80, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 7183, + "tvdb_id": 0, + "title": "Perfect Stranger", + "overview": "A journalist goes undercover to ferret out businessman Harrison Hill as her best friend's killer. Posing as one of his temps, she enters into a game of online cat-and-mouse.", + "poster_path": "/i7D5QqQ0q5EXU1UuZVY9KwIxoYR.jpg", + "backdrop_path": "/sNhYDvDbEwf9AUGip2fGRwP7x8r.jpg", + "year": 2007, + "score": 0.056750893124843764, + "genre_ids": [ + 53, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 893086, + "tvdb_id": 0, + "title": "The Good Neighbor", + "overview": "A nightmarish evening unfolds for neighbors David and Robert when they accidentally hit a woman on her bike and flee the scene. While David is increasingly plagued by feelings of guilt, Robert shows no remorse and becomes overbearing and possessive.", + "poster_path": "/mXJ2xI7B0Z9WxDjyv18anPsOxeE.jpg", + "backdrop_path": "/AnmfRV7X0kwEn96pg0lAVopV8YK.jpg", + "year": 2022, + "score": 0.05555555555555555, + "genre_ids": [ + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1213, + "tvdb_id": 0, + "title": "The Talented Mr. Ripley", + "overview": "Tom Ripley is a calculating young man who believes it's better to be a fake somebody than a real nobody. Opportunity knocks in the form of a wealthy U.S. shipbuilder who hires Tom to travel to Italy to bring back his playboy son, Dickie. Ripley worms his way into the idyllic lives of Dickie and his girlfriend, plunging into a daring scheme of duplicity, lies and murder.", + "poster_path": "/6ojHgqtIR41O2qLKa7LFUVj0cZa.jpg", + "backdrop_path": "/51UDa3IuGyiKGe2MgMcLGvJE2Fm.jpg", + "year": 1999, + "score": 0.05269725790164064, + "genre_ids": [ + 53, + 80, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 1317088, + "tvdb_id": 0, + "title": "The Assessment", + "overview": "In a climate change-ravaged world, a utopian society optimizes life, including parenthood assessments. A successful couple faces scrutiny by an evaluator over seven days to determine their fitness for childbearing.", + "poster_path": "/o1K7be85FAauSbVPzCQ0GjOXUqu.jpg", + "backdrop_path": "/96w2p3xKIgvuSTJsNVnvNFqOhPJ.jpg", + "year": 2025, + "score": 0.05263157894736842, + "genre_ids": [ + 878, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 50875, + "tvdb_id": 0, + "title": "Higher Ground", + "overview": "Inspired by screenwriter Carolyn S. Briggs' memoir This Dark World, a tight-knit spiritual community is thrown off-kilter when one of their own begins to question her faith, love, and trust - in human relationships as well as in God.", + "poster_path": "/xFvN6OVYGwPokUnkZlKxd1coxbS.jpg", + "backdrop_path": "/evC47eVO3jBNb1iaXyc5TdnDMgl.jpg", + "year": 2011, + "score": 0.05, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 210479, + "watched": true, + "added": true, + "title": "Locke" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 11660, + "tvdb_id": 0, + "title": "Following", + "overview": "Bill, an idle, unemployed aspiring writer, walks the crowded streets of London following randomly chosen strangers, a seemingly innocent entertainment that becomes dangerous when he crosses paths with a mysterious character.", + "poster_path": "/3bX6VVSMf0dvzk5pMT4ALG5A92d.jpg", + "backdrop_path": "/yf5I5VHllK4h5VdbxHfQ40RBTe8.jpg", + "year": 1999, + "score": 0.04611010066393556, + "genre_ids": [ + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 65650, + "tvdb_id": 0, + "title": "The Good Doctor", + "overview": "Dr. Martin Blake, who has spent his life looking for respect, meets an 18-year-old patient named Diane, suffering from a kidney infection, and gets a much-needed boost of self-esteem. However, when her health starts improving, Martin fears losing her, so he begins tampering with her treatment, keeping Diane sick and in the hospital right next to him.", + "poster_path": "/fJSdqHzpMeCkXMrILYt1SdGN2sL.jpg", + "backdrop_path": "/jPlyFmmh9rHQqIuRtEv0gCsOVPK.jpg", + "year": 2011, + "score": 0.04172386354402945, + "genre_ids": [ + 18, + 53, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 241, + "tvdb_id": 0, + "title": "Natural Born Killers", + "overview": "Two victims of traumatized childhoods become lovers and serial murderers irresponsibly glorified by the mass media.", + "poster_path": "/fEKZwT91gxvkAoyPgpNXo8W5fu0.jpg", + "backdrop_path": "/k3TOt50cHvTLXWYPnXP0szhiXB8.jpg", + "year": 1994, + "score": 0.03824687491536033, + "genre_ids": [ + 80, + 53, + 18 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 645710, + "tvdb_id": 0, + "title": "The Voyeurs", + "overview": "When Pippa and Thomas move into their dream apartment, they notice that their windows look directly into the apartment opposite \u2013 inviting them to witness the volatile relationship of the attractive couple across the street. But what starts as a simple curiosity turns into full-blown obsession with increasingly dangerous consequences.", + "poster_path": "/8Y4XOIWhpOvSOEn8XrxbkH9yAXO.jpg", + "backdrop_path": "/5KGrBXvE0cYDwqfEaymvNBVspkJ.jpg", + "year": 2021, + "score": 0.03688808053114845, + "genre_ids": [ + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 9357, + "watched": true, + "added": false, + "title": "One Hour Photo" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 69668, + "tvdb_id": 0, + "title": "Dream House", + "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman and her children were murdered there, and the surviving husband is the town's prime suspect. With help from a neighbor who was close to the murdered family, Will pieces together a horrifying chain of events.", + "poster_path": "/uQFbnBEYguc6EWYKG0Hgq4LVdc2.jpg", + "backdrop_path": "/6eFU4wRgnL4YEvDagEianumeGpc.jpg", + "year": 2011, + "score": 0.03278303564173742, + "genre_ids": [ + 9648, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 33195, + "tvdb_id": 0, + "title": "Triage", + "overview": "The wife of a photojournalist sets out to discover why he came home from a recent assignment without his colleague.", + "poster_path": "/qH3QzgkqJYcTVs5QoH9SOE80B8q.jpg", + "backdrop_path": "/yLqD4mKGLR4pZRP42DxGqPlIG1t.jpg", + "year": 2009, + "score": 0.030597499932288264, + "genre_ids": [ + 18, + 53, + 9648 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 4547, + "tvdb_id": 0, + "title": "Panic Room", + "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with three intruders - Burnham, Raoul and Junior - during a brutal home invasion. But the room itself is the focal point because what the intruders really want is inside it.", + "poster_path": "/hANYbvfwxmkC9E4yY6YyJxYxlSJ.jpg", + "backdrop_path": "/gJfd2SzUsvY1q1iszthJ3ryuvEA.jpg", + "year": 2002, + "score": 0.028685156186520247, + "genre_ids": [ + 80, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + }, + { + "media_type": "movie", + "tmdb_id": 74998, + "tvdb_id": 0, + "title": "Seeking Justice", + "overview": "After his wife is assaulted, a husband enlists the services of a vigilante group to help him settle the score.", + "poster_path": "/fJCBxeeKLmxOG2TS4ewhalSb9ur.jpg", + "backdrop_path": "/3luwjhBo76a7swVxqfjcOh3e8tR.jpg", + "year": 2011, + "score": 0.02549791661024022, + "genre_ids": [ + 28, + 18, + 53 + ], + "contributions": [ + { + "media_type": "movie", + "tmdb_id": 399057, + "watched": false, + "added": true, + "title": "The Killing of a Sacred Deer" + } + ] + } + ], + "shows": [ + { + "media_type": "tv", + "tmdb_id": 73552, + "tvdb_id": 341235, + "title": "Heathers", + "overview": "A pitch-black comedy anthology set in the present day, as our heroine, Veronica Sawyer, deals with a very different but equally vicious group of Heathers. Based on the 1988 cult classic film.", + "poster_path": "/pbK7GvYCrkqUevhGLcNuyja3ngm.jpg", + "backdrop_path": "/bsazBvyJQlor6PvA5pS6X3mRWmX.jpg", + "year": 2018, + "score": 1.0824883110875794, + "genre_ids": [ + 35, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2327, + "tvdb_id": 72584, + "title": "Dawson's Creek", + "overview": "Dawson's Creek is an American teen drama that portrays the fictional lives of a close-knit group of teenagers through high school and college.", + "poster_path": "/arT93dBemanftfWTcf9I0JRIlxU.jpg", + "backdrop_path": "/ofiNBOL7gpuzqXEqWBprF90CtY7.jpg", + "year": 1998, + "score": 1.030931929109617, + "genre_ids": [ + 18, + 10766 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 578, + "tvdb_id": 73918, + "title": "Numb3rs", + "overview": "Inspired by actual cases and experiences, Numb3rs depicts the confluence of police work and mathematics in solving crime as an FBI agent recruits his mathematical genius brother to help solve a wide range of challenging crimes in Los Angeles from a very different perspective.", + "poster_path": "/wOzbxJmpccPA5MYvBSGbor3WKh3.jpg", + "backdrop_path": "/oYwaOeQmE0tuBKy8kt43NFNI4bE.jpg", + "year": 2005, + "score": 1, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 900, + "tvdb_id": 79773, + "title": "Skins", + "overview": "Irreverent comedy drama which follows the messy lives, loves, delirious highs and inevitable lows of a group of raucous teenage friends in Bristol.", + "poster_path": "/bf4L6bB9YOGyrPeLdlpz3FlMZQg.jpg", + "backdrop_path": "/saBnI34QTYTjG9sj8DsembO4PBK.jpg", + "year": 2007, + "score": 1, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 56570, + "tvdb_id": 270408, + "title": "Outlander", + "overview": "The story of Claire Randall, a married combat nurse from 1945 who is mysteriously swept back in time to 1743, where she is immediately thrown into an unknown world where her life is threatened. When she is forced to marry Jamie, a chivalrous and romantic young Scottish warrior, a passionate affair is ignited that tears Claire's heart between two vastly different men in two irreconcilable lives.", + "poster_path": "/oftZNfyTVNU7IfOqoGLoT8MGvNs.jpg", + "backdrop_path": "/nf3Vlxm3C9U1aKUUQHmKFZmxPSc.jpg", + "year": 2014, + "score": 1, + "genre_ids": [ + 18, + 10765 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62117, + "tvdb_id": 280706, + "title": "Younger", + "overview": "Liza Miller, a suddenly single stay-at-home mother, tries to get back into the working world, only to find it\u2019s nearly impossible to start at the bottom at 40-year old. When a chance encounter convinces her she looks younger than she is, Liza tries to pass herself off as 26 and lands a job as an assistant at Empirical Press. Now she just has to make sure no one finds out the secret only she and her best friend Maggie share.", + "poster_path": "/feWw6i09zaX2apzRqd7QbKEAsx9.jpg", + "backdrop_path": "/wCdhtaLMNtQ37j4OEch4dSKXyoE.jpg", + "year": 2015, + "score": 1, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 87428, + "tvdb_id": 365069, + "title": "Why Women Kill", + "overview": "Three women living in three different decades: a housewife in the '60s, a socialite in the '80s and a lawyer in 2018, deal with infidelity in their marriages.", + "poster_path": "/fI3E1G5N8oimMeyYn5UzWRT2cST.jpg", + "backdrop_path": "/61YttrsiP6B4sJo98HFMuHKPV3G.jpg", + "year": 2019, + "score": 1, + "genre_ids": [ + 35, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 88989, + "tvdb_id": 376815, + "title": "Nine Perfect Strangers", + "overview": "At a boutique health-and-wellness resort that promises healing and transformation, nine stressed city dwellers try to get on a path to a better way of living. Watching over them during this 10-day retreat is the resort's director, Masha, a woman on a mission to reinvigorate their tired minds and bodies. However, these nine \"perfect\" strangers have no idea what is about to hit them.", + "poster_path": "/oPcqsZIFHrv7HoGgFQnsGDiPulD.jpg", + "backdrop_path": "/RzCw9LQyAcoa9mn4xkEAyuvhgr.jpg", + "year": 2021, + "score": 1, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 106454, + "tvdb_id": 384495, + "title": "The Afterparty", + "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", + "poster_path": "/9iAxAxeX5RjB39ckhxPMDCJPpG9.jpg", + "backdrop_path": "/kbehRFMpW7VHsoLB0FM2wsdZsyd.jpg", + "year": 2022, + "score": 0.9711203427159524, + "genre_ids": [ + 9648, + 35, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 71199, + "tvdb_id": 321149, + "title": "Claws", + "overview": "Follow the life and crimes of five diverse and treacherous manicurists working at the Nail Artisan of Manatee County salon, where there is a lot more going on than silk wraps and pedicures.", + "poster_path": "/3XtQTSWYpmv9NpC9MJt96nJ9fbf.jpg", + "backdrop_path": "/ooCJReGvCw8UcHQeP5gmNFSInQp.jpg", + "year": 2017, + "score": 0.9248318554229322, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 100883, + "tvdb_id": 378746, + "title": "Never Have I Ever", + "overview": "After a traumatic year, all an Indian-American teen wants is to go from pariah to popular -- but friends, family and feeling won't make it easy on her.", + "poster_path": "/hd5fnBixab6IzfUwjC5wfdbX3eM.jpg", + "backdrop_path": "/umVYLVZ7T85TkIHudxK799lPnLQ.jpg", + "year": 2020, + "score": 0.8999999999999999, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 42282, + "tvdb_id": 220411, + "title": "Girls", + "overview": "The assorted humiliations, disasters and rare triumphs of four very different twenty-something girls: Hannah, an aspiring writer; Marnie, an art gallery assistant and cousins Jessa and Shoshanna.", + "poster_path": "/ikyT3hnELofnYRsPIqVGgUhDbOT.jpg", + "backdrop_path": "/9O2k1lfAMes2UgrWzHVvpMJHjw.jpg", + "year": 2012, + "score": 0.8666666666666667, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 243861, + "tvdb_id": 444846, + "title": "The Vince Staples Show", + "overview": "Kind of famous and sort of rich, rapper and actor Vince Staples navigates the challenges and surprises of everyday life in his hometown of The Beach.", + "poster_path": "/gyUKo6ILKVwuiVq4EWv1avEXpBp.jpg", + "backdrop_path": "/saDz8sl3IjuKpPgD3uqkzJ952tJ.jpg", + "year": 2024, + "score": 0.7377724130921047, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 94617, + "tvdb_id": 415488, + "title": "Grease: Rise of the Pink Ladies", + "overview": "In 1954, a time before rock \u2018n\u2019 roll ruled, before the T-Birds were the coolest in the school, four fed-up outcasts dare to have fun on their own terms, sparking a moral panic that will change Rydell High forever.", + "poster_path": "/dGu8u5F9Cjn9Ij2aMYtGmKHp5m1.jpg", + "backdrop_path": "/aWXGFJyhEvI1Y7NWk2A3oriHMfb.jpg", + "year": 2023, + "score": 0.6669665360344916, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62823, + "tvdb_id": 294417, + "title": "Scream: The TV Series", + "overview": "What starts as a YouTube video going viral, soon leads to problems for the teenagers of Lakewood and serves as the catalyst for a murder that opens up a window to the town's troubled past. Everyone has secrets. Everyone tells lies. Everyone is fair game.", + "poster_path": "/cb2WD75HjajBmFtPzfU5ukGqfeo.jpg", + "backdrop_path": "/xCXyhbr7T4vx82QUqtN8ZszYIqx.jpg", + "year": 2015, + "score": 0.666741634008623, + "genre_ids": [ + 18, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 18856, + "tvdb_id": 95741, + "title": "Hawthorne", + "overview": "Christina Hawthorne is a compassionate and headstrong Chief Nursing Officer heading up a group of dedicated nurses at Richmond Trinity Hospital on the hospital's front lines. She does everything in her power to help her patients. When necessary, she takes on doctors and administrators who are overworked, distracted or just unable to see the human being behind the hospital chart. But the long days at the hospital and Hawthorne\u2019s intense focus on helping others take a toll on her personal life.", + "poster_path": "/bZuFnSA7RtgEQJUBdHqbhtlbpmp.jpg", + "backdrop_path": "/rvlQ6u2ZR8phyOtQSmaOOP9pOPv.jpg", + "year": 2009, + "score": 0.6224984087250857, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1803, + "tvdb_id": 76130, + "title": "The Facts of Life", + "overview": "Mrs. Edna Garrett, housemother and dietitian at the Eastland School, teaches a group of girls in her charge how to solve those problems that every teenager has to face.", + "poster_path": "/45i9VujUbQITmHl3QE3eNs9ZUv3.jpg", + "backdrop_path": "/nxqRVJ3QP76CZBaunWl42hQVK0.jpg", + "year": 1979, + "score": 0.598852635575991, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 116450, + "tvdb_id": 394924, + "title": "And Just Like That\u2026", + "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life and friendship in their 50s.", + "poster_path": "/2b6E0pN19IGsmxSv6HyTKydKKdb.jpg", + "backdrop_path": "/sHvTsXSWxuW7hoXAqWM4lBLJI50.jpg", + "year": 2021, + "score": 0.5808878159517542, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 199001, + "tvdb_id": 405817, + "title": "My Life with the Walter Boys", + "overview": "When a tragedy disrupts her life, a teen moves in with her guardian's big family in a small town and learns new lessons about love, hope and friendship.", + "poster_path": "/dQOwpTpBQEqRUcev4423LrU32G6.jpg", + "backdrop_path": "/ntsEP1jUbLxz62MZqxmVfM5TNfX.jpg", + "year": 2023, + "score": 0.5398411880684697, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 64254, + "tvdb_id": 298901, + "title": "Master of None", + "overview": "30-year-old New York actor Dev takes on such pillars of maturity as the first big job, a serious relationship, and busting sex offenders on the subway.", + "poster_path": "/AcJM86PhgHAfbrF4dMKBaqO3cHV.jpg", + "backdrop_path": "/yYnxld9hgTLCOFORequmOK6paj2.jpg", + "year": 2015, + "score": 0.5, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 196454, + "tvdb_id": 425204, + "title": "Queen Charlotte: A Bridgerton Story", + "overview": "Young Queen Charlotte's marriage to King George of England sparks an epic love story and transforms high society in this \"Bridgerton\" universe prequel.", + "poster_path": "/kFSaVIlBRycGNfrLzJZX7dgSQ1o.jpg", + "backdrop_path": "/92R2R75gIiVo32dIm0JUaIytpij.jpg", + "year": 2023, + "score": 0.5, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 206829, + "tvdb_id": 422776, + "title": "The Regime", + "overview": "The story of one year within the walls of the palace of a modern European regime as it begins to unravel.", + "poster_path": "/ztqPixNyezY6pWSNIP2AlhwPMO0.jpg", + "backdrop_path": "/lp6Rsgid52BIlzYxWDURFSZZU6m.jpg", + "year": 2024, + "score": 0.5, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 99248, + "tvdb_id": 376879, + "title": "Survive", + "overview": "Jane's plane crashes on a remote snow-covered mountain and she, along with Paul, the only remaining survivors, must pull themselves out of the wreckage and fight for their lives. Together they embark on a harrowing journey out of the wilderness, battling brutal conditions and personal traumas.", + "poster_path": "/stere2OYFYoqj6LTGm0XXWUcKYy.jpg", + "backdrop_path": "/A9h3YUFbnQ7ReXwvnaQvJf5uNjC.jpg", + "year": 2020, + "score": 0.49493359594934455, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 124834, + "tvdb_id": 395494, + "title": "Heartstopper", + "overview": "Teens Charlie and Nick discover their unlikely friendship might be something more as they navigate school and young love.", + "poster_path": "/dQc0QbDiHjGmWxTfKtBgYtS4bj5.jpg", + "backdrop_path": "/8cpXau1LjYMBjiaHUS75JmlgGsU.jpg", + "year": 2022, + "score": 0.4949108657538713, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 7406, + "tvdb_id": 78289, + "title": "All Creatures Great and Small", + "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", + "poster_path": "/inl6wqdRPoLEDXttC33x9PBoMbN.jpg", + "backdrop_path": "/kqpOg91jYda1G3RU7MpjdHllAGy.jpg", + "year": 1978, + "score": 0.49490485366539394, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 69470, + "tvdb_id": 320450, + "title": "Santa Clarita Diet", + "overview": "They're ordinary husband and wife realtors until she undergoes a dramatic change that sends them down a road of death and destruction. In a good way.", + "poster_path": "/5rBjNm2sRubaXKBnU904NInFi7P.jpg", + "backdrop_path": "/zPCDfLwOEtEhddaT2xeO5ChErsV.jpg", + "year": 2017, + "score": 0.45706579996470237, + "genre_ids": [ + 35, + 9648, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62705, + "tvdb_id": 295729, + "title": "Married at First Sight", + "overview": "Working with leading relationship experts, eight Australian singles are carefully match-made into four married couples, who each meet each other - for the very first time - at their wedding. We'll follow them as they marry, honeymoon, meet the in-laws and set up home, all the while getting to know one another more and more deeply, to see if the matchmakers have got it right and they will have a future together.", + "poster_path": "/2F0ADv2beVB4tRG0qx7YlWMvnDN.jpg", + "backdrop_path": "/pLa2pcYnKA9xxWiWh1C08TXcDTY.jpg", + "year": 2015, + "score": 0.42500246169008526, + "genre_ids": [ + 10764 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 290750, + "watched": false, + "added": true, + "title": "Love is Blind: Netherlands" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 253376, + "tvdb_id": 449706, + "title": "The Girlfriend", + "overview": "Laura has it all: a glittering career, a loving husband, and her precious son, Daniel. But her perfect life unravels when Daniel brings home Cherry, a girlfriend who changes everything. After a tense introduction, Laura becomes convinced Cherry is hiding something. Is she a manipulative social climber, or is Laura just paranoid? The truth is a matter of perspective.", + "poster_path": "/ktrp3tdZ5IQpFux4nLy8z70bt3n.jpg", + "backdrop_path": "/lnVWmFvLv2pTZGsuAJytiqKu541.jpg", + "year": 2025, + "score": 0.41000601893271116, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + }, + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 39468, + "tvdb_id": 211681, + "title": "Mildred Pierce", + "overview": "Mildred Pierce depicts an overprotective, self-sacrificing mother during the Great Depression who finds herself separated from her husband, opening a restaurant of her own and falling in love with a man, all the while trying to earn her spoiled, narcissistic daughter's love and respect.", + "poster_path": "/eZ7TUocetldADAXZfnMSEVl7SbJ.jpg", + "backdrop_path": "/nYGVRE2VVJZ71gqsm1AYAjO0C1G.jpg", + "year": 2011, + "score": 0.407539273675682, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 126914, + "tvdb_id": 404298, + "title": "With Love", + "overview": "The Diaz siblings, Lily and Jorge, are on a mission to find love and purpose. They cross paths with seemingly unrelated residents during some of the most heightened days of the year\u2014the holidays.", + "poster_path": "/H7UoEyxRPKdwgdy4kt7UbvkAlX.jpg", + "backdrop_path": "/tlamrEuFPeXBjUNW4ynmJaAemV3.jpg", + "year": 2021, + "score": 0.4007003182322874, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 5721, + "tvdb_id": 82422, + "title": "The Secret Life of the American Teenager", + "overview": "Amy and her friends at Grant High learn to define themselves while they navigate the perilous waters of contemporary adolescence. Between their love triangles, secrets, drama, accusations, gossip, confusion, and scandalous rumors, there's never a dull moment.", + "poster_path": "/k2XTj9D0tzca3dSgvVojU9xk8F5.jpg", + "backdrop_path": "/bH9Sf6wzJqYeBzhOsqGXwcLSUgR.jpg", + "year": 2008, + "score": 0.38888888888888884, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 21762, + "tvdb_id": 94551, + "title": "Parenthood", + "overview": "The trials and tribulations of the very large, colorful and imperfect Braverman family.", + "poster_path": "/9KjKLJrrUI1sU9bNsQaQ5H8kTyY.jpg", + "backdrop_path": "/72zLfNDaL8JpfLDwmQ2b8DPbeHc.jpg", + "year": 2010, + "score": 0.3781500494089771, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 33155, + "watched": true, + "added": false, + "title": "Offspring" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 80587, + "tvdb_id": 342059, + "title": "Big City Greens", + "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", + "poster_path": "/buRk2Vrz8UhF5nZUOADSRZLEU1f.jpg", + "backdrop_path": "/vVGmv5mle6lCrgnx8lNv5Q1vZPw.jpg", + "year": 2018, + "score": 0.3781500494089771, + "genre_ids": [ + 10751, + 16, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 39859, + "tvdb_id": 249882, + "title": "Awkward.", + "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is blown way out of proportion. Narration in the first-person voice of Jenna's blog posts captures the humor within the struggles and experiences everyone can relate to from their formative years.", + "poster_path": "/7PLO6PdGmfKiV3KER2Rd08Ga8kM.jpg", + "backdrop_path": "/yFFW6MtYRjBHppYWskmG81pR7xV.jpg", + "year": 2011, + "score": 0.37155333461369744, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 210805, + "tvdb_id": 414550, + "title": "Tyler Perry's Zatima", + "overview": "Zac and Fatima take a huge step to strengthen their bond, but new friends and past actions interfere with their blossoming relationship.", + "poster_path": "/mccoy3vzuTHucJXkBL27SqcyAvv.jpg", + "backdrop_path": "/6Vtd3EMdOuoT3GIUKDbkDzSFp0Q.jpg", + "year": 2022, + "score": 0.36888620654605236, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4177, + "tvdb_id": 70589, + "title": "Perry Mason", + "overview": "The cases of master criminal defense attorney Perry Mason and his staff who handled the most difficult of cases in the aid of the innocent.", + "poster_path": "/sK6JvgNFP1ClT4aF4nPAZiDpAdk.jpg", + "backdrop_path": "/iq4BjyVsDxzg24XnjoGP0Xi2Tz5.jpg", + "year": 1957, + "score": 0.3333333333333333, + "genre_ids": [ + 9648, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 46028, + "tvdb_id": 267777, + "title": "Girl Meets World", + "overview": "The adventures of relatable and adventurous Riley Matthews, the tween daughter of Cory and Topanga Matthews, and her bold best friend Maya as they traverse the twists and turns of teenage years at Manhattan's John Quincy Adams Middle School where Riley's dad is their History teacher.", + "poster_path": "/8E1i8j6GVT7wMzw8CtqzifRpr5R.jpg", + "backdrop_path": "/pJvLprQwTVEOSBPs25y4rM214ch.jpg", + "year": 2014, + "score": 0.3333333333333333, + "genre_ids": [ + 35, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 92621, + "tvdb_id": 351960, + "title": "Brassic", + "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and stealing is catching up with them and a whole load of trouble is heading their way.", + "poster_path": "/tnIq3yiLMZJpaJyRs8FNV7WJs6V.jpg", + "backdrop_path": "/hEJE1WsZZqjJsq0Ltpom5qZzWEE.jpg", + "year": 2019, + "score": 0.325, + "genre_ids": [ + 35, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1395, + "tvdb_id": 80547, + "title": "Gossip Girl", + "overview": "An exclusive group of privileged teens from a posh prep school on Manhattan's Upper East Side whose lives revolve around the blog of the all-knowing albeit ultra-secretive Gossip Girl.", + "poster_path": "/ep03M6KKZGeljOizPEbt3Geja5n.jpg", + "backdrop_path": "/5RWAjCYhu3D8Ai2QBYP7GjaxFb3.jpg", + "year": 2007, + "score": 0.3166966536034492, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 6127, + "tvdb_id": 78895, + "title": "Agatha Christie's Marple", + "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon mysterious murders which she helps solve. Although the police are sometimes reluctant to accept Miss Marple's help, her reputation and unparalleled powers of observation eventually win them over.", + "poster_path": "/nVswrZQpqQND9TgwgbWgz18KgAo.jpg", + "backdrop_path": "/1lBEfsnzUkxtxzvFwimENpM6bEP.jpg", + "year": 2004, + "score": 0.31224984087250857, + "genre_ids": [ + 18, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + }, + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1021, + "tvdb_id": 71756, + "title": "Casualty", + "overview": "Drama series about the staff and patients at Holby City Hospital's emergency department, charting the ups and downs in their personal and professional lives.", + "poster_path": "/ahqMUNIUeGIBQSkShEV32085IFZ.jpg", + "backdrop_path": "/wnyMAP1Xep5BUwTqMq2KBHjRHws.jpg", + "year": 1986, + "score": 0.31124920436254283, + "genre_ids": [ + 18, + 10766 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 211, + "tvdb_id": 79636, + "title": "The Game", + "overview": "When her boyfriend Derwin Davis is chosen as the new third-string wide receiver for the San Diego Sabers, Melanie Barnett decides to attend a local college so she can be with him. While Derwin worries about the plays on the field, Melanie adjusts to her new lifestyle. She gets a play-by-play account of the lives and relationships among NFL wives, girlfriends and mom/managers who use their best game to help their men stay on the field and on their arm.", + "poster_path": "/zmvRaCRcmi6lsriYxZmAR6uWHPS.jpg", + "backdrop_path": "/lW3RCsfXXVGIBXyupMGT7dAYcze.jpg", + "year": 2006, + "score": 0.30110976993969285, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 61118, + "tvdb_id": 281776, + "title": "You're the Worst", + "overview": "Narcissistic, brash, and self-destructive Jimmy Shive-Overly thinks all relationships are doomed. Cynical, people-pleasing, and stubborn Gretchen Cutler knows that relationships aren't for her. So when they meet at a wedding, it's only natural that the two of them go home together.", + "poster_path": "/whXfP89yXz4AwQ9dzWCTGcl9YvV.jpg", + "backdrop_path": "/3gqDstdBLUbJ2jY3hl953iS97D4.jpg", + "year": 2014, + "score": 0.3005952380952381, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 62704, + "tvdb_id": 281714, + "title": "Ballers", + "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the daily grind of the business of football.", + "poster_path": "/WyIazPyjmcFMC42nWEpI0jr56x.jpg", + "backdrop_path": "/gyPWbM1lq7Ufd9cENSl5Phsrb0a.jpg", + "year": 2015, + "score": 0.29592413769736825, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 39525, + "tvdb_id": 239761, + "title": "Wilfred", + "overview": "Everyone else sees Wilfred as just a dog, but Ryan sees a crude and somewhat surly, yet irrepressibly brave and honest Australian bloke in a cheap dog suit. While leading him through a series of comedic and existential adventures, Wilfred the dog shows Ryan the man how to overcome his fears and joyfully embrace the unpredictability and insanity of the world around him.", + "poster_path": "/2cUYCkmHEWsxaNiVTQnFqxyq5Ij.jpg", + "backdrop_path": "/f43EKMX1QHJhiieDfhRgh5M1fZj.jpg", + "year": 2011, + "score": 0.2920702193720095, + "genre_ids": [ + 35, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 111838, + "tvdb_id": 390537, + "title": "The Woman in the House Across the Street from the Girl in the Window", + "overview": "For Anna, every day is the same. She sits with her wine, staring out the window, watching life go by without her. But when a handsome neighbor moves in across the street, Anna starts to see a light at the end of the tunnel. That is until she witnesses a gruesome murder\u2026 Or did she?", + "poster_path": "/riSoMsWYkwqed4ul5PgHOnbIVFp.jpg", + "backdrop_path": "/7I81Iey8vEk6IOfSXqulSckcCsZ.jpg", + "year": 2022, + "score": 0.2656526399717619, + "genre_ids": [ + 9648, + 80, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 124010, + "tvdb_id": 401513, + "title": "How I Met Your Father", + "overview": "In the near future, Sophie tells her son the story of how she met his father: a story that catapults us back to the year 2021 where Sophie and her close-knit group of friends are in the midst of figuring out who they are, what they want out of life, and how to fall in love in the age of dating apps and limitless options.", + "poster_path": "/8psLGtFzTMYCXxUNMpMOCB6doPn.jpg", + "backdrop_path": "/40JJpWnIkxMpKGHCjt9uE6kzJHU.jpg", + "year": 2022, + "score": 0.255580544669541, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 275139, + "tvdb_id": 455844, + "title": "Phony", + "overview": "After waking up from a freak car accident, Sonny discovers that his mother has been replaced by an impostor.", + "poster_path": "/iCyQc8cSSHkxUHbkvzbC77owALt.jpg", + "backdrop_path": "/roUD5m2nj6k88azMFsiqBae29cr.jpg", + "year": 2026, + "score": 0.25263157894736843, + "genre_ids": [ + 35, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 69050, + "tvdb_id": 311954, + "title": "Riverdale", + "overview": "Set in the present, the series offers a bold, subversive take on Archie, Betty, Veronica and their friends, exploring the surreality of small-town life, the darkness and weirdness bubbling beneath Riverdale\u2019s wholesome facade.", + "poster_path": "/d8mmn9thQ5dBk2qbv6BCqGUXWK3.jpg", + "backdrop_path": "/kb9XvtEnDhBVVit4eeKygqll4EA.jpg", + "year": 2017, + "score": 0.25, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 70796, + "tvdb_id": 326791, + "title": "The Marvelous Mrs. Maisel", + "overview": "It\u2019s 1958 Manhattan and Miriam \u201cMidge\u201d Maisel has everything she\u2019s ever wanted - the perfect husband, kids, and Upper West Side apartment. But when her life suddenly takes a turn and Midge must start over, she discovers a previously unknown talent - one that will take her all the way from the comedy clubs of Greenwich Village to a spot on Johnny Carson\u2019s couch.", + "poster_path": "/zS7fQiOZiKCVH2vlYSiIsFWW8hh.jpg", + "backdrop_path": "/vQ77kC1amsZECKIxkUIsMJCtBVp.jpg", + "year": 2017, + "score": 0.25, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 135959, + "tvdb_id": 411173, + "title": "That '90s Show", + "overview": "Hello, Wisconsin! It's 1995 and Leia Forman, daughter of Eric and Donna, is visiting her grandparents for the summer where she bonds with a new generation of Point Place kids under the watchful eye of Kitty and the stern glare of Red. Sex, drugs and rock 'n roll never dies, it just changes clothes.", + "poster_path": "/nZcufEuqZqNYMx6mNjlh2rmjDqx.jpg", + "backdrop_path": "/23WUgvjfnQFNwSPSSG9CdGFnauN.jpg", + "year": 2023, + "score": 0.25, + "genre_ids": [ + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 241372, + "tvdb_id": 443312, + "title": "56 Days", + "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", + "poster_path": "/sdJLbucggwGj8NdX0UN2zt1Vwmn.jpg", + "backdrop_path": "/dnEljhBkw53ra7tEl2JNKGrI0wk.jpg", + "year": 2026, + "score": 0.25, + "genre_ids": [ + 18, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 250504, + "tvdb_id": 448245, + "title": "The Beast in Me", + "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", + "poster_path": "/jxf7JCfIMvGONWTNUIJ068sFixK.jpg", + "backdrop_path": "/ylW0m5UztyyL4YFu8EC6usUHLF.jpg", + "year": 2025, + "score": 0.25, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 258190, + "tvdb_id": 451659, + "title": "Coldwater", + "overview": "A man in the midst of a mid-life crisis moves his family to a remote Scottish village, only to find himself entangled in a dangerous friendship with a sinister neighbour.", + "poster_path": "/oguBhjxw7hwwCyq2G4ujFRy6kbX.jpg", + "backdrop_path": "/yufA0LfNVYYFApA07cgs3V45yJK.jpg", + "year": 2025, + "score": 0.25, + "genre_ids": [ + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 120998, + "tvdb_id": 398822, + "title": "Poker Face", + "overview": "Follow Charlie Cale, a woman with an extraordinary ability to tell when someone is lying, as she hits the road and, at every stop, encounters a new cast of characters and crimes she can't help but solve.", + "poster_path": "/8Xm5dyMQC9whMJKnGdFugUAW73C.jpg", + "backdrop_path": "/ccOzCmrglAjRGtqv5ClTaBEsWt8.jpg", + "year": 2023, + "score": 0.2474649332627381, + "genre_ids": [ + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4454, + "tvdb_id": 71814, + "title": "Will & Grace", + "overview": "Will Truman and Grace Adler are best friends living in New York, and when Grace's engagement falls apart, she moves in with Will. Together, along with their friends, they go through the trials of dating, sex, relationships and their careers, butting heads at times but ultimately supporting one another while exchanging plenty of witty banter along the way.", + "poster_path": "/aEaYdyHcpHN3fIXHhXHtXOs8Rtk.jpg", + "backdrop_path": "/pIPMPMEZErCxTvQG9HDt4AsnAfn.jpg", + "year": 1998, + "score": 0.24745543287693564, + "genre_ids": [ + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2585, + "tvdb_id": 74684, + "title": "Heartbeat", + "overview": "Set during the 1960s in the fictional North Yorkshire village of Aidensfield, this enduringly popular series interweaves crime and medical storylines.", + "poster_path": "/naMIm2WuIFcBk2yNWAQcRAhZFyU.jpg", + "backdrop_path": "/dRVToyhldkgjzGcrhPlZf4IuK0y.jpg", + "year": 1992, + "score": 0.24745242683269697, + "genre_ids": [ + 80, + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 269, + "tvdb_id": 72158, + "title": "One Tree Hill", + "overview": "In Tree Hill, North Carolina two half brothers share a last name and nothing else. Brooding, blue-collar Lucas is a talented street-side basketball player, but his skills are appreciated only by his friends at the river court. Popular, affluent Nathan basks in the hero-worship of the town, as the star of his high school team. And both boys are the son of former college ball player Dan Scott whose long ago choice to abandon Lucas and his mother Karen, will haunt him long into his life with wife Deb and their son Nathan.", + "poster_path": "/sOrelBaAhp7DZbPTivDwKEyPslC.jpg", + "backdrop_path": "/gJOpHgHa37qnglH3dL9g63iztky.jpg", + "year": 2003, + "score": 0.22683789670264065, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 95004, + "tvdb_id": 367658, + "title": "Tyler Perry's The Oval", + "overview": "A seemingly perfect interracial first family becomes the White House's newest residents. But behind closed doors they unleash a torrent of lies, cheating and corruption.", + "poster_path": "/l0u0L1ekz6VVBvbZDWM1XizNEes.jpg", + "backdrop_path": "/LdcSeybOK0xoWTkDjyy1P49KvA.jpg", + "year": 2019, + "score": 0.22345230192348647, + "genre_ids": [ + 18, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 33155, + "watched": true, + "added": false, + "title": "Offspring" + }, + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4107, + "tvdb_id": 74112, + "title": "The Steve Harvey Show", + "overview": "Down on his luck and out of money, former R&B star Steve Hightower lands a music teacher gig at an inner-city Chicago school. Showing who's in charge with his unorthodox approach, Steve discovers a new groove at Washington High School.", + "poster_path": "/iGUWv21kgKc79bpYxImk2nTIPin.jpg", + "backdrop_path": "/2y3q6BzERpZRZrutdKrMHrBnbDb.jpg", + "year": 1996, + "score": 0.22232217867816387, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 70785, + "tvdb_id": 322971, + "title": "Anne with an E", + "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s, who, after an abusive childhood spent in orphanages and the homes of strangers, is mistakenly sent to live with an elderly woman and her aging brother. Over time, 13-year-old Anne will transform their lives and eventually the small town in which they live with her unique spirit, fierce intellect and brilliant imagination.", + "poster_path": "/6P6tXhjT5tK3qOXzxF9OMLlG7iz.jpg", + "backdrop_path": "/ywQtHG17LZhAFZyZtBflhtFMtJ7.jpg", + "year": 2017, + "score": 0.21646710032483785, + "genre_ids": [ + 18, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + }, + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + }, + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4607, + "tvdb_id": 73739, + "title": "Lost", + "overview": "Stripped of everything, the survivors of a horrific plane crash must work together to stay alive. But the island holds many secrets.", + "poster_path": "/og6S0aTZU6YUJAbqxeKjCa3kY1E.jpg", + "backdrop_path": "/yUOFocKDW7MCC5isx4FK8A68QFp.jpg", + "year": 2004, + "score": 0.2149778653164482, + "genre_ids": [ + 9648, + 10759, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 73319, + "tvdb_id": 332747, + "title": "90 Day Fianc\u00e9: Before the 90 Days", + "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an in-person relationship and start the K-1 visa process.", + "poster_path": "/miG0k1MGyLovjKETzhqbKs9kIdt.jpg", + "backdrop_path": "/5cq5nn8L9wxK4GBPfZU7KnTnrFE.jpg", + "year": 2017, + "score": 0.21250123084504263, + "genre_ids": [ + 10764 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 290750, + "watched": false, + "added": true, + "title": "Love is Blind: Netherlands" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2398, + "tvdb_id": 74631, + "title": "Girlfriends", + "overview": "A friend group of black women face life's tests and triumphs together. From dating to divorce, and friends to family to relationships, Joan, Maya, Lynn and Toni support each other despite their differing backgrounds.", + "poster_path": "/buFnsyWEg4pPR7dco5Vbsm6HCIY.jpg", + "backdrop_path": "/lJEItmoc7bdqvA0yS5quuBIwW0N.jpg", + "year": 2000, + "score": 0.20647286761979436, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + }, + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 186, + "tvdb_id": 74845, + "title": "Weeds", + "overview": "After the unexpected death of her husband, a suburban mom resorts to selling weed to support her family.", + "poster_path": "/gKUsBTa5b0GY7U4iWBseeBalfjO.jpg", + "backdrop_path": "/6k6vAUa0Fm73HB0QBOJbFGTgPq5.jpg", + "year": 2005, + "score": 0.20535714285714285, + "genre_ids": [ + 35, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 31917, + "tvdb_id": 146711, + "title": "Pretty Little Liars", + "overview": "Based on the Pretty Little Liars series of young adult novels by Sara Shepard, the series follows the lives of four girls \u2014 Spencer, Hanna, Aria, and Emily \u2014 whose clique falls apart after the disappearance of their queen bee, Alison. One year later, they begin receiving messages from someone using the name \"A\" who threatens to expose their secrets \u2014 including long-hidden ones they thought only Alison knew.", + "poster_path": "/aUPbHiLS3hCHKjtLsncFa9g0viV.jpg", + "backdrop_path": "/6NuvpA24GbWrnkKsYrNyr2RiRbp.jpg", + "year": 2010, + "score": 0.20473848173969317, + "genre_ids": [ + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + }, + { + "media_type": "tv", + "tmdb_id": 117488, + "watched": false, + "added": true, + "title": "Yellowjackets" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 88324, + "tvdb_id": 371972, + "title": "Virgin River", + "overview": "After seeing an ad for a midwife, a recently divorced big-city nurse moves to the redwood forests of California, where she meets an intriguing man.", + "poster_path": "/oHEqfmSWR4Ex4V4AJyUn8HGHHnt.jpg", + "backdrop_path": "/m1nuMHBCiNy6pMGzRAdH5frNesN.jpg", + "year": 2019, + "score": 0.203769636837841, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 4582, + "tvdb_id": 78677, + "title": "Ironside", + "overview": "When an assassin's bullet confines him to a wheelchair for life ending his career as Chief of Detectives, Robert T. Ironside becomes a consultant to the police department. Detective Sergeant Ed Brown and policewoman Eve Whitfield join with him to crack varied and fascinating cases. Ex-con Mark Sanger is employed by the chief as home help but eventually becomes a fully fledged member of the team also. Officer Whitfield leaves after 4 years service, and is replaced by Officer Fran Belding.", + "poster_path": "/Fu9Sv0pQ0s89uWwwXcaG0kiNW7.jpg", + "backdrop_path": "/7lYBvjfgrguOUgnS9NrpysHtaCq.jpg", + "year": 1967, + "score": 0.2, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 67697, + "tvdb_id": 309523, + "title": "Search Party", + "overview": "A dark comedy about four self-absorbed twenty-somethings who become entangled in an ominous mystery when a former college acquaintance suddenly disappears.", + "poster_path": "/8ldHjc3Hx29NKK0bfgaYZkIwQWN.jpg", + "backdrop_path": "/kK2bP2ZWF2up3Y2uzBJupHCaMos.jpg", + "year": 2016, + "score": 0.2, + "genre_ids": [ + 35, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 130842, + "tvdb_id": 393813, + "title": "The Newsreader", + "overview": "An unconventional relationship in a world on the cusp of change; a star newsreader and an ambitious bisexual reporter join forces in a ruthless 1986 newsroom, as events unfold that will change their lives.", + "poster_path": "/aHl6HgHcFLfe68E7uiq8NRDCXOv.jpg", + "backdrop_path": "/aGLAJddCYwKhnGZeJdtOEhBGYsr.jpg", + "year": 2021, + "score": 0.2, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 153723, + "tvdb_id": 396645, + "title": "All American: Homecoming", + "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", + "poster_path": "/97988mOprohvBB61LgdsSdeMGa3.jpg", + "backdrop_path": "/9An9RjsIGLnRmZDbeUVCWDUcuWB.jpg", + "year": 2022, + "score": 0.2, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 41875, + "tvdb_id": 251405, + "title": "Chemistry", + "overview": "The scandalous lives of Liz and Michael, a cop and a lawyer who meet in a near-fatal coincidence. Drama and comedy with inevitable and powerful chemistry between a man and woman that completely alter a destined life plan.", + "poster_path": "/6YL87vk4mdRv2o0v04X6FjSd0XO.jpg", + "backdrop_path": "/4bUFGOe6AxBqBPx6rNMsWVJeKsW.jpg", + "year": 2011, + "score": 0.1875, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + }, + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 60705, + "tvdb_id": 277169, + "title": "Faking It", + "overview": "A romantic comedy about two best friends who love each other -- in slightly different ways. After numerous failed attempts to become popular, the girls are mistakenly outed as lesbians, which launches them to instant celebrity status. Seduced by their newfound fame, Karma and Amy decide to keep up their romantic ruse.", + "poster_path": "/vb9J7q977HB3AmvLETpmWIyIyTP.jpg", + "backdrop_path": "/nIMog8nl1FB2uE9NvbL7YOKCcP3.jpg", + "year": 2014, + "score": 0.18436103789614888, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 236235, + "tvdb_id": 426845, + "title": "The Gentlemen", + "overview": "When aristocratic Eddie inherits the family estate, he discovers that it's home to an enormous weed empire \u2014 and its proprietors aren't going anywhere.", + "poster_path": "/tw3tzfXaSpmUZIB8ZNqNEGzMBCy.jpg", + "backdrop_path": "/yG1wltFmkX5c5ocACKfpX0tp3SY.jpg", + "year": 2024, + "score": 0.17692307692307693, + "genre_ids": [ + 35, + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 81357, + "tvdb_id": 361857, + "title": "Dead to Me", + "overview": "A hotheaded widow searching for the hit-and-run driver who mowed down her husband befriends an eccentric optimist who isn't quite what she seems.", + "poster_path": "/mAj18PHeACxqcrEus4pD8pksRmB.jpg", + "backdrop_path": "/j4TXQ7OJsLx1Ba6z8XA7is0DcRZ.jpg", + "year": 2019, + "score": 0.16783216783216784, + "genre_ids": [ + 18, + 35, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 117581, + "watched": true, + "added": false, + "title": "Ginny & Georgia" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 1088, + "tvdb_id": 71436, + "title": "Dream On", + "overview": "The family life, romantic life, and career of Martin Tupper, a divorced New York City book editor. The show distinctively interjected clips from older black and white television series to punctuate Tupper's feelings or thoughts.", + "poster_path": "/8aiG4OUBXj5yMFrBIpYIXMtxCJv.jpg", + "backdrop_path": "/oLrKqDLFdAdbNe2KurNBDDtrE6d.jpg", + "year": 1990, + "score": 0.16666666666666666, + "genre_ids": [ + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 3004, + "tvdb_id": 76896, + "title": "Newhart", + "overview": "Dick Loudon and his wife Joanna decide to leave life in New York City and buy a little inn in Vermont. Dick is a how-to book writer, who eventually becomes a local TV celebrity as host of \"Vermont Today.\" George Utley is the handyman at the inn and Leslie Vanderkellen is the maid, with ambitions of being an Olympic Ski champion; she is later replaced by her cousin Stephanie, an heiress who hates her job. Her boyfriend is Dick's yuppie TV producer, Michael Harris. There are many other quirky characters in this fictional little town, including Dick's neighbors Larry, Darryl, and Darryl...three brothers who buy the Minuteman Cafe from Kirk Devane. Besides sharing a name, Darryl and Darryl never speak.", + "poster_path": "/zBI5YcdsftGE9F6zY6UwYVybsdY.jpg", + "backdrop_path": "/pCUfyiLZ1bT7JkffXZdDT0PgbGz.jpg", + "year": 1982, + "score": 0.16666666666666666, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 3216, + "tvdb_id": 71928, + "title": "Baretta", + "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New Jersey police officer David Toma. While popular, Toma received intense criticism at the time for its realistic and frequent depiction of police and criminal violence. When Musante left the series after a single season, the concept was retooled as Baretta, with Robert Blake in the title role.", + "poster_path": "/xayGSRT2GbcFTwNIqt6l355v29F.jpg", + "backdrop_path": "/wixjwQv8aPfIwbc7euj0fPPsMjT.jpg", + "year": 1975, + "score": 0.16666666666666666, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 80213, + "tvdb_id": 349661, + "title": "The Purge", + "overview": "Set in a dystopian America ruled by a totalitarian political party, the series follows several seemingly unrelated characters living in a small city. Tying them all together is a mysterious savior who\u2019s impeccably equipped for everything the night throws at them. As the clock winds down with their fates hanging in the balance, each character is forced to reckon with their pasts as they discover how far they will go to survive the night.", + "poster_path": "/9CaS2XFd0Db42grzzVBnWcSkrbg.jpg", + "backdrop_path": "/9PARvL1FQtyjNhLF6AT6Oanqg1U.jpg", + "year": 2018, + "score": 0.16666666666666666, + "genre_ids": [ + 9648, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 81358, + "tvdb_id": 349743, + "title": "Mr Inbetween", + "overview": "Ray Shoesmith is a father, ex-husband, boyfriend and best friend: tough roles to juggle in the modern age. Even harder when you\u2019re a criminal for hire.", + "poster_path": "/1FkONly4j7hxi6Pz5CmuhRN1Ops.jpg", + "backdrop_path": "/2INMEKxee4z0mVYpHCS4AdPxgOb.jpg", + "year": 2018, + "score": 0.16666666666666666, + "genre_ids": [ + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 153312, + "tvdb_id": 413215, + "title": "Tulsa King", + "overview": "New York mafia capo Dwight \"The General\" Manfredi is released from prison after 25 years and exiled by his boss to set up shop in Tulsa, Oklahoma. Realizing that his mob family may not have his best interests in mind, Dwight slowly builds a crew.", + "poster_path": "/y13WOeZ2rz2Ezpws0RJ0I8sQpG4.jpg", + "backdrop_path": "/mNHRGO1gFpR2CYZdANe72kcKq7G.jpg", + "year": 2022, + "score": 0.16666666666666666, + "genre_ids": [ + 80, + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 111803, + "watched": true, + "added": true, + "title": "The White Lotus" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 242054, + "tvdb_id": 417552, + "title": "The Residence", + "overview": "A brilliant, eccentric detective must solve a murder in the White House residence \u2014 where the staff and guests at a state dinner are all suspects.", + "poster_path": "/cJZbg425I9LF3SlJVQDOg4nJEvq.jpg", + "backdrop_path": "/rhwN3mIN89zlDveR0gv0VkrspXa.jpg", + "year": 2025, + "score": 0.16497662217515874, + "genre_ids": [ + 35, + 80, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 107113, + "watched": false, + "added": true, + "title": "Only Murders in the Building" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 749, + "tvdb_id": 77152, + "title": "Cold Feet", + "overview": "Follow the lives and loves of a group of thirtysomethings in a heart-warming comic drama as they try to find true love - or at least keep their relationships on track.\u00a0In this wry and funny look at a generation which is as confused as it is liberated by the choices it faces, will the chill in their feet put out the passion that burns in their hearts?", + "poster_path": "/mSPjuYGUpxr3bwP2VB97Ry0oJA0.jpg", + "backdrop_path": "/u6VRUfHyrh4tsIwucbs39P7HghH.jpg", + "year": 1998, + "score": 0.16497028858462376, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2215, + "tvdb_id": 77589, + "title": "Monarch of the Glen", + "overview": "Archie MacDonald, a young restaurateur is called back to his childhood home of Glenbogle where he is told he is the new Laird of Glenbogle.", + "poster_path": "/jeMhROvJWKTQuwSip5z5RsNPyfU.jpg", + "backdrop_path": "/ecxni5kM5664x1cy4Hdi2WB1Zdy.jpg", + "year": 2000, + "score": 0.16496828455513132, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 108255, + "watched": false, + "added": true, + "title": "All Creatures Great & Small (2020)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 87049, + "tvdb_id": 384248, + "title": "Firefly Lane", + "overview": "For decades, childhood best friends Kate and Tully have weathered life's storms together -- until a betrayal threatens to break them apart for good.", + "poster_path": "/2AavbPEEXzyy7Smyusozyz48T1n.jpg", + "backdrop_path": "/zvdnqNYYjW1NvULZbToh50pdts4.jpg", + "year": 2021, + "score": 0.15744037397447605, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 76148, + "watched": true, + "added": true, + "title": "Derry Girls" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 95386, + "tvdb_id": 372160, + "title": "Nurses", + "overview": "Follow five young nurses working on the frontlines of a busy hospital in downtown Toronto, dedicating their lives to helping others, while struggling to help themselves.", + "poster_path": "/hBhiT42BLVuWwl0B79zYA5bJ0ES.jpg", + "backdrop_path": "/tqhsaVtN3zvmCDxut1DvI2sq5pe.jpg", + "year": 2020, + "score": 0.15562460218127142, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 39793, + "watched": true, + "added": false, + "title": "Call the Midwife" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 136044, + "tvdb_id": 378995, + "title": "The Outlaws", + "overview": "Seven strangers from different walks of life - people who would never normally interact - are forced to work together to renovate a derelict community centre. They resent the menial physical labour and they resent each other. But when one of their number gets dragged into a dangerous world of organised crime, they unite in ways none of them thought possible.", + "poster_path": "/s8vp0S0iz2nPb7TpOpV0rHbm1er.jpg", + "backdrop_path": "/qQcI42QcZk9Eo4IqexsUwaRhusx.jpg", + "year": 2021, + "score": 0.14766081499309897, + "genre_ids": [ + 35, + 80, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 65315, + "watched": true, + "added": false, + "title": "Flaked" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 195670, + "tvdb_id": 412285, + "title": "XO, Kitty", + "overview": "Teen matchmaker Kitty Song Covey thinks she knows everything there is to know about love. But when she moves halfway across the world to reunite with her long-distance boyfriend, she'll soon realise that relationships are a lot more complicated when it's your heart on the line.", + "poster_path": "/hxvTdKAwv27PUfpXOQp6AwWr6V.jpg", + "backdrop_path": "/h0CQ36rm9cSLznve5ocCDtYlnwN.jpg", + "year": 2023, + "score": 0.1466226400103808, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + }, + { + "media_type": "tv", + "tmdb_id": 202851, + "watched": false, + "added": true, + "title": "Big Boys" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 102904, + "tvdb_id": 381065, + "title": "Sweet Magnolias", + "overview": "Lifelong friends Maddie, Helen and Dana Sue lift each other up as they juggle relationships, family and careers in the small Southern town of Serenity.", + "poster_path": "/n1WDSFOnCCg3cdHHGBywZtYIzf9.jpg", + "backdrop_path": "/wt60Q4doM2vrubiCsutwaeWncLL.jpg", + "year": 2020, + "score": 0.14528214151952024, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + }, + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 45194, + "tvdb_id": 77570, + "title": "Dragnet", + "overview": "Police Detective Sgt. Joe Friday and his partners investigate crimes in Los Angeles.", + "poster_path": "/tvozcbojiCAHIFar7kdaGfwSWew.jpg", + "backdrop_path": "/k7w3VSy1F4pW5HeoZjk2wn73AXS.jpg", + "year": 1967, + "score": 0.14285714285714285, + "genre_ids": [ + 80, + 18, + 9648 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 226637, + "watched": true, + "added": false, + "title": "High Potential" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 69630, + "tvdb_id": 323385, + "title": "The Bold Type", + "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", + "poster_path": "/cKloQpl6zE7kdFf8SUeYU2qPT9H.jpg", + "backdrop_path": "/ppBiYWnUn2STU8YUdlct4Klfyu4.jpg", + "year": 2017, + "score": 0.14285714285714285, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 105, + "watched": true, + "added": false, + "title": "Sex and the City" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 91239, + "tvdb_id": 366668, + "title": "Bridgerton", + "overview": "Wealth, lust, and betrayal set in the backdrop of Regency era England, seen through the eyes of the powerful Bridgerton family.", + "poster_path": "/uXTg565ahu9RwonCX1V2Hex1NU6.jpg", + "backdrop_path": "/6umsRLI7t0ydFwCl0JNEIO0q2LH.jpg", + "year": 2020, + "score": 0.14285714285714285, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 207484, + "watched": true, + "added": false, + "title": "Outlander: Blood of My Blood" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 100412, + "tvdb_id": 376698, + "title": "The Baby-Sitters Club", + "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", + "poster_path": "/afyG6cPl0jeV3SwNwyZxRG0Q9wd.jpg", + "backdrop_path": "/lhk5vgXN4Mnz4YWDw5RDqTaqPLd.jpg", + "year": 2020, + "score": 0.14285714285714285, + "genre_ids": [ + 10762, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 4586, + "watched": true, + "added": false, + "title": "Gilmore Girls" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 284558, + "tvdb_id": 460247, + "title": "Kill Jackie", + "overview": "Jackie Price has been living a wealthy, luxurious life for the past 20 years after escaping a dangerous past as an international cocaine dealer. But just as life begins to feel a little boring, she discovers that The Seven Demons, a group of the world\u2019s most terrifying hitmen, have been hired to kill her.", + "poster_path": "/3tjZspkqquE24iK1rc0nvnJwdEZ.jpg", + "backdrop_path": "/twc2YYBXTjdv52TShVumB1voT3Q.jpg", + "year": 2026, + "score": 0.14285714285714285, + "genre_ids": [ + 18, + 80 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 90046, + "tvdb_id": 363769, + "title": "90 Day Fianc\u00e9: The Other Way", + "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", + "poster_path": "/jw4KDjNmpBnMd7fEjbz9qTUmKJS.jpg", + "backdrop_path": "/9jmxvWOt5xTjrJ03FNmViZCuZ5f.jpg", + "year": 2019, + "score": 0.14166748723002842, + "genre_ids": [ + 10764 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 290750, + "watched": false, + "added": true, + "title": "Love is Blind: Netherlands" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 259702, + "tvdb_id": 452532, + "title": "The House of the Spirits", + "overview": "The epic tale of a proud and passionate family, secret loves, and bloody revolution. The Trueba family's passions, struggles, and secrets span a century of violent social change, culminating in a crisis that hurls the proud, tyrannical patriarch and his beloved granddaughter towards opposite sides of the fence.", + "poster_path": "/yb682O0KCZlS5qRydxOytO5A6co.jpg", + "backdrop_path": "/u2nBtDrTOIi9u78c02bL0A3lo7Y.jpg", + "year": 2026, + "score": 0.13584642455856066, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 258165, + "watched": false, + "added": true, + "title": "East of Eden (2026)" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 2382, + "tvdb_id": 76321, + "title": "Freaks and Geeks", + "overview": "High school mathlete Lindsay Weir rebels and begins hanging out with a crowd of burnouts (the \"freaks\"), while her brother Sam Weir navigates a different part of the social universe with his nerdy friends (the \"geeks\").", + "poster_path": "/tPqV63zcW6ZV0Hd48DMGb5UzQIG.jpg", + "backdrop_path": "/1lOGkpGa9SjZ2uP1PxfxDkSXRNs.jpg", + "year": 1999, + "score": 0.13339330720689832, + "genre_ids": [ + 18, + 35 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 254420, + "watched": true, + "added": false + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 10860, + "tvdb_id": 74481, + "title": "Road to Avonlea", + "overview": "In the fictional small town of Avonlea, Prince Edward Island, in the early 20th century, 10-year-old Montreal heiress Sara Stanley is sent by her wealthy father to live with her two maiden aunts, Hetty and Olivia King, to be near her late mother's side of the family.", + "poster_path": "/2392PwVEfsMOMgYD5q0KaRWbjhP.jpg", + "backdrop_path": "/fM7OxNnULTgN19JChMPIv2OSiNx.jpg", + "year": 1990, + "score": 0.12605001646965905, + "genre_ids": [ + 18, + 10751 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 66134, + "watched": true, + "added": false, + "title": "The Durrells" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 211564, + "tvdb_id": 420865, + "title": "Sullivan's Crossing", + "overview": "After Neurosurgeon Maggie Sullivan\u2019s personal and professional life are thrown into turmoil she returns home to Sullivan\u2019s Crossing. While there, Maggie is forced to navigate her complicated present while confronting the painful past she has chosen to ignore for years.", + "poster_path": "/nYRnuQNR13nEYaXkOZaY0qIWl2K.jpg", + "backdrop_path": "/coh0nl5ov1o42fy7f74MDMvme2I.jpg", + "year": 2023, + "score": 0.12605001646965905, + "genre_ids": [ + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 33155, + "watched": true, + "added": false, + "title": "Offspring" + } + ] + }, + { + "media_type": "tv", + "tmdb_id": 197373, + "tvdb_id": 418934, + "title": "Based on a True Story", + "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", + "poster_path": "/5xPLk78TFStBi1dQAlG2mnoSK60.jpg", + "backdrop_path": "/yVWlvUHX02ARZte4aBep6GPkkck.jpg", + "year": 2023, + "score": 0.12549019607843137, + "genre_ids": [ + 35, + 18 + ], + "contributions": [ + { + "media_type": "tv", + "tmdb_id": 693, + "watched": true, + "added": false, + "title": "Desperate Housewives" + }, + { + "media_type": "tv", + "tmdb_id": 218347, + "watched": true, + "added": false + } + ] + } + ], + "projection": { + "media_type": "all", + "genre_id": 0, + "service_id": 0, + "coverage": { + "total": 141, + "matched": 141, + "excluded": 0, + "unresolved": 0, + "complete": true + }, + "facts": [ + { + "metadata": { + "tmdb_id": 235271, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T10:43:00.643180289Z", + "providers_observed_at": "2026-11-06T10:43:00.643180289Z", + "checked_at": "2026-11-06T10:43:00.643180289Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 8999, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:52.599309363Z", + "providers_observed_at": "2026-11-06T15:44:52.599309363Z", + "checked_at": "2026-11-06T15:44:52.599309363Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 317744, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:17.038050781Z", + "checked_at": "2026-11-06T15:44:17.038050781Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 13250, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:18.812047665Z", + "checked_at": "2026-11-06T15:44:18.812047665Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 376004, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:20.081486476Z", + "checked_at": "2026-11-06T15:44:20.081486476Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 13483, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T00:21:02.623137183Z", + "providers_observed_at": "2026-11-06T00:21:02.623137183Z", + "checked_at": "2026-11-06T00:21:02.623137183Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 18190, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:56.168073579Z", + "checked_at": "2026-11-06T15:44:56.168073579Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 210577, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:31.995556322Z", + "providers_observed_at": "2026-11-05T21:25:31.995556322Z", + "checked_at": "2026-11-05T21:25:31.995556322Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 619979, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:23:46.870483061Z", + "providers_observed_at": "2026-11-05T21:23:46.870483061Z", + "checked_at": "2026-11-05T21:23:46.870483061Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1317288, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-06T12:21:37.342438175Z", + "providers_observed_at": "2026-11-06T12:21:37.342438175Z", + "checked_at": "2026-11-06T12:21:37.342438175Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 582014, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T00:21:16.848833721Z", + "providers_observed_at": "2026-11-06T00:21:16.848833721Z", + "checked_at": "2026-11-06T00:21:16.848833721Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 549514, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 297, + "name": "Ziggo TV", + "logo_url": "https://image.tmdb.org/t/p/w92/96DBWDlJw9X0EnP22cOahM8Fdpn.png" + }, + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-05T21:24:51.510424868Z", + "providers_observed_at": "2026-11-05T21:24:51.510424868Z", + "checked_at": "2026-11-05T21:24:51.510424868Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 59231, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:24.19081339Z", + "checked_at": "2026-11-06T15:45:24.19081339Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1249213, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T15:45:03.503769553Z", + "providers_observed_at": "2026-11-06T15:45:03.503769553Z", + "checked_at": "2026-11-06T15:45:03.503769553Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 624788, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:19.075907378Z", + "checked_at": "2026-11-05T21:34:19.075907378Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2252, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:24:35.282458128Z", + "providers_observed_at": "2026-11-05T21:24:35.282458128Z", + "checked_at": "2026-11-05T21:24:35.282458128Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 311667, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 639, + "name": "CineMember", + "logo_url": "https://image.tmdb.org/t/p/w92/gd0zMEph8WdBDaRd7LTqfCG53vI.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:25.057305529Z", + "providers_observed_at": "2026-11-05T21:27:25.057305529Z", + "checked_at": "2026-11-05T21:27:25.057305529Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2990, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:14.82897145Z", + "checked_at": "2026-11-06T00:21:14.82897145Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1965, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:02.873240161Z", + "providers_observed_at": "2026-11-06T15:45:02.873240161Z", + "checked_at": "2026-11-06T15:45:02.873240161Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 63493, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:00.513271626Z", + "checked_at": "2026-11-06T15:45:00.513271626Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 451156, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 10770, + "name": "TV Movie" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:26:30.493325244Z", + "checked_at": "2026-11-05T21:26:30.493325244Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1920, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:58.412956949Z", + "checked_at": "2026-11-06T15:44:58.412956949Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1033148, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 11, + "name": "MUBI", + "logo_url": "https://image.tmdb.org/t/p/w92/k7iSlvgWzZuO4zU5PcBjhABMuia.png" + }, + { + "id": 201, + "name": "MUBI Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/w1hl8HszsPf5aAGT0mVY0O6LYP4.png" + } + ], + "genres_observed_at": "2026-11-05T21:24:05.016873731Z", + "providers_observed_at": "2026-11-05T21:24:05.016873731Z", + "checked_at": "2026-11-05T21:24:05.016873731Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 7442, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-06T12:26:41.975535551Z", + "providers_observed_at": "2026-11-06T12:26:41.975535551Z", + "checked_at": "2026-11-06T12:26:41.975535551Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 8884, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 14, + "name": "Fantasy" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:25:13.822417756Z", + "checked_at": "2026-11-05T21:25:13.822417756Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 14698, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:20.880053202Z", + "providers_observed_at": "2026-11-06T00:21:20.880053202Z", + "checked_at": "2026-11-06T00:21:20.880053202Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 45938, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T12:35:54.548175409Z", + "checked_at": "2026-11-06T12:35:54.548175409Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 16933, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T13:23:01.142473058Z", + "providers_observed_at": "2026-11-06T13:23:01.142473058Z", + "checked_at": "2026-11-06T13:23:01.142473058Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 7183, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:08.281292709Z", + "providers_observed_at": "2026-11-05T21:29:08.281292709Z", + "checked_at": "2026-11-05T21:29:08.281292709Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 893086, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T17:51:19.878708528Z", + "providers_observed_at": "2026-11-06T17:51:19.878708528Z", + "checked_at": "2026-11-06T17:51:19.878708528Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1213, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:33:16.269467208Z", + "providers_observed_at": "2026-11-05T21:33:16.269467208Z", + "checked_at": "2026-11-05T21:33:16.269467208Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1317088, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 878, + "name": "Science Fiction" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T13:23:05.185284868Z", + "providers_observed_at": "2026-11-06T13:23:05.185284868Z", + "checked_at": "2026-11-06T13:23:05.185284868Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 50875, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T13:22:59.015768824Z", + "checked_at": "2026-11-06T13:22:59.015768824Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 11660, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:25:09.782964078Z", + "checked_at": "2026-11-05T21:25:09.782964078Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 65650, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:23.004963735Z", + "checked_at": "2026-11-06T00:21:23.004963735Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 241, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-06T00:21:12.685828539Z", + "providers_observed_at": "2026-11-06T00:21:12.685828539Z", + "checked_at": "2026-11-06T00:21:12.685828539Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 645710, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T16:14:14.986254896Z", + "providers_observed_at": "2026-11-06T16:14:14.986254896Z", + "checked_at": "2026-11-06T16:14:14.986254896Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69668, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:45:07.983781006Z", + "checked_at": "2026-11-06T15:45:07.983781006Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 33195, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T00:21:25.163153867Z", + "checked_at": "2026-11-06T00:21:25.163153867Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4547, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:16:43.34835158Z", + "providers_observed_at": "2026-11-05T21:16:43.34835158Z", + "checked_at": "2026-11-05T21:16:43.34835158Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 74998, + "media_type": "movie", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 28, + "name": "Action" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 53, + "name": "Thriller" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T17:48:57.705507001Z", + "providers_observed_at": "2026-10-30T17:48:50.333477339Z", + "checked_at": "2026-11-06T17:48:57.705507001Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 73552, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:50.276092275Z", + "checked_at": "2026-11-06T15:44:50.276092275Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2327, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10766, + "name": "Soap" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T15:44:51.398997957Z", + "providers_observed_at": "2026-11-06T15:44:51.398997957Z", + "checked_at": "2026-11-06T15:44:51.398997957Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 578, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T15:44:54.217033174Z", + "checked_at": "2026-11-06T15:44:54.217033174Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 900, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T16:12:55.902717952Z", + "checked_at": "2026-11-06T16:12:55.902717952Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 56570, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10765, + "name": "Sci-Fi & Fantasy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 297, + "name": "Ziggo TV", + "logo_url": "https://image.tmdb.org/t/p/w92/96DBWDlJw9X0EnP22cOahM8Fdpn.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:53.93026257Z", + "providers_observed_at": "2026-11-05T21:28:53.93026257Z", + "checked_at": "2026-11-05T21:28:53.93026257Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62117, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:34:59.469454878Z", + "providers_observed_at": "2026-11-05T21:34:59.469454878Z", + "checked_at": "2026-11-05T21:34:59.469454878Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 87428, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-06T15:44:56.822294671Z", + "providers_observed_at": "2026-11-06T15:44:56.822294671Z", + "checked_at": "2026-11-06T15:44:56.822294671Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 88989, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 72, + "name": "Videoland", + "logo_url": "https://image.tmdb.org/t/p/w92/3IdfSLRjSoDoo9lrpvX4oxZmnVi.png" + } + ], + "genres_observed_at": "2026-11-06T16:06:10.308397809Z", + "providers_observed_at": "2026-11-06T16:06:10.308397809Z", + "checked_at": "2026-11-06T16:06:10.308397809Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 106454, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 350, + "name": "Apple TV", + "logo_url": "https://image.tmdb.org/t/p/w92/9icYBfYFcwgCbky5VdGUIKJ4C5i.png" + }, + { + "id": 2243, + "name": "Apple TV Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/rerVCu9auZrgxTTiZu1criqrQzg.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:15.207798622Z", + "providers_observed_at": "2026-11-05T21:31:15.207798622Z", + "checked_at": "2026-11-05T21:31:15.207798622Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 71199, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:23:04.487348035Z", + "checked_at": "2026-11-05T21:23:04.487348035Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 100883, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:19.499884611Z", + "providers_observed_at": "2026-11-05T21:28:19.499884611Z", + "checked_at": "2026-11-05T21:28:19.499884611Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 42282, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:25.936697952Z", + "providers_observed_at": "2026-11-05T21:25:25.936697952Z", + "checked_at": "2026-11-05T21:25:25.936697952Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 243861, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T11:54:43.481292701Z", + "providers_observed_at": "2026-11-06T11:54:43.481292701Z", + "checked_at": "2026-11-06T11:54:43.481292701Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 94617, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T14:16:07.710906158Z", + "checked_at": "2026-11-06T14:16:07.710906158Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62823, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:50.618636269Z", + "checked_at": "2026-11-05T21:29:50.618636269Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 18856, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T20:11:40.044894331Z", + "checked_at": "2026-11-05T20:11:40.044894331Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1803, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:31:45.413498213Z", + "checked_at": "2026-11-05T21:31:45.413498213Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 116450, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:23.639209961Z", + "providers_observed_at": "2026-11-05T21:21:23.639209961Z", + "checked_at": "2026-11-05T21:21:23.639209961Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 199001, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:07.451469754Z", + "providers_observed_at": "2026-11-05T21:28:07.451469754Z", + "checked_at": "2026-11-05T21:28:07.451469754Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 64254, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:39.175684225Z", + "providers_observed_at": "2026-11-05T21:27:39.175684225Z", + "checked_at": "2026-11-05T21:27:39.175684225Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 196454, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:29:14.359242906Z", + "providers_observed_at": "2026-11-05T21:29:14.359242906Z", + "checked_at": "2026-11-05T21:29:14.359242906Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 206829, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:32:53.972934948Z", + "providers_observed_at": "2026-11-05T21:32:53.972934948Z", + "checked_at": "2026-11-05T21:32:53.972934948Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 99248, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T23:55:25.168767993Z", + "checked_at": "2026-11-05T23:55:25.168767993Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 124834, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:19.136059766Z", + "providers_observed_at": "2026-11-05T23:55:19.136059766Z", + "checked_at": "2026-11-05T23:55:19.136059766Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 7406, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 472, + "name": "NLZIET", + "logo_url": "https://image.tmdb.org/t/p/w92/jY3kxKfekzgpEDtghtK9elh4G6T.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:11.294138927Z", + "providers_observed_at": "2026-11-05T21:21:11.294138927Z", + "checked_at": "2026-11-05T21:21:11.294138927Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69470, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:29:38.48700516Z", + "providers_observed_at": "2026-11-05T21:29:38.48700516Z", + "checked_at": "2026-11-05T21:29:38.48700516Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62705, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "providers": [ + { + "id": 72, + "name": "Videoland", + "logo_url": "https://image.tmdb.org/t/p/w92/3IdfSLRjSoDoo9lrpvX4oxZmnVi.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:31.084696935Z", + "providers_observed_at": "2026-11-05T21:27:31.084696935Z", + "checked_at": "2026-11-05T21:27:31.084696935Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 253376, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T10:13:53.419455494Z", + "providers_observed_at": "2026-11-06T10:13:53.419455494Z", + "checked_at": "2026-11-06T10:13:53.419455494Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 39468, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:27:47.2630122Z", + "providers_observed_at": "2026-11-05T21:27:47.2630122Z", + "checked_at": "2026-11-05T21:27:47.2630122Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 126914, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:34:41.152157563Z", + "providers_observed_at": "2026-11-05T21:34:41.152157563Z", + "checked_at": "2026-11-05T21:34:41.152157563Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 5721, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:33:08.217497968Z", + "checked_at": "2026-11-05T21:33:08.217497968Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 21762, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:29:02.122502055Z", + "providers_observed_at": "2026-11-05T21:29:02.122502055Z", + "checked_at": "2026-11-05T21:29:02.122502055Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 80587, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10751, + "name": "Family" + }, + { + "id": 16, + "name": "Animation" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:22:05.988680828Z", + "providers_observed_at": "2026-11-05T21:22:05.988680828Z", + "checked_at": "2026-11-05T21:22:05.988680828Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 39859, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T08:24:56.06076054Z", + "checked_at": "2026-11-06T08:24:56.06076054Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 210805, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T16:14:25.197987217Z", + "checked_at": "2026-11-06T16:14:25.197987217Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4177, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:10.325858991Z", + "checked_at": "2026-11-05T21:29:10.325858991Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 46028, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:23.923810903Z", + "providers_observed_at": "2026-11-05T21:25:23.923810903Z", + "checked_at": "2026-11-05T21:25:23.923810903Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 92621, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:22:26.18747643Z", + "checked_at": "2026-11-05T21:22:26.18747643Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1395, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:25:36.035509536Z", + "providers_observed_at": "2026-11-05T21:25:36.035509536Z", + "checked_at": "2026-11-05T21:25:36.035509536Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 6127, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:21:05.257417929Z", + "checked_at": "2026-11-05T21:21:05.257417929Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1021, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10766, + "name": "Soap" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:22:48.413281577Z", + "checked_at": "2026-11-05T21:22:48.413281577Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 211, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T12:25:13.758451016Z", + "checked_at": "2026-11-06T12:25:13.758451016Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 61118, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:53.407473419Z", + "checked_at": "2026-11-05T21:34:53.407473419Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 62704, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:45.84116767Z", + "providers_observed_at": "2026-11-05T21:21:45.84116767Z", + "checked_at": "2026-11-05T21:21:45.84116767Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 39525, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:35.115608453Z", + "checked_at": "2026-11-05T21:34:35.115608453Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 111838, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T11:54:45.492394545Z", + "providers_observed_at": "2026-11-06T11:54:45.492394545Z", + "checked_at": "2026-11-06T11:54:45.492394545Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 124010, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:26:22.356825229Z", + "providers_observed_at": "2026-11-05T21:26:22.356825229Z", + "checked_at": "2026-11-05T21:26:22.356825229Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 275139, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-06T05:51:43.36209593Z", + "providers_observed_at": "2026-11-06T05:51:43.36209593Z", + "checked_at": "2026-11-06T05:51:43.36209593Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69050, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T12:26:31.89821713Z", + "providers_observed_at": "2026-11-06T12:26:31.89821713Z", + "checked_at": "2026-11-06T12:26:31.89821713Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 70796, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:32:33.891659122Z", + "providers_observed_at": "2026-11-05T21:32:33.891659122Z", + "checked_at": "2026-11-05T21:32:33.891659122Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 135959, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:07.148373201Z", + "providers_observed_at": "2026-11-05T21:31:07.148373201Z", + "checked_at": "2026-11-05T21:31:07.148373201Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 241372, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:20:34.823654155Z", + "providers_observed_at": "2026-11-05T21:20:34.823654155Z", + "checked_at": "2026-11-05T21:20:34.823654155Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 250504, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:35.342107155Z", + "providers_observed_at": "2026-11-05T21:31:35.342107155Z", + "checked_at": "2026-11-05T21:31:35.342107155Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 258190, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:23:10.633405579Z", + "checked_at": "2026-11-05T21:23:10.633405579Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 120998, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:23.162687765Z", + "providers_observed_at": "2026-11-05T23:55:23.162687765Z", + "checked_at": "2026-11-05T23:55:23.162687765Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4454, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T23:55:31.205160147Z", + "providers_observed_at": "2026-11-05T23:55:31.205160147Z", + "checked_at": "2026-11-05T23:55:31.205160147Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2585, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:26:02.269758895Z", + "checked_at": "2026-11-05T21:26:02.269758895Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 269, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T11:49:56.238979796Z", + "providers_observed_at": "2026-11-06T11:49:56.238979796Z", + "checked_at": "2026-11-06T11:49:56.238979796Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 95004, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:34:21.087963942Z", + "checked_at": "2026-11-05T21:34:21.087963942Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4107, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:33:14.236852284Z", + "checked_at": "2026-11-05T21:33:14.236852284Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 70785, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:27.666221762Z", + "providers_observed_at": "2026-11-05T21:21:27.666221762Z", + "checked_at": "2026-11-05T21:21:27.666221762Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4607, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 10759, + "name": "Action & Adventure" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:21.150838395Z", + "providers_observed_at": "2026-11-05T23:55:21.150838395Z", + "checked_at": "2026-11-05T23:55:21.150838395Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 73319, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:20:36.83693195Z", + "providers_observed_at": "2026-11-05T21:20:36.83693195Z", + "checked_at": "2026-11-05T21:20:36.83693195Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2398, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 315, + "name": "Hoichoi", + "logo_url": "https://image.tmdb.org/t/p/w92/8dgAD4EzQWMsyudVwWCFg19998L.png" + } + ], + "genres_observed_at": "2026-11-06T12:26:07.803808577Z", + "providers_observed_at": "2026-11-06T12:26:07.803808577Z", + "checked_at": "2026-11-06T12:26:07.803808577Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 186, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T11:01:27.847601167Z", + "providers_observed_at": "2026-11-06T11:01:27.847601167Z", + "checked_at": "2026-11-06T11:01:27.847601167Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 31917, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + }, + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-06T11:52:12.800296211Z", + "providers_observed_at": "2026-11-06T11:52:12.800296211Z", + "checked_at": "2026-11-06T11:52:12.800296211Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 88324, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:34:27.102165661Z", + "providers_observed_at": "2026-11-05T21:34:27.102165661Z", + "checked_at": "2026-11-05T21:34:27.102165661Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 4582, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:26:34.535473425Z", + "providers_observed_at": "2026-11-05T21:26:34.535473425Z", + "checked_at": "2026-11-05T21:26:34.535473425Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 67697, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T14:23:12.166007627Z", + "checked_at": "2026-11-06T14:23:12.166007627Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 130842, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 76, + "name": "Viaplay", + "logo_url": "https://image.tmdb.org/t/p/w92/c1J9PGGowXwW5AxAaZaGipeFu7U.png" + }, + { + "id": 2358, + "name": "Lionsgate+ Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/1356slxu4QrHOMrEVDKHJXRo5sW.png" + } + ], + "genres_observed_at": "2026-11-06T14:23:26.216965491Z", + "providers_observed_at": "2026-11-06T14:23:26.216965491Z", + "checked_at": "2026-11-06T14:23:26.216965491Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 153723, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:09.275219679Z", + "providers_observed_at": "2026-11-05T21:21:09.275219679Z", + "checked_at": "2026-11-05T21:21:09.275219679Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 41875, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:51:26.706620327Z", + "checked_at": "2026-11-06T11:51:26.706620327Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 60705, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T23:55:17.124876009Z", + "checked_at": "2026-11-05T23:55:17.124876009Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 236235, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T18:26:46.070072679Z", + "providers_observed_at": "2026-11-06T18:26:46.070072679Z", + "checked_at": "2026-11-06T18:26:46.070072679Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 81357, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T21:23:42.814357802Z", + "providers_observed_at": "2026-11-05T21:23:42.814357802Z", + "checked_at": "2026-11-05T21:23:42.814357802Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 1088, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:24:27.206645932Z", + "checked_at": "2026-11-05T21:24:27.206645932Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 3004, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:28:29.672713141Z", + "checked_at": "2026-11-05T21:28:29.672713141Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 3216, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:47:20.045517228Z", + "checked_at": "2026-11-06T11:47:20.045517228Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 80213, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-06T11:48:40.127262242Z", + "providers_observed_at": "2026-11-06T11:48:40.127262242Z", + "checked_at": "2026-11-06T11:48:40.127262242Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 81358, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 337, + "name": "Disney Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/5eZ872CghnHFLB1j8grszbrx0dx.png" + } + ], + "genres_observed_at": "2026-11-05T21:28:01.418530642Z", + "providers_observed_at": "2026-11-05T21:28:01.418530642Z", + "checked_at": "2026-11-05T21:28:01.418530642Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 153312, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 1773, + "name": "SkyShowtime", + "logo_url": "https://image.tmdb.org/t/p/w92/ube1pVpP3FklRkeyQjlDkkC3tL2.png" + } + ], + "genres_observed_at": "2026-11-06T14:22:04.05218024Z", + "providers_observed_at": "2026-11-06T14:22:04.05218024Z", + "checked_at": "2026-11-06T14:22:04.05218024Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 242054, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:27.177769487Z", + "providers_observed_at": "2026-11-05T23:55:27.177769487Z", + "checked_at": "2026-11-05T23:55:27.177769487Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 749, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 472, + "name": "NLZIET", + "logo_url": "https://image.tmdb.org/t/p/w92/jY3kxKfekzgpEDtghtK9elh4G6T.png" + }, + { + "id": 1986, + "name": "NPO Plus", + "logo_url": "https://image.tmdb.org/t/p/w92/eIplw9Hv4Q5RyHtofKZwjeRufz6.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:15.115823011Z", + "providers_observed_at": "2026-11-05T23:55:15.115823011Z", + "checked_at": "2026-11-05T23:55:15.115823011Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2215, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:27:53.294051763Z", + "checked_at": "2026-11-05T21:27:53.294051763Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 87049, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T10:13:55.439872014Z", + "providers_observed_at": "2026-11-06T10:13:55.439872014Z", + "checked_at": "2026-11-06T10:13:55.439872014Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 95386, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:28:41.730357928Z", + "checked_at": "2026-11-05T21:28:41.730357928Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 136044, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-06T11:54:35.332205607Z", + "providers_observed_at": "2026-11-06T11:54:35.332205607Z", + "checked_at": "2026-11-06T11:54:35.332205607Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 195670, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T23:55:33.213426461Z", + "providers_observed_at": "2026-11-05T23:55:33.213426461Z", + "checked_at": "2026-11-05T23:55:33.213426461Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 102904, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-05T20:24:15.046007915Z", + "providers_observed_at": "2026-11-05T20:24:15.046007915Z", + "checked_at": "2026-11-05T20:24:15.046007915Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 45194, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 80, + "name": "Crime" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 9648, + "name": "Mystery" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:54:47.502903807Z", + "checked_at": "2026-11-06T11:54:47.502903807Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 69630, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:31:37.373897507Z", + "providers_observed_at": "2026-11-05T21:31:37.373897507Z", + "checked_at": "2026-11-05T21:31:37.373897507Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 91239, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + } + ], + "genres_observed_at": "2026-11-06T11:51:22.696252683Z", + "providers_observed_at": "2026-11-06T11:51:22.696252683Z", + "checked_at": "2026-11-06T11:51:22.696252683Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 100412, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10762, + "name": "Kids" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 8, + "name": "Netflix", + "logo_url": "https://image.tmdb.org/t/p/w92/rK1KljqmbvO9HQa1PBFLILWah72.png" + }, + { + "id": 175, + "name": "Netflix Kids", + "logo_url": "https://image.tmdb.org/t/p/w92/rjPaInWHH1nPgtRFF17BbHN2gu.png" + } + ], + "genres_observed_at": "2026-11-06T11:53:31.09024729Z", + "providers_observed_at": "2026-11-06T11:53:31.09024729Z", + "checked_at": "2026-11-06T11:53:31.09024729Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 284558, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T12:36:00.575054236Z", + "providers_observed_at": "2026-11-06T12:36:00.575054236Z", + "checked_at": "2026-11-06T12:36:00.575054236Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 90046, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 10764, + "name": "Reality" + } + ], + "providers": [ + { + "id": 1899, + "name": "HBO Max", + "logo_url": "https://image.tmdb.org/t/p/w92/skypuy7SXuugIQeYg0IglmzoKaS.png" + }, + { + "id": 1825, + "name": "HBO Max Amazon Channel", + "logo_url": "https://image.tmdb.org/t/p/w92/64fLWeSyZ1KQZhIdvTdy4QHeWty.png" + } + ], + "genres_observed_at": "2026-11-05T21:20:38.84522959Z", + "providers_observed_at": "2026-11-05T21:20:38.84522959Z", + "checked_at": "2026-11-05T21:20:38.84522959Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 259702, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-06T05:48:13.225189916Z", + "providers_observed_at": "2026-11-06T05:48:13.225189916Z", + "checked_at": "2026-11-06T05:48:13.225189916Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 2382, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 35, + "name": "Comedy" + } + ], + "providers": [], + "genres_observed_at": "2026-11-06T11:48:52.142526753Z", + "checked_at": "2026-11-06T11:48:52.142526753Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 10860, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "unobserved", + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + } + ], + "providers": [], + "genres_observed_at": "2026-11-05T21:29:28.442352928Z", + "checked_at": "2026-11-05T21:29:28.442352928Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 211564, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 472, + "name": "NLZIET", + "logo_url": "https://image.tmdb.org/t/p/w92/jY3kxKfekzgpEDtghtK9elh4G6T.png" + } + ], + "genres_observed_at": "2026-11-06T16:14:23.188509289Z", + "providers_observed_at": "2026-11-06T16:14:23.188509289Z", + "checked_at": "2026-11-06T16:14:23.188509289Z" + }, + "match": "matched", + "genre_source": "ranked" + }, + { + "metadata": { + "tmdb_id": 197373, + "media_type": "tv", + "region": "NL", + "genre_state": "observed", + "provider_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 18, + "name": "Drama" + } + ], + "providers": [ + { + "id": 119, + "name": "Amazon Prime Video", + "logo_url": "https://image.tmdb.org/t/p/w92/gMZdpavHmxFNnLpMHwVxfqeux2g.png" + } + ], + "genres_observed_at": "2026-11-05T21:21:49.852947013Z", + "providers_observed_at": "2026-11-05T21:21:49.852947013Z", + "checked_at": "2026-11-05T21:21:49.852947013Z" + }, + "match": "matched", + "genre_source": "ranked" + } + ], + "unresolved": [] + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt index 1718dc44..3f76141f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt @@ -41,20 +41,62 @@ internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle( overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(), ) +/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */ internal fun RequestProgress.comingIn(): ComingInRequest { val stagesByName = stages.orEmpty().associateBy { it.name } val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION] val visibility = stagesByName[ReadinessStage.Name.VISIBILITY] + val playback = stagesByName[ReadinessStage.Name.PLAYBACK] val stage = when { - stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed + acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed + acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet + playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth + visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere + visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary + visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded + stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready - awaitingApproval == true -> ComingInStage.Waiting - visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady - acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading - acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching - stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking - else -> ComingInStage.Waiting + awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting + acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile + visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady + acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading + acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching + acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting + else -> ComingInStage.Checking + } + val detail = when (stage) { + ComingInStage.FileHealth -> playback + ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility + ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED } + else -> acquisition } return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, - requester.name, actor?.requester, actor?.name, stage) + requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary, + readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() }) +} + +/** Expanded readiness describes each observed stage, not a synthetic download failure. */ +private fun ReadinessStage?.readinessWord(): String = when { + this == null -> "Unknown" + name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention" + name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed" + name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed" + name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet" + name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present" + name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file" + name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library" + name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library" + name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading" + name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play" + name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet" + else -> when (state) { + ReadinessStage.State.DONE -> "Done" + ReadinessStage.State.FAILED -> "Failed" + ReadinessStage.State.IN_PROGRESS -> "In progress" + ReadinessStage.State.PARTIAL -> "Partial" + ReadinessStage.State.NONE -> "Not yet" + ReadinessStage.State.UNKNOWN -> "Unknown" + ReadinessStage.State.STALE -> "Last observed" + ReadinessStage.State.CONFLICTING -> "Check needed" + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt index 06caea9c..8196559c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -57,10 +57,14 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it } val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE) val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } - val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } - val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate -> - candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } } - }.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) } + val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty() + val picks = candidates.map { it.discoverTitle(genres) } + val seeds = candidates.flatMapIndexed { index, candidate -> + candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] } + }.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) -> + DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second }, + watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added }) + } val hidden = hiddenJob.await()?.dismissals.orEmpty().map { DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath)) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt index 2c88cc9b..a678ca21 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt @@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: () refresh() while (currentCoroutineContext().isActive) { delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L) - if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh() + if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh() } } diff --git a/docker/README.md b/docker/README.md index 228da088..f2fd9935 100644 --- a/docker/README.md +++ b/docker/README.md @@ -213,6 +213,14 @@ destination is an explicit title-family integration stub exposing only Discover' request and pick actions. Unheld titles have no trailer control until the pinned Conductarr title contract supplies a trailer. +Named “Because you watched” shelves require watched contribution evidence. +Seeds are grouped by media type and TMDB identity, never by caption; repeated +contributions retain both watched/added flags. The current feed has no per-seed +watch timestamp, so eligible shelves keep wire order. Added-only contributions +remain available in the general For-you collections, but make no named shelf. +Titleless contributions do not make a shelf. Recommendations remain acquisition +suggestions, not a filter to the viewer's current library. + The debug-only fixture and local art live in `app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a network-free fixture host; real providers are the default in debug and release. @@ -351,14 +359,24 @@ are recorded differences, not a claim of browser pixel equality. ## Requests under Settings -The Requests row and Coming-in sheet share one immutable household snapshot. -Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready, -Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four -with readiness expanded. Right expands Request/Download/Library/Playback; Left -collapses; Up/Down collapse while moving. Opening a title preserves the request -for Back, and closing the sheet restores its opener. Selecting a root or changing -person clears the title-return link. Shared's authenticated actor always reads -“by Shared”, never “by you”. +The Requests row and Coming-in sheet share one immutable household history snapshot. +Settings counts returned requests, including Ready, rather than calling every +non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows +read Removed; absent holdings read Not in any library; unreleased titles read +Not out yet; holdings outside the recipient's libraries read In another library. +Waiting for an import is not evidence of an active download. Downloading and +Searching require explicit acquisition signals. Visibility-proof faults read +Check needed; playback faults read File health needs attention, even when a +library holding is present. Right expands the actual stage evidence and the +relevant summary, including episode/source names when supplied by that summary. +The activity feed currently supplies only a title-level summary for Avatar. + +Attention-needed rows sort first, then active acquisition/checks, waiting and +settled history. The bounded window shows five rows, or four with readiness +expanded. Left collapses; Up/Down collapse while moving. Opening a title preserves +the request for Back, and closing the sheet restores its opener. Selecting a root +or changing person clears the title-return link. Shared's authenticated actor +always reads “by Shared”, never “by you”; absent actors have no “by” clause. The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and its eight local posters. Shared and focused-rating records also feed the native @@ -410,6 +428,15 @@ type and TMDB identity. Back restores the exact Coming-in row and expansion; the temporary `request-title/...` placeholder is removed. TV sign-in, release and device deployment remain separate. +`ActivityTruthProjectionTest` feeds recorded activity JSON through the TV adapter +for Steins;Gate, Yu Yu Hakusho, Love is Blind, Spider-Man: Homecoming, deleted +One Hour Photo, unreleased The Body, Avatar health and Partition waiting. +`ConductarrTruthCaptureTest` renders those provider snapshots and Mindel's recorded +recommendation feed on the native Compose surfaces at 1920×1080, writing +`build/wolfskin-captures/truth/`. Artwork is deliberately omitted to keep these +captures offline; missing-art rendering, tokens, focus, sheet geometry and shelf +spacing are the shipped components. + ## Libraries and See all Movies, Shows and Anime use the shell's retained view and a continuous six-column diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 23c5c596..d02b4835 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -157,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex Column(Modifier.weight(1f)) { Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), + val actor = item.actor(viewer) + Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), style = ComingInTypography.caption, color = secondary) FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) { @@ -168,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex } if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin), horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) { - item.stage.readiness.forEachIndexed { index, word -> + item.readiness.forEachIndexed { index, word -> Row { - val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" } + val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" } if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight }) Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary) } } } + if (expanded) item.summary?.let { summary -> + Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary) + } } if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt index f8aa27df..feddf7b8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.delay -/** Household acquisition stages, ordered independently from their display wording. */ +/** History states distinguish acquisition work from library evidence and playback health. */ enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List) { Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")), + FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")), + CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")), Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")), AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")), Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")), Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")), Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")), + WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")), + NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")), + HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")), + Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")), + NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")), Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")), } @@ -33,13 +40,15 @@ data class ComingInRequest( val stage: ComingInStage, val poster: Any? = null, val ratings: List = emptyList(), + val summary: String? = null, + val readiness: List = stage.readiness, ) { val ratingKey = "$mediaType:$tmdbId" fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName - fun actor(viewer: ShellPerson): String = when { + fun actor(viewer: ShellPerson): String? = when { actorKey == "shared" -> "Shared" actorKey != null && actorKey == viewer.key -> "you" - else -> actorName ?: "Unknown" + else -> actorName?.takeIf { it.isNotBlank() } } } @@ -54,7 +63,13 @@ data class ComingInSnapshot( val savedAt: String? = null, ) { val ordered = items.sortedBy { it.stage.rank } - val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } + val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null + val hasUnsettledRequests: Boolean get() = items.any { + when (it.stage) { + ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false + else -> true + } + } } /** A bounded window and stable request id preserve focus across refreshes and title round trips. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt index 396a5862..2136e5ed 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt @@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = { val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) } - return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) + return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) }, + services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) } fun service(service: DiscoverService, type: String): List { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt index cd66673f..9558b735 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List() fun take(items: List, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) } if (snapshot.status == DiscoverStatus.Ready) { - val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3) + val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() } + .map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) } + .filter { it.items.isNotEmpty() }.take(3).toList() for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) { val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden } val count = all.count { it.key !in seen } @@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List