diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 98f03a3a..30bff204 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,7 +82,6 @@ configure { testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) - buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"") } signingConfigs { @@ -133,13 +132,6 @@ configure { isDebuggable = true applicationIdSuffix = ".debug" } - - create("optimized") { - initWith(getByName("release")) - signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".opt" - matchingFallbacks += "release" - } } flavorDimensions += "version" productFlavors { @@ -206,10 +198,6 @@ configure { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" kotlin.directories += "src/wolfskin/java" } - getByName("optimized") { - kotlin.directories += "src/release/java" - baselineProfiles.directories += "src/optimized/baselineProfiles" - } } testOptions { @@ -239,11 +227,6 @@ 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() @@ -465,7 +448,6 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) testImplementation(libs.kotlinx.coroutines.test) - testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) diff --git a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt index 8eb4a397..691f67c4 100644 --- a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt +++ b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt @@ -5,5 +5,4 @@ import org.justinvisser.wolfskin.design.HomeFixture import org.justinvisser.wolfskin.design.HomeSnapshot /** Only debug builds may read the locked prototype household projection. */ -fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = - if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null +fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person) diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml index c76d6792..dd5ba542 100644 --- a/app/src/default/res/values/strings.xml +++ b/app/src/default/res/values/strings.xml @@ -5,7 +5,4 @@ Wolfskin Default Wolfskin Compact Wolfskin updated to %s - Conductarr server URL - Advanced override. HTTPS required; blank restores the build default. - Wolfskin preview fixtures (debug only) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 6b3403c6..ec61583d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -32,7 +32,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -577,22 +576,12 @@ class MainActivityViewModel val restoredSession = if (signInAutomatically) { Timber.v("Sign in automatically") - serverRepository.restoreSavedSession() + serverRepository.restoreLastSession() } else { serverRepository.getMostRecentServer() } Timber.d("Restore last session result=%s", restoredSession) setupNavigationManager.navigateTo(restoredSession.destination) - if (signInAutomatically && restoredSession is RestoredSession.Success) { - viewModelScope.launchDefault { - try { - serverRepository.revalidateSession(restoredSession.currentUser) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - Timber.w(exception, "Saved launch session revalidation failed") - } - } - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index aba2a0a6..6ec9d4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -17,7 +17,6 @@ import org.acra.ktx.initAcra import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber -import org.justinvisser.wolfskin.tv.NetworkStartup import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @@ -55,7 +54,6 @@ class WholphinApplication : override fun onCreate() { super.onCreate() - networkStartup.start() if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy @@ -112,9 +110,6 @@ class WholphinApplication : @Inject lateinit var workerFactory: HiltWorkerFactory - @Inject - lateinit var networkStartup: NetworkStartup - override val workManagerConfiguration: Configuration get() = Configuration diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index 68af5123..f72eb3b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -51,10 +51,6 @@ interface JellyfinServerDao { return user.copy(rowId = result.toInt()) } - /** Network name refreshes must not overwrite settings or protection changed since launch. */ - @Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId") - fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int - @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") fun getUser( serverId: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 0930129e..2c24c8f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -8,8 +8,6 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -103,7 +101,15 @@ class ServerRepository } Timber.v("Changing user to ${user.name} on ${server.url}") apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val (updatedServer, userDto) = readSession(server) + val userDto by apiClient.userApi.getCurrentUser() + val updatedServer = + try { + val sysInfo by apiClient.systemApi.getPublicSystemInfo() + server.copy(name = sysInfo.serverName, version = sysInfo.version) + } catch (ex: Exception) { + Timber.w(ex, "Exception fetching public system info") + server + } var updatedUser = user.copy( id = userDto.id, @@ -119,45 +125,6 @@ class ServerRepository return@withContext currentUser } - /** Restore only an unprotected saved login; the launch picker must not wait for the network. */ - suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) { - val recent = mostRecentServerProvider.get() - if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent) - val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None - val user = saved.users.firstOrNull { it.id == recent.userId } - if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server) - apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken) - val current = CurrentUser(saved.server, user) - _currentUserDto.value = null - _current.value = current - RestoredSession.Success(current) - } - - /** A late launch refresh must never replace a person selected while it was in flight. */ - suspend fun revalidateSession(expected: CurrentUser) = withContext(ioDispatcher) { - val (server, userDto) = readSession(expected.server) - if (_current.value !== expected || userDto.id != expected.user.id) return@withContext - serverDao.addOrUpdateServer(server) - serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name) - val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext - if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto) - } - - private suspend fun readSession(server: JellyfinServer): Pair = coroutineScope { - val system = async { - try { - val info by apiClient.systemApi.getPublicSystemInfo() - server.copy(name = info.serverName, version = info.version) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - Timber.w(exception, "Exception fetching public system info") - server - } - } - val user by apiClient.userApi.getCurrentUser() - system.await() to user - } - /** * Try to restore the most recent session * diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 5d43b394..b6d9426c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -660,31 +660,6 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) - val ConductarrUrl = - AppStringPreference( - title = R.string.wolfskin_conductarr_url, - defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL, - getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl }, - setter = { prefs, value -> - org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value) - prefs - }, - summary = R.string.wolfskin_conductarr_url_summary, - ) - - val WolfskinFixtures = - AppSwitchPreference( - title = R.string.wolfskin_fixtures, - defaultValue = false, - getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures }, - setter = { prefs, value -> - org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value) - prefs - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val UserProfileSettings = AppDestinationPreference( title = R.string.override_user_profile_settings, @@ -1345,8 +1320,6 @@ val advancedPreferences = preferences = listOf( AppPreference.WolfskinScreens, - AppPreference.ConductarrUrl, - *if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(), AppPreference.ShowClock, AppPreference.BackdropStylePref, AppPreference.ShowLogos, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 87095f8b..3208f4ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -24,13 +24,6 @@ class NavigationManager log() } - /** Replace only the current page, preserving every parent entry. */ - fun replaceTop(destination: Destination) { - if (backStack.isEmpty()) backStack.add(destination) - else backStack[backStack.lastIndex] = destination - log() - } - /** * Go to the specified [Destination], but reset the back stack to Home first */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 0591f4a1..3a114017 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -26,9 +26,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo -import org.justinvisser.wolfskin.conductarr.PersistentDns -import org.justinvisser.wolfskin.conductarr.DnsConnectionRetry -import java.io.File import javax.inject.Qualifier import javax.inject.Singleton @@ -94,14 +91,12 @@ object AppModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient(dns: PersistentDns) = - DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build() - - /** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */ - @Provides - @Singleton - fun dns(@ApplicationContext context: Context): PersistentDns = - PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") }) + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() @AuthOkHttpClient @Provides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 421cc279..b17036b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -82,11 +82,9 @@ class PlayExternalViewModel ) : ViewModel() { val launched = savedStateHandle.getMutableStateFlow("launched", false) val state = MutableStateFlow(PlayExternalState()) - private var watchScope: String? = null fun init(destination: Destination) { Timber.v("init called: %s", destination) - watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { val prefs = userPreferencesService.getCurrent() @@ -360,7 +358,6 @@ class PlayExternalViewModel failed = false, ), ) - watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) } } } else { Timber.w( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 9cbbe551..24bd8ee9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -830,12 +830,10 @@ class PlaybackViewModel } val playbackItemState = PlaybackItemState(playback) - val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" val activityListener = TrackActivityPlaybackListener( api = api, player = player, - onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) }, getState = { playbackItemState }, ) player.addListener(activityListener) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index e0c8005f..d83ae269 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -4,11 +4,6 @@ import android.content.Context import com.github.damontecres.wholphin.data.MostRecentServer import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule -import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -78,7 +73,10 @@ class CrashReportSender : ReportSender { private fun MostRecentServer.createApi(context: Context): ApiClient? = (this as? MostRecentServer.ServerAndUser)?.let { - val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client() + val okHttpClient = + OkHttpClient + .Builder() + .build() val okHttpFactory = OkHttpFactory(okHttpClient) val api = createJellyfin { @@ -91,11 +89,3 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? = }.createApi(baseUrl = serverUrl, accessToken = accessToken) api } - -/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */ -@EntryPoint -@InstallIn(SingletonComponent::class) -interface CrashNetwork { - @StandardOkHttpClient - fun client(): OkHttpClient -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt deleted file mode 100644 index 3306ca18..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.content.Context -import org.justinvisser.wolfskin.conductarr.FileCacheStore -import java.io.File -import java.io.IOException -import timber.log.Timber -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -/** Only an acknowledged stop invalidates the captured server/user's watch projections. */ -object PlaybackWatchState { - private val mutableRevisions = MutableStateFlow>(emptyMap()) - val revisions = mutableRevisions.asStateFlow() - - /** Remove persisted watch grids before publishing the captured user's acknowledgement. */ - fun stopped(context: Context, scope: String) { - try { - FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope) - } catch (exception: IOException) { - Timber.w(exception, "Could not invalidate playback watch snapshots") - } - val time = System.currentTimeMillis() - mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 859a04aa..2ab4c2f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -7,8 +7,6 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.model.api.PlayMethod @@ -33,12 +31,9 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, - private val onStopped: () -> Unit = {}, private val getState: () -> PlaybackItemState?, ) : Player.Listener { private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) - private val reports = Mutex() - @Volatile private var released = false private val task: TimerTask = object : TimerTask() { override fun run() { @@ -75,23 +70,19 @@ class TrackActivityPlaybackListener( val delay = 5.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing - if (!released) { - TIMER.schedule(task, delay, delay) - initialized = true - } + TIMER.schedule(task, delay, delay) + initialized = true } } } fun release() { - if (released) return - released = true +// player.removeListener(this) task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds - val stoppedState = getState.invoke() launch("reportPlaybackStopped") { - stoppedState?.let { state -> + getState.invoke()?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") if (position < Duration.ZERO) { Timber.w("Negative position when reporting playback stopped: %s", position) @@ -105,7 +96,6 @@ class TrackActivityPlaybackListener( liveStreamId = state.liveStreamId, ), ) - onStopped() } } } @@ -127,7 +117,6 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { - if (released) return@launch getState.invoke()?.let { state -> val calcPosition = withContext(WholphinDispatchers.Main) { @@ -163,7 +152,7 @@ class TrackActivityPlaybackListener( ) { coroutineScope.launchIO { try { - reports.withLock { block.invoke(this) } + block.invoke(this) } catch (ex: Exception) { Timber.w(ex, "Exception during %s", name) } diff --git a/app/src/optimized/baselineProfiles/baseline-prof.txt b/app/src/optimized/baselineProfiles/baseline-prof.txt deleted file mode 100644 index ad4df0ae..00000000 --- a/app/src/optimized/baselineProfiles/baseline-prof.txt +++ /dev/null @@ -1,8 +0,0 @@ -# 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 deleted file mode 100644 index fa4e585f..00000000 --- a/app/src/optimized/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Wolfskin (Opt) - diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index d8fe5695..1de5becd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -32,8 +32,6 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkStatic import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow @@ -118,6 +116,9 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.Success(currentUser) viewModel.handleAppStart() @@ -131,7 +132,7 @@ class TestMainActivityViewModel { destination as SetupDestination.AppContent Assert.assertEquals(currentUser, destination.current) } - coVerify(exactly = 0) { serverRepository.restoreSavedSession() } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @@ -149,7 +150,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } - coVerify(exactly = 0) { serverRepository.restoreSavedSession() } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } coVerify(exactly = 1) { serverRepository.getMostRecentServer() } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } @@ -164,20 +165,6 @@ class TestMainActivityViewModel { } } - @Test - fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) { - setupPreferences { signInAutomatically = true } - every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) - val release = CompletableDeferred() - coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } - viewModel.handleAppStart() - runCurrent() - verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) } - assertFalse(release.isCompleted) - release.complete(Unit) - } - @Test fun `Test auto sign in, hot load, with protected profile`() = runTest { @@ -191,7 +178,7 @@ class TestMainActivityViewModel { coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreSavedSession() } returns + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.ServerOnly(server) viewModel.handleAppStart() @@ -217,7 +204,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.ServerOnly(server) coEvery { serverRepository.tryChangeUser(serverId, userId) @@ -225,7 +212,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSavedSession() } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -245,11 +232,11 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSavedSession() } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -267,7 +254,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSavedSession() } returns + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.Success(currentUser) coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp @@ -284,7 +271,7 @@ class TestMainActivityViewModel { // destination as SetupDestination.AppContent // Assert.assertEquals(currentUser, destination.current) // } - coVerify(exactly = 0) { serverRepository.restoreSavedSession() } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt deleted file mode 100644 index 2724cc61..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.app.Application -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import android.content.Context -import com.github.damontecres.wholphin.data.* -import com.github.damontecres.wholphin.data.model.* -import com.github.damontecres.wholphin.ui.successResponse -import io.mockk.* -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.systemApi -import org.jellyfin.sdk.api.client.extensions.userApi -import org.jellyfin.sdk.api.operations.SystemApi -import org.jellyfin.sdk.api.operations.UserApi -import org.jellyfin.sdk.model.api.PublicSystemInfo -import org.jellyfin.sdk.model.api.UserDto -import org.junit.Assert.* -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ServerSavedSessionTest { - private val dispatcher = StandardTestDispatcher() - private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0") - private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token") - private val users = mockk() - private val system = mockk() - private val api = mockk(relaxed = true) - private val recent = mockk() - private val database by lazy { - Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) - .allowMainThreadQueries().build() - } - private val dao by lazy { database.serverDao() } - private val dto = UserDto(id = user.id, name = "Refreshed name", - hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false) - private val info = PublicSystemInfo(serverName = "Refreshed server", version = "12.1.0") - private val repository by lazy { ServerRepository(mockk(), mockk(), dao, api, dispatcher, recent) } - - @Before fun savedLogin() { - every { recent.get() } returns MostRecentServer.from(CurrentUser(server, user)) - dao.addOrUpdateServer(server) - user = dao.addOrUpdateUser(user) - every { api.userApi } returns users - every { api.systemApi } returns system - coEvery { users.getCurrentUser() } returns successResponse(dto) - coEvery { system.getPublicSystemInfo() } returns successResponse(info) - } - - @After fun closeDatabase() { database.close() } - - @Test fun savedSessionIsUsableWhileUserRevalidationWaitsAndSystemInfoRunsInParallel() = runTest(dispatcher) { - val pendingUser = CompletableDeferred() - val systemStarted = CompletableDeferred() - coEvery { users.getCurrentUser() } coAnswers { pendingUser.await(); successResponse(dto) } - coEvery { system.getPublicSystemInfo() } coAnswers { systemStarted.complete(Unit); successResponse(info) } - val restored = repository.restoreSavedSession() as RestoredSession.Success - assertEquals(user, restored.currentUser.user) - assertEquals(restored.currentUser, repository.current.value) - val refresh = async { repository.revalidateSession(restored.currentUser) } - runCurrent() - systemStarted.await() - assertFalse(refresh.isCompleted) - pendingUser.complete(Unit) - refresh.await() - assertEquals("Refreshed name", repository.current.value!!.user.name) - assertEquals("Refreshed server", repository.current.value!!.server.name) - } - - @Test fun savedProtectedPeopleRemainLocked() = runTest(dispatcher) { - for (protected in listOf(user.copy(pin = "1234"), user.copy(requireLogin = true))) { - dao.updateUser(protected) - assertTrue(repository.restoreSavedSession() is RestoredSession.ServerOnly) - assertNull(repository.current.value) - } - } - - @Test fun lateLaunchRefreshCannotReplaceTheSelectedPerson() = runTest(dispatcher) { - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - val entered = CompletableDeferred() - val pending = CompletableDeferred() - coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) } - val refresh = async { repository.revalidateSession(first) } - entered.await() - val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person")) - every { recent.get() } returns MostRecentServer.from(CurrentUser(server, selected)) - repository.restoreSavedSession() - pending.complete(Unit) - refresh.await() - assertEquals(selected, repository.current.value!!.user) - assertNull(repository.currentUserDto) - } - - @Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) { - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - val entered = CompletableDeferred() - val pending = CompletableDeferred() - coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) } - val refresh = async { repository.revalidateSession(first) } - entered.await() - val edited = user.copy( - pin = "4567", - appPreferences = user.appPreferences.copy(preferredAudioLanguage = "nld"), - config = user.config.copy(homeSettingsSource = HomePageSettingsSource.LOCAL), - ) - dao.updateUser(edited) - pending.complete(Unit) - refresh.await() - val expected = edited.copy(name = dto.name) - assertEquals(expected, dao.getUser(server.id, user.id)) - assertEquals(expected, repository.current.value!!.user) - } - - @Test fun anotherUsersResponseCannotRenameTheSavedPerson() = runTest(dispatcher) { - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - coEvery { users.getCurrentUser() } returns successResponse(dto.copy(id = UUID.randomUUID())) - repository.revalidateSession(first) - assertEquals(user, repository.current.value!!.user) - assertNull(repository.currentUserDto) - } -} 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 fdd45e6a..e7e63acd 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,6 +1,5 @@ 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 @@ -19,12 +18,6 @@ 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/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 3b4d3efa..b266bb96 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -62,7 +62,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber -import org.justinvisser.wolfskin.conductarr.PersistentDns import javax.inject.Singleton @Module @@ -92,11 +91,12 @@ object TestModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns) - - @Provides - @Singleton - fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context) + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() @AuthOkHttpClient @Provides diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt index c6bc32f0..511dbaa2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -114,7 +114,6 @@ class PlayExternalViewModelTest { every { api.subtitleApi } returns subtitleApi every { api.playStateApi } returns playStateApi every { serverRepository.currentUser } returns null - every { serverRepository.currentServer } returns null coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt deleted file mode 100644 index 73791251..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.app.Application -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import androidx.media3.common.Player -import io.mockk.* -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.* -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.playStateApi -import org.jellyfin.sdk.api.operations.PlayStateApi -import org.jellyfin.sdk.model.api.PlayMethod -import com.github.damontecres.wholphin.ui.successResponse -import org.junit.Assert.* -import org.junit.Test -import java.util.UUID -import org.justinvisser.wolfskin.conductarr.FileCacheStore -import org.justinvisser.wolfskin.conductarr.CacheEntry -import java.io.File - -/** Return refresh waits for the stop write rather than racing its acknowledgement. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class PlaybackWatchStateTest { - @Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val previous = WholphinDispatchers.IO - Dispatchers.setMain(dispatcher) - WholphinDispatchers.IO = dispatcher - val api = mockk() - val reports = mockk() - every { api.playStateApi } returns reports - val player = mockk { every { currentPosition } returns 751_246L } - val scope = "server:${UUID.randomUUID()}" - val another = "server:${UUID.randomUUID()}" - val context = RuntimeEnvironment.getApplication() - val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache")) - val saved = CacheEntry("old progress", null, 1L, 1L, group = "library") - cache.write(scope, "library:movies", saved) - cache.write(another, "library:movies", saved) - val acknowledgement = CompletableDeferred() - coEvery { reports.reportPlaybackStopped(any()) } coAnswers { - acknowledgement.await() - successResponse(Unit) - } - val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) - val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state }) - try { - listener.release() - runCurrent() - assertNull(PlaybackWatchState.revisions.value[scope]) - assertNotNull(cache.read(scope, "library:movies")) - acknowledgement.complete(Unit) - advanceUntilIdle() - assertNotNull(PlaybackWatchState.revisions.value[scope]) - assertNull(PlaybackWatchState.revisions.value[another]) - assertNull(cache.read(scope, "library:movies")) - assertEquals(saved, cache.read(another, "library:movies")) - coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) } - listener.release() - advanceUntilIdle() - coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) } - } finally { - WholphinDispatchers.IO = previous - Dispatchers.resetMain() - } - } - - @Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val previous = WholphinDispatchers.IO - Dispatchers.setMain(dispatcher) - WholphinDispatchers.IO = dispatcher - val api = mockk() - val reports = mockk() - every { api.playStateApi } returns reports - coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected") - val scope = "server:${UUID.randomUUID()}" - val listener = TrackActivityPlaybackListener(api, mockk { every { currentPosition } returns 751_246L }, - onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) }) - try { - listener.release() - advanceUntilIdle() - assertNull(PlaybackWatchState.revisions.value[scope]) - } finally { - WholphinDispatchers.IO = previous - Dispatchers.resetMain() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt deleted file mode 100644 index 89afd29e..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ActivityTruthProjectionTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -// 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/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt deleted file mode 100644 index 35e45892..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -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), 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, - buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" }) - } finally { model.viewModelScope.cancel() } - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt deleted file mode 100644 index 1af82b5c..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -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/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt deleted file mode 100644 index b748367e..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -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 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.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.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ConductarrAudienceRecoveryTest { - @Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) } - @After fun restoreMain() { Dispatchers.resetMain() } - - @Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking { - val context = ApplicationProvider.getApplicationContext() - ConductarrPreference.initialize(context) - ConductarrPreference.updateFixtures(false) - for (empty in listOf(false, true)) FakeConductarr().use { fake -> - fake.audiencesStatus = if (empty) 200 else 503 - fake.emptyAudiences = empty - 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() - assertTrue(session.identity.value!!.signedIn) - val client = session.identity.value!!.client - val libraries = LibraryReadFacade(mockk()) - val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) - try { - requireNotNull(model.onForeground(server, user)).join() - assertNull(libraries.audiences) - assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES)) - assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS)) - assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) - if (!empty) { - fake.audiencesStatus = 200 - requireNotNull(model.onForeground(server, user)).join() - assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries) - assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) - assertSame(client, session.identity.value!!.client) - } - } finally { model.viewModelScope.cancel() } - } - } - } - - @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 deleted file mode 100644 index 7842289d..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.lifecycle.viewModelScope -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.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.File -import java.util.UUID -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.last -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.HomeLibraryCache -import org.justinvisser.wolfskin.tv.read.HomeReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ConductarrHomeSurfaceTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun currentIdentityFeedsRealHomeAndSavedRequestsWithoutInventingJellyfinPlayback() { - FakeConductarr().use { fake -> - fake.localArt = true - ConductarrSession(MemoryCacheStore()).use { session -> - val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fake", fake.url, null) - val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val home = mockk() - coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf( - HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) - coEvery { home.readLibrary(user.id) } returns HomeLibrary() - val cache = mockk(relaxed = true) - every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) - 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, 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) } } - } - } } - rule.waitUntil(timeoutMillis = 30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } - } - rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" } - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - rule.mainClock.advanceTimeBy(500) - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() - assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held }) - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "conductarr-home-real.png").absolutePath) - val provider = session.identity.value!!.provider - runBlocking { provider.requests().last() } - fake.status = 503 - val saved = runBlocking { provider.requests().last() } - assertNotNull(saved.savedAt) - assertEquals(ComingInStage.Downloading, saved.items.single().stage) - assertEquals("Shared", saved.items.single().actor(shell.person)) - rule.runOnIdle { shell.requests.update(saved); shell.openRequests() } - rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() - rule.onRoot().captureRoboImage(File(directory, "conductarr-requests-saved.png").absolutePath) - } finally { model.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt deleted file mode 100644 index 7fcdf8c4..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.* - -/** Consumer-visible mappings exercise realistic wire responses over a local HTTP server. */ -class ConductarrProjectionTest { - @Test fun householdFeedAndCatalogueBecomePersonalRowsWithoutRawLibraryNamesOrHomeComingSoon() = runBlocking { - FakeConductarr().use { fake -> - ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> - val provider = ConductarrProvider(client) - assertEquals("Shared", provider.profiles()!!.profiles!!.first().name) - assertEquals(listOf("Shared Anime"), provider.audiences()!!.single().libraries) - val snapshot = provider.discover("shared").last() - assertEquals(DiscoverStatus.Ready, snapshot.status) - assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster) - assertEquals("Solaris", snapshot.seeds.single().title) - assertEquals(listOf(42L), snapshot.seeds.single().items.map { it.tmdb }) - assertEquals(listOf("New on Netflix", "Trending"), snapshot.lanes.take(2).map { it.title }) - assertTrue(snapshot.lanes.first { it.id == "genre-Comedy" }.pending) - assertEquals(99L, snapshot.hidden.single().tmdb) - assertEquals(8L, snapshot.services.single().id) - val rows = snapshot.homeRows() - assertEquals("Moon", rows.first { it.id == "pk-movie" }.cards.single().title) - assertFalse(rows.any { "Coming soon" in it.title || "Shared Anime" in it.title }) - assertTrue(fake.calls.filter { it.url.encodedPath == "/catalogue/discover" }.all { it.url.queryParameter("genre_id") == null }) - val expanded = provider.lanesThrough(99) - assertFalse(expanded.any { it.pending }) - assertTrue(fake.calls.any { it.url.queryParameter("genre_id") == "35" }) - assertEquals("Coming soon", expanded.last().title) - val service = provider.service(snapshot.services.single(), "tv") - assertEquals(listOf("Popular series", "New series", "Top rated series"), service.take(3).map { it.title }) - assertTrue(fake.calls.filter { it.url.queryParameter("service_id") == "8" }.any { it.url.queryParameter("media_type") == "tv" }) - val requests = provider.requests().last() - val request = requests.items.single() - assertEquals(ComingInStage.Downloading, request.stage) - assertEquals("Mindel", request.requesterName) - assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true))) - } - } - } - - @Test fun uncertainRequestIsLookedUpRatherThanSentTwiceAndSharedRemainsTheAuthenticatedActor() = runBlocking { - FakeConductarr().use { fake -> - ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> - val operations = ConductarrDiscoverOperations(client, { DiscoverSnapshot() }, profile = "shared", allowedRequesters = setOf("shared", "mindel")) - val intent = DiscoverRequest(DiscoverTitle(42, "movie", "Moon"), "mindel", "untrusted-ui-profile") - fake.postStatus = 503 - assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) - assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) - assertEquals(1, fake.calls.count { it.method == "POST" }) - val post = fake.calls.single { it.method == "POST" } - val body = post.body!!.utf8() - assertTrue(body.contains("\"requester\":\"mindel\"")) - assertTrue(body.contains("\"recommendation_profile\":\"shared\"")) - assertFalse(body.contains("actor")) - assertEquals(listOf("GET", "POST", "GET"), fake.calls.map { it.method }) - fake.existingRequests = true - val receipt = operations.check(intent) - assertEquals(RequestPhase.Receipt, receipt.phase) - assertEquals("Shared", receipt.actor) - assertEquals("mindel", receipt.requester) - assertEquals(RequestPhase.Refused, operations.request(intent.copy(requester = "outsider")).phase) - assertEquals(RequestPhase.Refused, operations.request(intent.copy(title = DiscoverTitle(44, "tv", "Dark"))).phase) - assertEquals(1, fake.calls.count { it.method == "POST" }) - assertTrue(operations.hide("untrusted-ui-profile", intent.title)) - assertTrue(operations.restore("untrusted-ui-profile", intent.title)) - assertTrue(fake.calls.takeLast(2).all { "/profiles/shared/dismissals" in it.url.encodedPath }) - } - } - } - - @Test fun episodeVotesReplaceSeriesScoreAndConditionalTitleReadsKeepTheOriginalBody() = runBlocking { - FakeConductarr().use { fake -> - ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> - val ratings = ConductarrRatings(client) - assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source }) - val episode = ratings.read(44, "episode", 1, 1)!! - assertEquals(listOf("rtCritics", "rtAudience", "tmdb"), episode.map { it.source }) - assertTrue(episode.last().episode) - assertEquals(8.1, episode.last().score, 0.0) - ratings.read(44, "episode", 1, 2) - assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") }) - assertFalse(fake.calls.any { it.url.encodedPath.endsWith("/enrichment") }) - val provider: ConductarrTitleProvider = ConductarrProvider(client) - val title = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved - fake.notModified = true - val revalidated = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved - assertEquals(title.value, revalidated.value) - assertEquals(title.savedAtMillis, revalidated.savedAtMillis) - assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"]) - fake.notModified = false - fake.ratingsStatus = 401 - assertEquals(emptyList(), ratings.read(45, "movie", null, null)) - assertEquals(emptyList(), ratings.read(42, "movie", null, null)) - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt deleted file mode 100644 index 3027d5fc..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.nio.file.Files -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.* - -/** Real sockets and reopened disk stores prove identity and authentication boundaries. */ -class ConductarrSessionTest { - @Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking { - val directory = Files.createTempDirectory("tv-session-").toFile() - try { - FakeConductarr().use { fake -> - ConductarrSession(FileCacheStore(directory)).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - val first = session.identity.value!! - assertEquals("shared", first.operator!!.requester) - assertEquals("Moon", first.provider.discover("shared").last().picks.single().title) - session.select("server:mindel", MINDEL_TOKEN, fake.url) - val second = session.identity.value!! - assertNotSame(first.client, second.client) - assertFalse(second.signedIn) - assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - session.authenticate() - assertEquals("Mindel", session.identity.value!!.operator!!.name) - val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" } - assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"]) - session.select("server:other", SHARED_TOKEN, fake.url) - assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - session.select("server:shared", SHARED_TOKEN, fake.url) - assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester) - session.select("server:shared", null, fake.url) - assertNull(session.identity.value) - } - fake.calls.forEach { request -> - assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\"")) - listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) } - assertFalse(request.url.toString().contains(SHARED_TOKEN)) - } - } - assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) }) - } finally { directory.deleteRecursively() } - } - - @Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking { - val directory = Files.createTempDirectory("tv-offline-").toFile() - try { - FakeConductarr().use { fake -> - ConductarrSession(FileCacheStore(directory)).use { original -> - original.select("server:shared", SHARED_TOKEN, fake.url) - original.authenticate() - original.identity.value!!.provider.discover("shared").toList() - } - ConductarrSession(FileCacheStore(directory)).use { reopened -> - reopened.select("server:shared", SHARED_TOKEN, fake.url) - fake.status = 503 - reopened.authenticate() - assertTrue(reopened.identity.value!!.signedIn) - val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList() - assertEquals("Moon", snapshots.first().picks.single().title) - assertEquals("Moon", snapshots.last().picks.single().title) - assertNotNull(snapshots.last().savedAt) - fake.status = 200 - fake.ratingsStatus = 401 - val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null) - assertTrue(ratings!!.isEmpty()) - assertFalse(reopened.identity.value!!.signedIn) - assertNull(reopened.identity.value!!.operator) - assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error) - assertEquals(DiscoverStatus.NeedsSignIn, reopened.identity.value!!.provider.discover("shared").last().status) - fake.ratingsStatus = 200 - reopened.select("server:shared", SHARED_TOKEN, fake.url, retry = true) - reopened.authenticate() - assertTrue(reopened.identity.value!!.signedIn) - } - } - } finally { directory.deleteRecursively() } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt deleted file mode 100644 index 265dcbd9..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import kotlinx.coroutines.runBlocking -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 -import org.robolectric.annotation.GraphicsMode - -/** The shipped host reads a local server and draws real data, dwell ratings and 401 recovery. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ConductarrSurfaceTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply { - pickerVisible = false - enterDestination("discover") - } - lateinit var current: DiscoverState - val art = ImageBitmap(1, 1) - rule.setContent { - val identity by session.identity.collectAsState() - val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false) - current = state - CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) { - WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } - } - } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - current.snapshot.status == DiscoverStatus.Ready - } - rule.onNodeWithText("Moon").assertIsDisplayed() - rule.mainClock.autoAdvance = false - rule.runOnIdle { - val lane = current.lanes.indexOfFirst { it.titles.any { title -> title.tmdb == 43L } } - current.focus(current.firstShelf + lane, 0) - } - rule.mainClock.advanceTimeByFrame() - rule.waitForIdle() - rule.mainClock.advanceTimeBy(100) - org.junit.Assert.assertFalse(fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }) - rule.mainClock.advanceTimeBy(500) - rule.waitUntil(10_000) { - rule.mainClock.advanceTimeByFrame() - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" } - } - rule.waitForIdle() - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) - directory.mkdirs() - rule.onRoot().captureRoboImage(File(directory, "conductarr-real-provider.png").absolutePath) - fake.ratingsStatus = 401 - runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } - rule.mainClock.autoAdvance = true - rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.NeedsSignIn } - rule.onNodeWithText("Discover needs sign-in").assertIsDisplayed() - org.junit.Assert.assertFalse(current.canRequest) - rule.onRoot().captureRoboImage(File(directory, "conductarr-401.png").absolutePath) - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt deleted file mode 100644 index 3c77fa66..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -// 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.Json -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.Rating -import org.justinvisser.wolfskin.design.* - -class ConductarrTitlePagesTest { - @Test fun catalogueEvidenceNeverGrantsPlaybackAndOfflinePagesKeepTheirLastKnownGoodFacts() = runBlocking { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - val identity = session.identity.value!! - identity.provider.audiences() - val pages = ConductarrTitlePages(identity) - val live = pages.title(42, MediaType.MOVIE).last() - val state = TitleState(live, ShellPerson("shared", "Shared", "S", true)) {} - assertEquals(97 * 60L, live.item.runtimeSeconds) - assertEquals(listOf("Comedy"), live.item.genres) - assertEquals("216", live.cast.single().id) - assertEquals("Sam Bell", live.cast.single().character) - assertEquals("movie:43", live.related.single().id) - assertFalse(live.item.held) - assertNull(live.playTarget) - assertFalse(state.options.any { it.id in listOf("play", "watched", "version") }) - assertTrue(state.options.any { it.id == "request" }) - val actor = pages.person(216).last() - assertEquals("Actor.", actor.person!!.biography) - assertEquals("Sam Bell", actor.credits.single().character) - assertFalse(actor.credits.single().held) - assertFalse(actor.hasMore) - fake.status = 503 - val saved = pages.title(42, MediaType.MOVIE).last() - assertEquals(live.item, saved.item) - assertEquals(live.cast, saved.cast) - assertEquals(TitleConnection.Offline, saved.connection) - assertNotNull(saved.savedAt) - assertFalse(TitleState(saved, state.person) {}.options.any { it.id == "request" }) - } - } - } - - @Test fun onlyResolvedJellyfinEpisodesArePlayableAndTheirObservedVotesReplaceSeriesScores() = runBlocking { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - fake.seasonTwoScore = 7.2 - val episode = TitleItem("00000000-0000-0000-0000-000000000012", "Episode", "episode", held = true, season = 1, episode = 1) - val held = TitlePageData(TitleItem("00000000-0000-0000-0000-000000000011", "Dark", "tv", held = true, seasons = 2), - playTarget = episode, watchTarget = episode, episodes = listOf(episode.copy(id = "00000000-0000-0000-0000-000000000013", season = 2)), selectedSeason = 2) - val page = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last() - assertEquals(episode.id, page.playTarget!!.id) - assertEquals(episode.id, page.watchTarget.id) - assertEquals(held.item.id, page.item.id) - assertEquals(44L, page.episodes.single().tmdbId) - assertTrue(page.episodes.single().ratings.last().episode) - assertEquals(8.1, page.playTarget!!.ratings.last().score, 0.0) - assertEquals(7.2, page.episodes.single().ratings.last().score, 0.0) - assertFalse(page.item.ratings.any { it.episode }) - fake.status = 401 - val revoked = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last() - assertFalse(session.identity.value!!.signedIn) - assertEquals(TitleConnection.SignedOut, revoked.connection) - assertTrue(revoked.item.ratings.isEmpty()) - assertTrue(revoked.playTarget!!.ratings.isEmpty()) - assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) - } - } - } - - @Test fun ratingsUseObservedLabelsPrecedenceAndPositiveVoteCountsWithoutDiscardingRealZeroes() { - fun ratings(json: String) = Json.decodeFromString>(json).screenRatings("tv") - assertEquals(listOf("rtCritics"), ratings("""[{"source":"rt_critics","state":"observed","score":0},{"source":"tmdb","state":"observed","score":9,"votes":20}]""").map { it.source }) - assertNull(ratings("""[{"source":"rt_critics","state":"observed","score":99,"status":"unknown"}]""").single().label) - assertTrue(ratings("""[{"source":"tmdb","state":"observed","score":7,"votes":0},{"source":"imdb","state":"observed","score":8,"votes":10}]""").isEmpty()) - assertEquals(0.0, ratings("""[{"source":"tmdb","state":"observed","score":0,"votes":1}]""").single().score, 0.0) - assertTrue(emptyList().withEpisode(0.0, 1).single().episode) - assertTrue(emptyList().withEpisode(8.0, null).isEmpty()) - assertTrue(emptyList().withEpisode(8.0, 0).isEmpty()) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt deleted file mode 100644 index e41862d8..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.mockk -import java.io.File -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.* -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 - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ConductarrTitleSurfaceTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun realTitleOffersNoFakePlaybackAndSharedRequestForMindelHasARealReceipt() { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val facade = mockk() - coEvery { facade.findHeld(any(), any()) } returns null - val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true)) - try { - val route = titleRoute("movie:42") - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { - pickerVisible = false; pickerLaunch = false; enterDestination(route) - } - val art = mutableMapOf() - fun decode(element: JsonElement) { - when (element) { - is JsonObject -> element.values.forEach(::decode) - is JsonArray -> element.forEach(::decode) - is JsonPrimitive -> element.contentOrNull?.takeIf { it.startsWith("http") || it.startsWith('/') }?.let { art[catalogueImage(it)!!] = ImageBitmap(1, 1) } - } - } - decode(fake.wire) - rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { - TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false) - } } } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" } - } - rule.onNodeWithText("Moon").assertIsDisplayed() - assertFalse(viewModel.title!!.options.any { it.id == "play" }) - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } - rule.onNodeWithText("Request for Mindel").assertIsDisplayed() - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - viewModel.title?.data?.requestActor == "Shared" - } - assertEquals("Mindel", viewModel.title!!.data.requestFor) - rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed() - val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" } - val body = Json.parseToJsonElement(post.body!!.utf8()).jsonObject - assertEquals("mindel", body.getValue("requester").jsonPrimitive.content) - assertFalse(body.containsKey("actor")) - rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } - fake.ratingsStatus = 401 - runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable - } - assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) - assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) - } finally { viewModel.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt deleted file mode 100644 index 41d4fffb..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTruthCaptureTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -// 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/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt index d2530d4f..b18f6a22 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt @@ -63,6 +63,7 @@ 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/DiscoverSeedProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt deleted file mode 100644 index 0bd5e342..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSeedProjectionTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -// 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/DiscoverSnapshotReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt deleted file mode 100644 index cd7d4208..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -// 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/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt deleted file mode 100644 index 9fbbeaa0..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 647b0388..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.io.Closeable -import java.util.concurrent.CopyOnWriteArrayList -import kotlinx.serialization.json.* -import mockwebserver3.Dispatcher -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -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 { - val server = MockWebServer() - val calls = CopyOnWriteArrayList() - val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject - @Volatile var status = 200 - @Volatile var postStatus = 200 - @Volatile var existingRequests = false - @Volatile var notModified = false - @Volatile var ratingsStatus = 200 - @Volatile var audiencesStatus = 200 - @Volatile var emptyAudiences = false - @Volatile var seasonTwoScore = 8.1 - @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=") - - init { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - calls.add(request) - if (status != 200) return response("", status) - 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()) - val key = when { - path == "/api/v1/operator" -> "operator" - path == "/api/v1/recommendations/profiles" -> "profiles" - path == "/curation/library-audiences" -> "audiences" - path.endsWith("/feed") -> "feed" - path.endsWith("/dismissals") -> "dismissals" - path == "/catalogue/options" -> "options" - path == "/catalogue/discover" -> "discover" - path.endsWith("/ratings") -> "ratings" - path.endsWith("/cast") -> "cast" - path.endsWith("/related") -> "discover" - path.endsWith("/enrichment") -> "enrichment" - path.endsWith("/display") -> "display" - path.contains("/seasons/") -> "season" - path.endsWith("/filmography") -> "filmography" - path.startsWith("/catalogue/performers/") -> "performer" - path.startsWith("/catalogue/titles/") -> "detail" - path.startsWith("/api/v1/activity/titles/") -> "readiness" - path.startsWith("/api/v1/requests/titles/") -> "requests" - path == "/api/v1/activity" -> "activity" - else -> return response("", 404) - } - 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 - if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList()))) - 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"))))))) - } - if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2))) - if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map { - JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore))) - }))) - val responseBody = if (localArt) localizeImages(body).toString() else body.toString() - return response(responseBody) - } - } - server.start() - } - - val url: String get() = server.url("/").toString() - private fun localizeImages(element: JsonElement): JsonElement = when (element) { - is JsonObject -> JsonObject(element.mapValues { (key, value) -> - if (key in setOf("poster_url", "poster_path", "backdrop_url", "backdrop_path", "profile_url") && value is JsonPrimitive && value.contentOrNull != null) - JsonPrimitive(server.url("/image").toString()) else localizeImages(value) - }) - is JsonArray -> JsonArray(element.map(::localizeImages)) - else -> element - } - private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build() - override fun close() = server.close() -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt deleted file mode 100644 index 07cd1e37..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -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.After -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 - -/** 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() - 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.updateFixtures(false) - WolfskinScreenPreference.enabled = true - 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.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.waitForIdle() - 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.waitForIdle() - try { - rule.waitUntil(timeoutMillis = 15_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - rule.onAllNodes(hasTestTag("library-grid") and - hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty() - } - } 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() - 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() } - } - } - } -} 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 128dae1e..864c1743 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -32,8 +32,6 @@ 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,13 +41,11 @@ class HomeRecoveryTest { val started = CompletableDeferred() val response = CompletableDeferred() val facade = mockk() - coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } - coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } + coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() } + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists()) + val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) - assertFalse(model.snapshot.rows.first().watchReady) response.completeExceptionally(IOException("fixture offline")) refresh.await() assertFalse(model.snapshot.jellyfinAvailable) @@ -64,14 +60,13 @@ class HomeRecoveryTest { cache.write("${server.id}:${personA.id}", library, 1000L) val startedA = CompletableDeferred() val responseA = CompletableDeferred() - coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } - coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") - coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } + 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()) + val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") } startedA.await() assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) - model.loadLibrary(server, personB, viewerB) + model.loadLibrary(server, personB, "mindel") 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,30 +78,14 @@ class HomeRecoveryTest { @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { val facade = mockk() - coEvery { facade.readWatch(personA.id) } returns library - coEvery { facade.readLibrary(any()) } returns HomeLibrary() + coEvery { facade.read(personA.id) } returns library val cache = mockk() 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, viewerA) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists()) + model.loadLibrary(server, personA, "justin") 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.readWatch(personA.id) } returns library - coEvery { facade.readLibrary(any()) } returns HomeLibrary() - 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/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index f8322b5f..47f843b2 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest { } val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists) val facade = mockk() rule.setContent { val destination = navigation.backStack.last() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt deleted file mode 100644 index 976f9588..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -// 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 - } - rule.runOnIdle { - 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 deleted file mode 100644 index a2fb8b0d..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -// 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.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 4e2917c3..5363848c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -2,7 +2,6 @@ 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 @@ -21,7 +20,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") { flowOf(items) }.toList() + store.read("server:justin", "movies") { 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 @@ -40,46 +39,11 @@ class LibrarySnapshotStoreTest { @Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking { val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } - store.read("server:justin", "movies") { flowOf(emptyList()) }.toList() + store.read("server:justin", "movies") { emptyList() }.toList() val current = listOf(LibraryTitle("b", "New Film", "movie")) - val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList() + val reads = store.read("server:justin", "movies", force = true) { current }.toList() assertTrue(reads.first().items.isEmpty()) assertEquals(current, reads.last().items) assertFalse(reads.last().unreachable) } - - @Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking { - var now = 1000L - val store = LibrarySnapshotStore(MemoryCacheStore()) { now } - val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f)) - store.read("server:justin", "movies") { flowOf(old) }.toList() - store.read("server:mindel", "movies") { flowOf(old) }.toList() - now = 1002L - val updated = listOf(old.single().copy(progress = .6f)) - val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList() - assertEquals(.1f, reads.first().items.single().progress) - assertEquals(.6f, reads.last().items.single().progress) - assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items) - assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items) - } - - @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/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt deleted file mode 100644 index 5f2aa9e7..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -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/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt deleted file mode 100644 index 95fcbb52..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -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) { - // Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition. - androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() - 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/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt deleted file mode 100644 index 6a5936f8..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ /dev/null @@ -1,253 +0,0 @@ -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.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( - org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) - coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() - 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/PlaybackUserData.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt deleted file mode 100644 index b8e86ca0..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import org.jellyfin.sdk.model.api.UserItemDataDto -import java.util.UUID - -/** Checkpoints use the SDK's required identity and watch fields. */ -internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto = - UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false, - lastPlayedDate = null, played = played, key = "", itemId = itemId) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index dca356a7..69fbe6ba 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -52,13 +52,13 @@ class PlayerHandoffTest { @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 shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } - model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists()) } @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") val title = snapshot.rows.first().cards.first() - coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline") + coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline") rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } @@ -80,16 +80,14 @@ class PlayerHandoffTest { @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val id = UUID.fromString("00000000-0000-0000-0000-000000000003") - coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( - id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, - userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) + coEvery { facade.playableId(user.id, title) } returns id val view = shell.view view.cards["hero"] = 0 - rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } + rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) } awaitModel { navigation.backStack.last() is Destination.Playback } val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) - assertEquals(1_136_274L, destination.positionMs) + assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs) assertTrue(destination.fullScreen) navigation.goBack() assertTrue(navigation.backStack.single() is Destination.Home) @@ -101,11 +99,11 @@ class PlayerHandoffTest { @Test fun anotherPersonsSessionCannotResolveOrStartPlayback() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val otherUser = user.copy(name = "Justin") - rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) } + rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) } awaitModel { model.toast != null } assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) assertTrue(navigation.backStack.single() is Destination.Home) - coVerify(exactly = 0) { facade.preparePlayback(any(), any()) } + coVerify(exactly = 0) { facade.playableId(any(), any()) } } private fun awaitModel(condition: () -> Boolean) { rule.waitUntil(5000) { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt deleted file mode 100644 index 1fb29d31..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -// 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.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinServerUsers -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import io.mockk.coEvery -import io.mockk.mockk -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.cancel -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.PeopleStatus -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The actual picker displays every saved login while household revalidation has no response. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class SavedPeoplePickerTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun savedPeopleAreVisibleBeforeTheFirstNetworkResponse() { - ConductarrPreference.initialize(rule.activity) - val previousUrl = ConductarrPreference.baseUrl - val previousFixtures = ConductarrPreference.fixtures - val release = CountDownLatch(1) - val entered = AtomicBoolean(false) - FakeConductarr().use { fake -> - fake.beforeResponse = { entered.set(true); release.await() } - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrPreference.updateFixtures(false) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Saved server", 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) - } - val repository = mockk(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) - val libraries = mockk(relaxed = true) - coEvery { libraries.readDestinations(any()) } returns emptyMap() - val session = WolfskinSession() - val navigation = NavigationManager() - val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - try { - rule.setContent { - WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - viewModel = model, homeContent = { _, _, _, _ -> }) - } - rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 } - users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } - rule.runOnIdle { - assertTrue(session.state!!.pickerVisible) - assertEquals(PeopleStatus.Ready, session.state!!.peopleStatus) - assertEquals(users.map { it.id.toString() }, session.state!!.people.map { it.jellyfinUserId }) - assertEquals(listOf("justin", "mindel", "shared"), session.state!!.people.map { it.key }) - assertEquals(1L, release.count) - } - val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(captures, "saved-people-before-network.png")) - } finally { - release.countDown() - model.viewModelScope.cancel() - ConductarrPreference.updateBaseUrl(previousUrl) - ConductarrPreference.updateFixtures(previousFixtures) - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt deleted file mode 100644 index dbcffdb4..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -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 savedPeopleStarted = CompletableDeferred() - val repository = mockk(relaxed = true) - 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() - 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 { - 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) - 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/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 9809967f..8316538b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -1,7 +1,6 @@ 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 @@ -36,7 +35,6 @@ 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 @@ -57,7 +55,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) val shell = ShellState(listOf(person), person.key).apply { @@ -96,7 +94,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = personRoute(id.toString()) val shell = ShellState(listOf(viewer), viewer.key).apply { @@ -139,7 +137,7 @@ class TitleAsyncFocusTest { reads.incrementAndGet() throw java.io.IOException("offline") } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) val shell = ShellState(listOf(viewer), viewer.key).apply { @@ -154,10 +152,7 @@ class TitleAsyncFocusTest { assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id) rule.runOnIdle { viewModel.title!!.toast = null } press(Key.DirectionCenter) - rule.waitUntil(timeoutMillis = 5000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - reads.get() == 2 && viewModel.title?.toast != null - } + rule.waitUntil(timeoutMillis = 5000) { 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/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 55f9d747..90d6fcc1 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, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) } } } return requests.distinctBy { it.first }.associate { (path, width, height) -> @@ -215,5 +215,4 @@ 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/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 0b6dac33..6df8426d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -11,9 +11,6 @@ import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasText import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.text.TextLayoutResult @@ -65,27 +62,6 @@ class TitleKeysTest { } } } - @Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() { - render("Andor") { page -> - page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29, - playTarget = page.playTarget!!.copy(season = 29)) - } - press(Key.DirectionDown) - assertEquals("seasons", state.row) - assertEquals(28, state.column) - rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed() - press(Key.DirectionLeft) - assertEquals(27, state.column) - rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed() - press(Key.DirectionUp) - press(Key.DirectionDown) - assertEquals(27, state.column) - press(Key.DirectionCenter) - assertEquals(28, state.season) - assertEquals(28, state.requestedSeason) - assertEquals(TitleAction.Season(28), effects.last()) - } - private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } private fun replay(end: String): Int { val path = paths.single { it.getString("end") == end } @@ -336,6 +312,11 @@ class TitleKeysTest { assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) } + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() { + render("Andor") + assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) + } @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { render(transform = { data -> diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt deleted file mode 100644 index 75c92ed5..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -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 io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.junit.Assert.* -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.RuntimeEnvironment -import org.robolectric.annotation.Config -import java.util.UUID - -/** Production title actions preserve parents and resolve live playback instead of cached offsets. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class TitlePlaybackNavigationTest { - private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") - private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") - private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") - private val person = ShellPerson("justin", "Justin", "J", false) - private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true) - private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12) - private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2) - - @Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val navigation = NavigationManager() - val facade = mockk() - coEvery { facade.readSaved(any(), any()) } returns null - coEvery { facade.read(any(), any(), any()) } coAnswers { - val id = firstArg() - TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()), - episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) - } - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) - fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) - try { - navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) - open(seriesId) - advanceUntilIdle() - model.title!!.click("episodes", 0) - assertEquals(3, navigation.backStack.size) - assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId) - open(firstId) - advanceUntilIdle() - for (id in listOf(secondId, firstId, secondId)) { - model.title!!.click("episodes", if (id == firstId) 0 else 1) - assertEquals(3, navigation.backStack.size) - assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId) - open(id) - advanceUntilIdle() - } - model.title!!.click("related", 0) - assertEquals(4, navigation.backStack.size) - navigation.goBack() - model.title!!.click("cast", 0) - assertEquals(4, navigation.backStack.size) - navigation.goBack() - model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }) - assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList()) - navigation.goBack() - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString()))) - open(firstId) - advanceUntilIdle() - model.title!!.click("episodes", 1) - navigation.goBack() - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - } finally { - model.viewModelScope.cancel() - session.close() - Dispatchers.resetMain() - } - } - - @Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val navigation = NavigationManager() - val facade = mockk() - coEvery { facade.readSaved(any(), any()) } returns null - coEvery { facade.read(any(), any(), any()) } coAnswers { - TitlePageData(series, first, watchTarget = if (firstArg() == seriesId) series else first) - } - coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE, - userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2)) - coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, - userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) - try { - model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) - advanceUntilIdle() - model.title!!.click("header", 0) - advanceUntilIdle() - assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) - navigation.goBack() - model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) - advanceUntilIdle() - assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) - navigation.goBack() - model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) - advanceUntilIdle() - model.title!!.click("header", 0) - advanceUntilIdle() - assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) - coVerify(exactly = 1) { facade.preparePlayback(seriesId) } - } finally { - model.viewModelScope.cancel() - session.close() - Dispatchers.resetMain() - } - } -} 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 7ea534e6..1a672f67 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -33,8 +33,6 @@ import com.github.damontecres.wholphin.test.TestTracks import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.After import org.junit.Before @@ -83,7 +81,6 @@ 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) } @@ -106,31 +103,8 @@ 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).copy(userData = playbackUserData(firstId, 200_000_000L))) + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) facade.read(firstId, null, preferences) coEvery { library.getItem(any()) } throws java.io.IOException("offline") val saved = facade.readSaved(firstId, preferences)!! @@ -141,7 +115,6 @@ class TitleReadFacadeTest { assertEquals(TitleConnection.SignedOut, saved.connection) assertTrue(saved.item.ratings.isEmpty()) assertEquals(firstId.toString(), saved.watchTarget.id) - assertFalse(screen.options.any { it.id == "play" || it.id == "restart" }) } @Test fun diskCopiesNeverCrossViewerIdentity() = runTest { @@ -191,18 +164,6 @@ class TitleReadFacadeTest { assertEquals(secondId.toString(), saved.credits.single().id) } - @Test fun cataloguePlaybackLookupRejectsAnotherMediaTypeOrTmdbIdentity() = runTest { - var payload = """{"Items":[{"Id":"$firstId","Type":"Series","ProviderIds":{"Tmdb":"42"}}]}""" - coEvery { api.request(any(), any(), any(), any(), any()) } coAnswers { - org.jellyfin.sdk.api.client.RawResponse(org.json.JSONObject(payload).put("TotalRecordCount", 1).put("StartIndex", 0).toString().toByteArray(), 200, emptyMap()) - } - assertNull(facade.findHeld(42, "movie")) - payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"43"}}]}""" - assertNull(facade.findHeld(42, "movie")) - payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"42"}}]}""" - assertEquals(firstId, facade.findHeld(42, "movie")) - } - @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest { val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1) @@ -221,43 +182,6 @@ class TitleReadFacadeTest { assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } - @Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest { - val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42")) - val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1) - coEvery { library.getItem(seriesId) } returns successResponse(series) - coEvery { library.getItem(firstId) } returns successResponse(episode) - coEvery { shows.getNextUp(any()) } coAnswers { - assertTrue(firstArg().fields.orEmpty().isEmpty()) - successQueryResult(emptyList()) - } - coEvery { items.getItems(any()) } returns successQueryResult( - listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3))) - coEvery { shows.getEpisodes(any()) } coAnswers { - val fields = firstArg().fields.orEmpty() - assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) }) - successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis"))) - } - val page = facade.read(seriesId, 3, preferences) - assertEquals(firstId.toString(), page.playTarget?.id) - assertEquals("MKV", page.versions.single().container) - assertEquals(listOf(3), page.versions.single().audio.map { it.index }) - val saved = requireNotNull(facade.readSaved(seriesId, preferences)) - assertEquals(page.versions, saved.versions) - } - - @Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest { - coEvery { library.getItem(firstId) } returns successResponse( - BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor")) - coEvery { items.getItems(any()) } coAnswers { - val fields = firstArg().fields.orEmpty() - assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) }) - assertTrue(ItemFields.PEOPLE in fields) - successQueryResult(listOf(file(secondId).copy(people = listOf( - org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef"))))) - } - assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character) - } - @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { playback.savePlayVersion(any(), any()) } returns null diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt index 5c55f1fc..d9e88778 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -112,7 +112,6 @@ class UpstreamContentFocusTest { val state = shellState() val model = mockk(relaxed = true) every { model.session } returns WolfskinSession().also { it.state = state } - every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) every { model.libraryDestinations } returns emptyMap() // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. every { model.signedInPerson } returns "unavailable" diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt deleted file mode 100644 index a7901727..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.content.Context -import com.github.damontecres.wholphin.WholphinApplication -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.ImageUrlService -import com.github.damontecres.wholphin.ui.successQueryResult -import com.github.damontecres.wholphin.ui.successResponse -import io.mockk.* -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.* -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.operations.ItemsApi -import org.jellyfin.sdk.api.operations.TvShowsApi -import org.jellyfin.sdk.api.operations.UserLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.BaseItemKind -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.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import java.util.UUID -import com.github.damontecres.wholphin.util.WholphinDispatchers -import com.github.damontecres.wholphin.util.configure -import com.github.damontecres.wholphin.util.reset - -/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class WatchReadTest { - private val api = mockk() - private val library = mockk() - private val shows = mockk() - private val items = mockk() - private val images = mockk(relaxed = true) - private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e") - private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") - private val json = Json { ignoreUnknownKeys = true } - // Selected fields from the read-only Resume and NextUp replies, without credentials. - private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}""" - private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}""" - private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first() - - @Before fun setup() { - every { api.userLibraryApi } returns library - every { api.tvShowsApi } returns shows - every { api.itemsApi } returns items - mockkObject(WholphinApplication.Companion) - every { WholphinApplication.instance } returns mockk { - every { resources } returns RuntimeEnvironment.getApplication().resources - } - } - - @After fun teardown() { unmockkObject(WholphinApplication.Companion) } - - @Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest { - val resume = decode(resumeWire) - val next = decode(nextWire) - val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES) - coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series) - coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume) - coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next) - coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next)) - val home = HomeReadFacade(api, images, mockk()) - val repository = mockk { - every { currentUser } returns mockk { every { id } returns userId } - } - val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk()) - val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0) - assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) - assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs()) - assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id) - val selected = title.preparePlayback(seriesId)!! - assertEquals(next.id, selected.id) - assertEquals(1_136_274L, selected.playbackPositionMs()) - coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } - val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true)) - coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed) - assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) - assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played) - } - - @Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest { - WholphinDispatchers.configure(StandardTestDispatcher(testScheduler)) - val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null) - val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString()) - val resume = decode(resumeWire) - val next = decode(nextWire) - coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume)) - coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next)) - val facade = spyk(HomeReadFacade(api, images, mockk())) - val releaseLibrary = CompletableDeferred() - coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") } - val cache = mockk(relaxed = true) - every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) - try { - val loading = launch { model.loadLibrary(server, user, person) } - runCurrent() - assertFalse(loading.isCompleted) - assertTrue(model.snapshot.rows.first().watchReady) - assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) - releaseLibrary.complete(Unit) - loading.join() - assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) - assertTrue(model.snapshot.jellyfinAvailable) - } finally { - model.viewModelScope.cancel() - session.close() - WholphinDispatchers.reset() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt deleted file mode 100644 index 494c8412..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.viewModelScope -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.util.PlaybackWatchState -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.cancel -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.HomeLibraryCache -import org.justinvisser.wolfskin.tv.read.HomeReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import java.util.UUID -import java.util.concurrent.atomic.AtomicReference - -/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class WatchReturnTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() { - val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null) - val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString()) - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false } - val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9) - val current = AtomicReference(episode) - val facade = mockk() - every { facade.audiences } returns null - coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) } - coEvery { facade.readLibrary(user.id) } returns HomeLibrary() - val cache = mockk(relaxed = true) - every { cache.read(any()) } returns null - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) - val oldFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(false) - try { - rule.setContent { WolfskinTheme { - ShellSurface(shell) { HomeDestination(shell, server, user, it, model) } - } } - rule.waitUntil(5000) { - shadowOf(Looper.getMainLooper()).idle() - model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0 - } - val view = shell.rememberedView("home") - rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) } - PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}") - rule.waitUntil(5000) { - shadowOf(Looper.getMainLooper()).idle() - model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274 - } - assertSame(view, shell.rememberedView("home")) - assertEquals(0, view.cards["hero"]) - assertTrue(model.snapshot.rows.first().watchReady) - } finally { - model.viewModelScope.cancel() - session.close() - ConductarrPreference.updateFixtures(oldFixtures) - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt deleted file mode 100644 index 9e4d1118..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.graphics.Bitmap -import androidx.activity.ComponentActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import coil3.request.SuccessResult -import coil3.imageLoader -import coil3.SingletonImageLoader -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.ui.CoilConfig -import io.mockk.every -import io.mockk.mockk -import java.io.ByteArrayOutputStream -import java.util.UUID -import java.util.concurrent.CopyOnWriteArrayList -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.OkHttpClient -import okio.Buffer -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.WolfskinImages -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class WolfskinArtworkTest { - @get:Rule val rule = createAndroidComposeRule() - - @OptIn(coil3.annotation.DelicateCoilApi::class) - @Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() { - MockWebServer().use { jellyfin -> - MockWebServer().use { artwork -> - jellyfin.start(); artwork.start() - val context = rule.activity - val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\"" - val authenticated = OkHttpClient.Builder().addInterceptor { chain -> - chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build()) - }.build() - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null) - val repository = mockk { every { currentServer } returns server } - val network = WolfskinArtwork(authenticated, repository) - val bytes = ByteArrayOutputStream().also { - Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it) - }.toByteArray() - jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) - artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) - artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) - val urls = listOf(jellyfin.url("/jellyfin.png"), - artwork.url("/public.png").newBuilder().host("127.0.0.1").build(), - artwork.url("/another-port.png")) - val results = CopyOnWriteArrayList() - val previousParent = context.applicationContext.imageLoader - try { - // Other screen tests may already have created Coil's process singleton. - SingletonImageLoader.reset() - rule.setContent { - CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false) - LaunchedEffect(network) { - // Force the upstream authenticated parent to exist before configuring Wolfskin. - context.applicationContext.imageLoader - network.configure(context) - val loader = WolfskinImages.loader(context) - for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString()) - .memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build())) - } - } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - results.size == urls.size - } - results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) } - assertEquals(credential, jellyfin.takeRequest().headers["Authorization"]) - assertNull(artwork.takeRequest().headers["Authorization"]) - assertNull(artwork.takeRequest().headers["Authorization"]) - } finally { - WolfskinImages.loader(context).shutdown() - val testParent = context.applicationContext.imageLoader - SingletonImageLoader.setUnsafe(previousParent) - testParent.shutdown() - authenticated.dispatcher.executorService.shutdown() - authenticated.connectionPool.evictAll() - } - } - } - } -} diff --git a/app/src/test/resources/conductarr/activity-history.json b/app/src/test/resources/conductarr/activity-history.json deleted file mode 100644 index 69a29b75..00000000 --- a/app/src/test/resources/conductarr/activity-history.json +++ /dev/null @@ -1,2568 +0,0 @@ -{ - "$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 deleted file mode 100644 index ffb957ab..00000000 --- a/app/src/test/resources/conductarr/mindel-feed.json +++ /dev/null @@ -1,8143 +0,0 @@ -{ - "$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/test/resources/conductarr/wire.json b/app/src/test/resources/conductarr/wire.json deleted file mode 100644 index 2893b49a..00000000 --- a/app/src/test/resources/conductarr/wire.json +++ /dev/null @@ -1,621 +0,0 @@ -{ - "operator": { - "capabilities": { - "administer": false, - "manage_library": false, - "manage_memberships": false, - "recommendation_profiles": [ - "shared" - ], - "request_media": true, - "requesters": [ - "shared", - "mindel" - ], - "watch_users": [ - "shared" - ] - }, - "name": "Shared", - "recommendation_profile": "shared", - "requester": "shared", - "role": "household", - "subject": "jellyfin:shared", - "watch_user": "shared" - }, - "profiles": { - "profiles": [ - { - "key": "shared", - "name": "Shared" - }, - { - "key": "mindel", - "name": "Mindel" - } - ], - "default_profile": "shared" - }, - "audiences": { - "audiences": [ - { - "is_anime": true, - "libraries": [ - "Shared Anime" - ], - "media_type": "tv", - "owner": "shared", - "users": [ - "shared", - "mindel" - ] - } - ] - }, - "feed": { - "profile": { - "key": "shared", - "name": "Shared" - }, - "projection": { - "coverage": { - "complete": true, - "excluded": 0, - "matched": 1, - "total": 1, - "unresolved": 0 - }, - "facts": [], - "genre_id": 0, - "media_type": "all", - "service_id": 0, - "unresolved": [] - }, - "state": "current", - "outcome": "ranked", - "materialization": "current", - "movies": [ - { - "backdrop_path": "/moon-wide.jpg", - "contributions": [ - { - "added": false, - "media_type": "movie", - "title": "Solaris", - "tmdb_id": 33, - "watched": true - } - ], - "genre_ids": [ - 35 - ], - "media_type": "movie", - "overview": "An astronaut reaches the end of his assignment.", - "poster_path": "/moon.jpg", - "score": 4.2, - "title": "Moon", - "tmdb_id": 42, - "tvdb_id": 0, - "year": 2009 - } - ], - "shows": [], - "expires_at": "2099-11-05T12:00:00Z" - }, - "options": { - "genres": [ - { - "id": 35, - "name": "Comedy" - }, - { - "id": 16, - "name": "Animation" - } - ], - "media_type": "movie", - "observations": [], - "region": "NL", - "services": [ - { - "id": 8, - "name": "Netflix" - } - ], - "state": "observed" - }, - "discover": { - "items": [ - { - "tmdb_id": 43, - "media_type": "movie", - "title": "Arrival", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - } - ], - "media_type": "movie", - "observations": [], - "page": 1, - "query": "", - "source": "general", - "state": "observed", - "total_pages": 2, - "total_results": 24 - }, - "dismissals": { - "profile": "shared", - "dismissals": [ - { - "hidden_at": "2026-11-05T12:00:00Z", - "hidden_by": "shared", - "media_type": "movie", - "poster_path": "/hidden.jpg", - "title": "Hidden film", - "tmdb_id": 99, - "year": 2017 - } - ] - }, - "ratings": { - "ratings": [ - { - "source": "rt_critics", - "state": "observed", - "score": 90, - "status": "Certified Fresh" - }, - { - "source": "rt_audience", - "state": "observed", - "score": 89, - "status": "Upright" - }, - { - "source": "imdb", - "state": "observed", - "score": 7.8, - "votes": 300000 - }, - { - "source": "tmdb", - "state": "observed", - "score": 7.6, - "votes": 4000 - } - ] - }, - "detail": { - "genres": [ - "Comedy" - ], - "metadata": { - "genre_state": "observed", - "genres": [ - { - "id": 35, - "name": "Comedy" - } - ], - "media_type": "movie", - "provider_state": "observed", - "providers": [], - "region": "NL", - "tmdb_id": 42 - }, - "metadata_state": "observed", - "observations": [], - "ratings": [ - { - "source": "rt_critics", - "state": "observed", - "score": 90, - "status": "Certified Fresh" - }, - { - "source": "rt_audience", - "state": "observed", - "score": 89, - "status": "Upright" - }, - { - "source": "imdb", - "state": "observed", - "score": 7.8, - "votes": 300000 - }, - { - "source": "tmdb", - "state": "observed", - "score": 7.6, - "votes": 4000 - } - ], - "runtime": 97, - "seasons": [ - { - "number": 1, - "name": "Season 1", - "overview": "A missing child.", - "episode_count": 10, - "recorded_episodes": 0, - "coverage": "none", - "request_state": "none", - "seerr_state": "untracked" - } - ], - "state": "observed", - "title": { - "tmdb_id": 42, - "media_type": "movie", - "title": "Moon", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - } - }, - "enrichment": { - "genres": [ - "Comedy" - ], - "metadata": { - "genre_state": "observed", - "genres": [ - { - "id": 35, - "name": "Comedy" - } - ], - "media_type": "movie", - "provider_state": "observed", - "providers": [], - "region": "NL", - "tmdb_id": 42 - }, - "metadata_state": "observed", - "observations": [], - "ratings": [ - { - "source": "rt_critics", - "state": "observed", - "score": 90, - "status": "Certified Fresh" - }, - { - "source": "rt_audience", - "state": "observed", - "score": 89, - "status": "Upright" - }, - { - "source": "imdb", - "state": "observed", - "score": 7.8, - "votes": 300000 - }, - { - "source": "tmdb", - "state": "observed", - "score": 7.6, - "votes": 4000 - } - ], - "runtime": 97, - "seasons": [ - { - "number": 1, - "name": "Season 1", - "overview": "A missing child.", - "episode_count": 10, - "recorded_episodes": 0, - "coverage": "none", - "request_state": "none", - "seerr_state": "untracked" - } - ], - "state": "observed", - "title": { - "tmdb_id": 42, - "media_type": "movie", - "title": "Moon", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - } - }, - "display": { - "media_type": "movie", - "metadata_state": "observed", - "poster_url": "https://images.example.invalid/moon.jpg", - "title": "Moon", - "tmdb_id": 42 - }, - "cast": { - "tmdb_id": 42, - "media_type": "movie", - "metadata_state": "observed", - "source": "seerr", - "paging": "unpaged", - "credit_context": "movie-character", - "items": [ - { - "performer_id": 216, - "name": "Sam Rockwell", - "character": "Sam Bell", - "credit_id": "cast-216", - "order": 0, - "profile_url": "https://images.example.invalid/sam.jpg" - } - ] - }, - "season": { - "tmdb_id": 44, - "media_type": "tv", - "metadata_state": "observed", - "season": { - "number": 1, - "name": "Season 1", - "overview": "A missing child.", - "episode_count": 10, - "recorded_episodes": 0, - "coverage": "none", - "request_state": "none", - "seerr_state": "untracked" - }, - "episodes": [ - { - "number": 1, - "name": "Secrets", - "overview": "A child disappears.", - "recorded": "missing", - "vote_average": 8.1, - "vote_count": 28 - } - ], - "observations": [] - }, - "performer": { - "performer_id": 216, - "metadata_state": "observed", - "source": "seerr", - "name": "Sam Rockwell", - "biography": "Actor.", - "also_known_as": [] - }, - "filmography": { - "performer_id": 216, - "metadata_state": "observed", - "source": "seerr", - "source_paging": "unpaged", - "completeness": "returned-non-adult-cast", - "page": 1, - "page_size": 20, - "items": [ - { - "title": { - "tmdb_id": 42, - "media_type": "movie", - "title": "Moon", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - }, - "credits": [ - { - "credit_id": "cast-216", - "character": "Sam Bell" - } - ], - "visibility_state": "unknown", - "users": [] - } - ], - "observations": [] - }, - "activity": { - "items": [ - { - "actor": { - "name": "Shared", - "requester": "shared", - "role": "household" - }, - "filed_by_you": true, - "id": "request:7", - "media_type": "movie", - "on_behalf": true, - "requester": { - "key": "mindel", - "name": "Mindel" - }, - "seasons": [], - "stages": [ - { - "name": "request", - "source": "conductarr", - "state": "done", - "summary": "Request accepted" - }, - { - "name": "acquisition", - "source": "sonarr", - "state": "in_progress", - "summary": "Downloading" - }, - { - "name": "visibility", - "source": "jellyfin", - "state": "none", - "summary": "Not yet" - }, - { - "name": "playback", - "source": "jellyfin", - "state": "none", - "summary": "Not checked" - } - ], - "title": "Moon", - "tmdb_id": 42, - "blocking_stage": "acquisition" - } - ], - "requester": "all", - "sources": [], - "truncated": false - }, - "readiness": { - "tmdb_id": 42, - "media_type": "movie", - "viewer": { - "key": "mindel", - "name": "Mindel" - }, - "stages": [ - { - "name": "request", - "source": "conductarr", - "state": "done", - "summary": "Request accepted" - }, - { - "name": "acquisition", - "source": "sonarr", - "state": "in_progress", - "summary": "Downloading" - }, - { - "name": "visibility", - "source": "jellyfin", - "state": "none", - "summary": "Not yet" - }, - { - "name": "playback", - "source": "jellyfin", - "state": "none", - "summary": "Not checked" - } - ], - "seerr": { - "state": "observed", - "requests": [] - }, - "coverage": [], - "open_link": { - "state": "missing" - }, - "sources": [] - }, - "requests": { - "requests": [ - { - "actor": { - "name": "Shared", - "requester": "shared", - "role": "household" - }, - "attempts": 1, - "coverage": [], - "created_at": "2026-11-05T12:00:00Z", - "id": 7, - "media_type": "movie", - "membership": { - "status": "added" - }, - "on_behalf": true, - "requester": "mindel", - "requester_username": "Mindel", - "seasons": null, - "seerr": { - "outcome": "requested", - "request_id": 9 - }, - "source": "recommendation", - "state": "settled", - "tmdb_id": 42, - "updated_at": "2026-11-05T12:00:00Z" - } - ] - }, - "receipt": { - "actor": { - "name": "Shared", - "requester": "shared", - "role": "household" - }, - "attempts": 1, - "coverage": [], - "created_at": "2026-11-05T12:00:00Z", - "id": 7, - "media_type": "movie", - "membership": { - "status": "added" - }, - "on_behalf": true, - "requester": "mindel", - "requester_username": "Mindel", - "seasons": null, - "seerr": { - "outcome": "requested", - "request_id": 9 - }, - "source": "recommendation", - "state": "settled", - "tmdb_id": 42, - "updated_at": "2026-11-05T12:00:00Z" - }, - "hide": { - "created": true, - "dismissal": { - "hidden_at": "2026-11-05T12:00:00Z", - "hidden_by": "shared", - "media_type": "movie", - "poster_path": "/hidden.jpg", - "title": "Moon", - "tmdb_id": 42, - "year": 2017 - }, - "materialization": "current", - "profile": "shared" - }, - "restore": { - "media_type": "movie", - "profile": "shared", - "restored": true, - "tmdb_id": 42 - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt deleted file mode 100644 index 00420a95..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.lastOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.LibraryAudience -import org.justinvisser.wolfskin.conductarr.model.Options -import org.justinvisser.wolfskin.conductarr.model.Page -import org.justinvisser.wolfskin.design.* - -/** One lane can interleave movie and TV reads without exposing transport pages on screen. */ -internal data class CatalogueLaneQuery( - val id: String, - val name: String, - val media: List, - val sort: DiscoverSort = DiscoverSort.POPULAR, - val service: Long? = null, - val genres: Map = emptyMap(), -) - -/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ -internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List, private val audiences: () -> List) { - private data class SourcePage(val number: Int, val last: Boolean, val titles: List, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) - private val sources = mutableMapOf() - private val visited = mutableSetOf() - private val mutex = Mutex() - - suspend fun saved(): List = mutex.withLock { - queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) } - lanes() - } - - suspend fun through(index: Int, force: Boolean = false): List = mutex.withLock { - queries.take((index + 1).coerceIn(0, queries.size)).forEach { query -> - if (force || query.id !in visited) { - load(query, if (force) ReadTrigger.FORCE else ReadTrigger.IF_STALE, false) - visited.add(query.id) - } - } - lanes() - } - - suspend fun more(id: String): List = mutex.withLock { - queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) } - lanes() - } - - private suspend fun load(query: CatalogueLaneQuery, trigger: ReadTrigger, next: Boolean) = coroutineScope { - val updates = query.media.map { media -> async { - val key = "${query.id}:${media.name}" - val previous = sources[key] - if (next && previous?.last == true && !previous.failed) return@async key to previous - val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1 - val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort) - val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull() - val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } - if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null - val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) } - val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items - key to SourcePage(if (saved != null) page else previous?.number ?: 1, - saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(), - result is ReadResult.Failure || saved?.state == Page.State.PARTIAL, - saved?.totalResults?.toInt() ?: previous?.total ?: 0, - next && saved != null && titles.size == previous?.titles?.size && saved.page < saved.totalPages) - } }.map { it.await() } - updates.forEach { (key, value) -> if (value != null) sources[key] = value } - } - - private fun lanes(): List = queries.map { query -> - val pages = query.media.mapNotNull { sources["${query.id}:${it.name}"] } - val items = pages.flatMap { it.titles }.distinctBy { it.key } - val pending = pages.isEmpty() && query.id !in visited - DiscoverLane(query.id, query.name, items, total = pages.sumOf { it.total }, tail = when { - pages.any { it.failed } -> DiscoverTail.Retry - pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore - pages.any { !it.last } -> DiscoverTail.SeeAll - else -> DiscoverTail.None - }, pending = pending, hasMore = pages.any { !it.last }) - } -} - -/** Option ids, not guessed service ids, determine every catalogue parameter. */ -internal fun catalogueQueries(options: List, service: DiscoverService? = null, type: String? = null): List { - val media = if (type == null) MediaType.entries else listOf(if (type == "movie") MediaType.MOVIE else MediaType.TV) - val noun = if (type == "movie") "movies" else "series" - val genres = options.flatMap { option -> option.genres.orEmpty().map { it.name to (MediaType.valueOf(option.mediaType.name) to it.id) } } - .groupBy({ it.first }, { it.second }) - if (service != null) return listOf( - CatalogueLaneQuery("sv-pop", "Popular $noun", media, service = service.id), - CatalogueLaneQuery("sv-new", "New $noun", media, DiscoverSort.NEW, service.id), - CatalogueLaneQuery("sv-top", "Top rated $noun", media, DiscoverSort.TOP, service.id), - ) + genres.entries.filter { entry -> entry.value.any { it.first in media } }.take(3).map { (name, ids) -> - CatalogueLaneQuery("sv-g-$name", "$name $noun", media.filter { item -> ids.any { it.first == item } }, service = service.id, genres = ids.toMap()) - } - val services = options.flatMap { it.services.orEmpty() }.distinctBy { it.id } - return services.map { CatalogueLaneQuery("new-${it.id}", "New on ${it.name}", media, DiscoverSort.NEW, it.id) } + - CatalogueLaneQuery("trending", "Trending", media) + - listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").mapNotNull { name -> - genres[name]?.let { ids -> CatalogueLaneQuery("genre-$name", name, - ids.map { it.first }.distinct(), genres = ids.toMap()) } - } + CatalogueLaneQuery("soon", "Coming soon", media, DiscoverSort.UPCOMING) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt index c558e893..0c8a7067 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -2,7 +2,6 @@ package org.justinvisser.wolfskin.tv import kotlinx.coroutines.flow.last -import kotlinx.coroutines.sync.withLock import org.justinvisser.wolfskin.conductarr.ClientError import org.justinvisser.wolfskin.conductarr.ConductarrClient import org.justinvisser.wolfskin.conductarr.MediaType @@ -23,64 +22,44 @@ import org.justinvisser.wolfskin.design.RequestPhase class ConductarrDiscoverOperations( private val client: ConductarrClient, private val refreshSnapshot: suspend () -> DiscoverSnapshot, - private val profile: String? = null, - private val allowedRequesters: Set? = null, - private val canRequest: Boolean = true, ) : DiscoverOperations { - private val submissions = mutableSetOf>() - private val requestMutex = kotlinx.coroutines.sync.Mutex() - private val dismissalMutex = kotlinx.coroutines.sync.Mutex() override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { - requestMutex.lock() - try { - val title = intent.title - if (!canRequest || allowedRequesters?.contains(intent.requester) == false || - (title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused) - val key = title.key to intent.requester - val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last() - if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed) - val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull { - it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong))) - } - if (existing != null) return existing.outcome() - if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed) - val body = RequestIntentBody( - mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, - recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester, - seasons = title.seasons?.map(Int::toLong), - source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, - tmdbId = title.tmdb, - ) - return when (val result = client.mutate(TvRoutes.request(body))) { - is MutationResult.Success -> result.value.outcome() - is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) - } - } finally { requestMutex.unlock() } + val title = intent.title + require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" } + val body = RequestIntentBody( + mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, + recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester, + seasons = title.seasons?.map(Int::toLong), + source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, + tmdbId = title.tmdb, + ) + return when (val result = client.mutate(TvRoutes.request(body))) { + is MutationResult.Success -> result.value.outcome() + is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) + } } override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { - is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { - it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong))) - }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) + is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed) } } - override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock { - val result = client.mutate(TvRoutes.hide(this.profile ?: profile, + override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { + val result = client.mutate(TvRoutes.hide(profile, RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) - result is MutationResult.Success || observedHidden(profile, title) == true + return result is MutationResult.Success || observedHidden(profile, title) == true } - override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock { - val result = client.mutate(TvRoutes.restore(this.profile ?: profile, title.tmdb, title.mediaType())) - result is MutationResult.Success || observedHidden(profile, title) == false + override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { + val result = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType())) + return result is MutationResult.Success || observedHidden(profile, title) == false } private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? { - return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { + return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { is ReadResult.Value -> result.saved.value?.dismissals?.any { it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt deleted file mode 100644 index b794391b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import android.content.SharedPreferences -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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 -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 -import org.justinvisser.wolfskin.conductarr.PersistentDns - -/** Advanced-only endpoint and fixture controls contain no credentials. */ -object ConductarrPreference { - private var preferences: SharedPreferences? = null - var baseUrl by mutableStateOf(BuildConfig.CONDUCTARR_BASE_URL) - private set - var fixtures by mutableStateOf(false) - private set - - fun initialize(context: Context) { - preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - baseUrl = preferences?.getString("wolfskin.conductarr.url", null)?.takeIf { it.isNotBlank() } ?: BuildConfig.CONDUCTARR_BASE_URL - fixtures = BuildConfig.DEBUG && preferences?.getBoolean("wolfskin.fixtures", false) == true - } - - fun updateBaseUrl(value: String) { - val normalized = value.trim().ifBlank { BuildConfig.CONDUCTARR_BASE_URL } - baseUrl = normalized - preferences?.edit { putString("wolfskin.conductarr.url", normalized) } - } - - fun updateFixtures(value: Boolean) { - fixtures = BuildConfig.DEBUG && value - preferences?.edit { putBoolean("wolfskin.fixtures", fixtures) } - } -} - -/** Household snapshots must not enter Android backup or shared storage. */ -@Module -@InstallIn(SingletonComponent::class) -object ConductarrModule { - @Provides - @Singleton - fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession = - ConductarrSession(ConductarrDiskCache(context), scope, dns) -} - -/** 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/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt deleted file mode 100644 index 3f76141f..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import org.justinvisser.wolfskin.conductarr.model.* -import org.justinvisser.wolfskin.design.* - -/** Relative TMDB art paths are not app or Conductarr-relative URLs. */ -internal fun catalogueImage(path: String?): String? = path?.takeIf { it.isNotBlank() }?.let { - if (it.startsWith("/")) "https://image.tmdb.org/t/p/w780$it" else it -} - -internal fun Candidate.discoverTitle(genres: Map): DiscoverTitle = DiscoverTitle( - tmdbId, mediaType.name.lowercase(), title, year.toInt(), genreIds.orEmpty().mapNotNull(genres::get), overview, - catalogueImage(posterPath), catalogueImage(backdropPath), score = score, -) - -internal fun Title.discoverTitle(audiences: List): DiscoverTitle = DiscoverTitle( - tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, - poster = posterUrl, backdrop = backdropUrl, - heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() }, -) - -internal fun DiscoverSnapshot.homeRows(): List { - if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut)) - if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true)) - if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable)) - val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) -> - picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items -> - HomeRow("pk-$type", HomeRowKind.Shelf, label, items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) - } - } - return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane -> - if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true) - else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title, - items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) } - } + HomeRow("explore", HomeRowKind.Explore) -} - -internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle( - id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds, seasons = seasonCount, - 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 { - 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 || 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, 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 deleted file mode 100644 index 8196559c..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.lastOrNull -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.* -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.conductarr.MediaType - -/** Screen projections share the identity-scoped transport, never a second HTTP or credential path. */ -class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider by ClientTitleProvider(client) { - private var catalogue: ConductarrCatalogue? = null - private var options = emptyList() - private val services = mutableMapOf() - @Volatile private var libraryAudiences = emptyList() - private var requestOperations: ConductarrDiscoverOperations? = null - private val profilesMutex = Mutex() - private var observedProfiles: RecommendationProfiles? = null - private val profileNames = MutableStateFlow>(emptyList()) - val requesterProfiles = profileNames.asStateFlow() - - /** One uncertain-submission ledger survives screen/navigation changes for this identity. */ - fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) { - requestOperations ?: ConductarrDiscoverOperations(client, { - profiles() - discover(operator.recommendationProfile, force = true).lastOrNull() - ?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) - }, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia) - .also { requestOperations = it } - } - fun discover(profile: String, force: Boolean = false): Flow = flow { - val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY) - if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved) - emit(discoverSnapshot(profile, if (force) ReadTrigger.FORCE else ReadTrigger.OPEN)) - } - - private suspend fun discoverSnapshot(profile: String, trigger: ReadTrigger): DiscoverSnapshot = coroutineScope { - val reads = SnapshotReads(client, trigger) - val feedJob = async { reads.read(TvRoutes.feed(profile)) } - val hiddenJob = async { reads.read(TvRoutes.dismissals(profile)) } - val optionsJobs = MediaType.entries.map { media -> async { reads.read(TvRoutes.options(media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.FOREGROUND) } } - val feed = feedJob.await() - val options = optionsJobs.mapNotNull { it.await() } - this@ConductarrProvider.options = options - val queries = catalogueQueries(options) - 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 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)) - } - val status = when { - reads.needsSignIn -> DiscoverStatus.NeedsSignIn - feed == null && lanes.all { it.titles.isEmpty() } -> DiscoverStatus.Unavailable - feed?.outcome == RecommendationFeed.Outcome.NO_SEEDS -> DiscoverStatus.NoSeeds - feed == null || feed.state == RecommendationFeed.State.UNAVAILABLE -> DiscoverStatus.PicksUnavailable - else -> DiscoverStatus.Ready - } - DiscoverSnapshot(picks, seeds, options.flatMap { it.services.orEmpty() }.distinctBy { it.id }.map { DiscoverService(it.id, it.name, discoverServiceHue(it.name)) }, - lanes, hidden, status, reads.savedTime(), reachable = !reads.failed) - } - - /** Read two shelves ahead; pagination is keyed by the exact service, genre and media parameters. */ - suspend fun lanesThrough(index: Int): List = catalogue?.through(index).orEmpty() - suspend fun more(lane: String): List = catalogue?.more(lane).orEmpty() - suspend fun readAhead(lane: String?): List { - val loader = catalogue ?: return emptyList() - val index = loader.queries.indexOfFirst { it.id == lane } - return loader.through(index + 2) - } - - suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List { - val key = "${service.id}:$type" - val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } } - return if (more != null) loader.more(more) else loader.through(through) - } - - fun requests(): Flow = flow { - for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.FOREGROUND)) { - val reads = SnapshotReads(client, trigger) - val feed = reads.read(TvRoutes.activity()) - if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue - val artReads = SnapshotReads(client, trigger) - val items = coroutineScope { - feed?.items.orEmpty().map { item -> async { - val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV - val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) - item.comingIn().copy(poster = art?.posterUrl) - } }.map { it.await() } - } - emit(ComingInSnapshot(items, - if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready, - partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null, - savedAt = reads.savedTime())) - } - } - - suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock { - observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also { - observedProfiles = it - profileNames.value = it.profiles.orEmpty() - } - } - /** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */ - suspend fun audiences(): List? { - val reads = SnapshotReads(client, ReadTrigger.IF_STALE) - val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() } - if (reads.failed || observed == null) return null - libraryAudiences = observed - return observed - } - - fun holders(title: Title): Set = title.discoverTitle(libraryAudiences).heldBy -} - -/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */ -internal fun ConductarrIdentity.requesters(profiles: List, saved: List): List { - val allowed = operator?.capabilities?.requesters.orEmpty() - return buildMap { - saved.filter { it.key in allowed }.forEach { put(it.key, it) } - profiles.filter { it.key in allowed }.forEach { profile -> - put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false)) - } - }.values.toList() -} - -/** A batch tracks source failure without replacing saved bodies with empty success. */ -internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) { - @Volatile var failed = false - private set - @Volatile var needsSignIn = false - private set - private var oldestSaved: Long? = null - - suspend fun read(call: ReadCall, event: ReadTrigger = trigger): T? { - return when (val result = client.read(call, event).lastOrNull()) { - is ReadResult.Value -> result.saved.value - is ReadResult.Failure -> { - failed = true - if (result.error == ClientError.NeedsSignIn) needsSignIn = true - result.saved?.let { saved -> synchronized(this) { oldestSaved = minOf(oldestSaved ?: Long.MAX_VALUE, saved.savedAtMillis) } } - result.saved?.value - } - null -> null - } - } - - fun savedTime(): String? = oldestSaved?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt deleted file mode 100644 index 76533ebd..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import com.github.damontecres.wholphin.data.model.RtMark -import com.github.damontecres.wholphin.data.model.SourceRatings -import kotlinx.coroutines.flow.lastOrNull -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.Rating -import org.justinvisser.wolfskin.design.LibraryRating -import org.justinvisser.wolfskin.design.ScreenRatings - -/** Failed reads are not cached as absence; saved observations survive offline. */ -class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings { - override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List? { - val media = if (type == "movie") MediaType.MOVIE else MediaType.TV - val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE - val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) { - is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type) - is ReadResult.Failure -> if (result.error == ClientError.NeedsSignIn) return emptyList() else result.saved?.value?.ratings?.screenRatings(type) ?: return null - null -> return null - } - if (type != "episode" || season == null || episode == null) return ratings - val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull() - if (result is ReadResult.Failure && result.error == ClientError.NeedsSignIn) return emptyList() - val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } - val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() } - return ratings.withEpisode(vote?.voteAverage, vote?.voteCount) - } -} - -internal fun List.withEpisode(score: Double?, votes: Long?): List { - val observed = SourceRatings.episodeScore(score, votes) ?: return this - return filterNot { it.source == "tmdb" } + LibraryRating("tmdb", observed, episode = true) -} - -/** Use the app's existing source precedence and label rules, not a second interpretation. */ -internal fun List.screenRatings(type: String): List { - val observed = filter { it.state == Rating.State.OBSERVED } - fun source(source: Rating.Source) = observed.firstOrNull { it.source == source } - val critics = source(Rating.Source.RT_CRITICS) - val audience = source(Rating.Source.RT_AUDIENCE) - val imdb = source(Rating.Source.IMDB).takeIf { type == "movie" } - val tmdb = source(Rating.Source.TMDB).takeIf { (it?.votes ?: 0) > 0 } - val ratings = SourceRatings.of(critics?.score, critics?.status, audience?.score, audience?.status, - imdb?.score, imdb?.votes, tmdb?.score, tmdb?.votes) - fun RtMark.label(): String? = when (this) { - RtMark.FRESH -> "Fresh"; RtMark.ROTTEN -> "Rotten"; RtMark.UPRIGHT -> "Upright"; RtMark.SPILLED -> "Spilled"; RtMark.UNKNOWN -> null - } - return buildList { - ratings.rtCritics?.let { add(LibraryRating("rtCritics", it.percent.toDouble(), it.mark.label())) } - ratings.rtAudience?.let { add(LibraryRating("rtAudience", it.percent.toDouble(), it.mark.label())) } - ratings.imdb?.let { add(LibraryRating("imdb", it)) } - ratings.tmdb?.let { add(LibraryRating("tmdb", it)) } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt deleted file mode 100644 index ce639277..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -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( - val person: String, - val client: ConductarrClient, - val provider: ConductarrProvider, - val operator: Operator? = null, - val error: ClientError? = null, - val signedIn: Boolean = false, -) - -/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */ -class ConductarrSession( - private val cache: CacheStore, - private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), - private val dns: PersistentDns? = null, -) : Closeable { - private val mutableIdentity = MutableStateFlow(null) - val identity: StateFlow = mutableIdentity.asStateFlow() - private var selection: ByteArray? = null - @Volatile private var generation = 0L - - 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()) - 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), dns = dns, 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 -> - when (result) { - 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 (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++ - 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") - } - } - } -} - -/** Person ids partition disk entries even if a caller accidentally supplies the same credential twice. */ -private class PersonCacheStore(private val store: CacheStore, private val person: String) : CacheStore { - override fun read(scope: String, key: String) = store.read("$person:$scope", key) - override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write("$person:$scope", key, entry, capacity) - override fun remove(scope: String, key: String) = store.remove("$person:$scope", key) - override fun clear(scope: String) = store.clear("$person:$scope") -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt deleted file mode 100644 index 9030249d..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.* -import org.justinvisser.wolfskin.design.* - -/** Conductarr never grants playback: only a resolved Jellyfin page can supply held/play identities. */ -internal class ConductarrTitlePages(private val identity: ConductarrIdentity) { - fun title(tmdb: Long, media: MediaType, held: TitlePageData? = null): Flow = flow { - for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) { - val page = titleSnapshot(tmdb, media, held, trigger) - if (trigger != ReadTrigger.CACHE_ONLY || page.status == TitleReadStatus.Ready) emit(page) - } - } - - private suspend fun titleSnapshot(tmdb: Long, media: MediaType, held: TitlePageData?, trigger: ReadTrigger): TitlePageData = coroutineScope { - val client = identity.client - val reads = SnapshotReads(client, trigger) - val profile = identity.operator!!.recommendationProfile - val detailJob = async { reads.read(TvRoutes.title(tmdb, media)) } - val castJob = async { reads.read(TvRoutes.cast(tmdb, media)) } - val relatedJob = async { reads.read(TvRoutes.related(tmdb, media, 1)) } - val enrichmentJob = async { reads.read(TvRoutes.enrichment(tmdb, media)) } - val displayJob = async { reads.read(TvRoutes.display(tmdb, media)) } - val ratingsJob = async { reads.read(TvRoutes.ratings(tmdb, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } - val readinessJob = async { reads.read(TvRoutes.readiness(tmdb, media, identity.operator.watchUser)) } - val receiptJob = async { reads.read(TvRoutes.requests(tmdb, media)) } - val feedJob = async { reads.read(TvRoutes.feed(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } - val hiddenJob = async { reads.read(TvRoutes.dismissals(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } - val seasonJobs = if (media == MediaType.TV && held != null) { - (listOfNotNull(held.selectedSeason) + listOfNotNull(held.playTarget, held.watchTarget).filter { it.kind == "episode" }.map { it.season }) - .distinct().associateWith { number -> async { reads.read(TvRoutes.season(tmdb, number)) } } - } else emptyMap() - val detail = detailJob.await() - val enrichment = enrichmentJob.await() - val display = displayJob.await() - val raw = detail?.title ?: enrichment?.title - val mediaName = media.name.lowercase() - val sourceItem = raw?.titleItem(identity.provider) ?: TitleItem("$mediaName:$tmdb", display?.title.orEmpty(), mediaName, poster = display?.posterUrl, tmdbId = tmdb) - val genres = detail?.genres ?: enrichment?.genres - val seasons = detail?.seasons ?: enrichment?.seasons - val cast = castJob.await() - val related = relatedJob.await()?.items.orEmpty().map { it.titleItem(identity.provider) } - val readiness = readinessJob.await() - val receipt = receiptJob.await()?.requests.orEmpty().filter { it.requester == identity.operator.requester || it.actor.requester == identity.operator.requester }.maxByOrNull { it.id } - val stages = readiness?.stages.orEmpty().takeIf { receipt?.requester == identity.operator.requester }.orEmpty() - val stage = when { - receipt == null -> null - stages.any { it.state == ReadinessStage.State.FAILED } -> "Failed" - stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state == ReadinessStage.State.DONE } -> "Ready" - stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state in listOf(ReadinessStage.State.PARTIAL, ReadinessStage.State.IN_PROGRESS) } -> "Almost ready" - stages.any { it.name == ReadinessStage.Name.ACQUISITION && it.state == ReadinessStage.State.IN_PROGRESS } -> "Downloading" - receipt?.state == RequestReceipt.State.SETTLED -> "Requested" - receipt != null -> "Checking" - else -> null - } - val feed = feedJob.await() - val hidden = hiddenJob.await()?.dismissals.orEmpty().any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName } - val votes = seasonJobs.mapValues { (_, job) -> job.await()?.episodes.orEmpty().associateBy { it.number } } - val observedRatings = ratingsJob.await()?.ratings.orEmpty() - val ratings = if (reads.needsSignIn) emptyList() else observedRatings.screenRatings(mediaName) - val item = (held?.item ?: sourceItem).copy( - runtimeSeconds = held?.item?.runtimeSeconds?.takeIf { it > 0 } ?: ((detail?.runtime ?: enrichment?.runtime ?: 0) * 60), - genres = genres ?: held?.item?.genres.orEmpty(), seasons = held?.item?.seasons ?: seasons.orEmpty().size, - ratings = ratings, tmdbId = tmdb, - ) - fun rated(target: TitleItem): TitleItem { - val episode = votes[target.season]?.get(target.episode.toLong()) - return target.copy(tmdbId = tmdb, ratings = if (reads.needsSignIn) emptyList() else if (target.kind == "episode") ratings.withEpisode(episode?.voteAverage, episode?.voteCount) else ratings) - } - val base = held ?: TitlePageData(item) - base.copy(item = item, watchTarget = rated(base.watchTarget), playTarget = base.playTarget?.let(::rated), - cast = cast?.items?.sortedBy { it.order }?.map { TitlePerson(it.performerId.toString(), it.name, it.character, catalogueImage(it.profileUrl)) } ?: base.cast, - castStatus = if (cast == null && base.cast.isEmpty()) TitleReadStatus.Unavailable else TitleReadStatus.Ready, - related = related, episodes = base.episodes.map(::rated), - status = if (raw != null || held != null || display?.title != null) TitleReadStatus.Ready else TitleReadStatus.Unavailable, - connection = when { reads.needsSignIn -> TitleConnection.SignedOut; reads.failed || identity.error != null -> TitleConnection.Offline; else -> TitleConnection.SignedIn }, - requestedStage = stage, requestActor = receipt?.actor?.name, - requestFor = receipt?.takeIf { it.requester != identity.operator.requester }?.requesterUsername, - hidden = hidden, pick = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName }, - savedAt = reads.savedTime() ?: held?.savedAt, - seasonNumbers = held?.seasonNumbers ?: seasons.orEmpty().map { it.number.toInt() }.filter { it > 0 }, - requestsAllowed = identity.operator.capabilities.requestMedia && !reads.failed && !reads.needsSignIn, - ) - } - - fun person(id: Long, page: Int = 1): Flow = flow { - for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) { - val reads = SnapshotReads(identity.client, trigger) - val result = coroutineScope { - val detail = async { reads.read(TvRoutes.performer(id)) } - val credits = async { reads.read(TvRoutes.filmography(id, page)) } - detail.await() to credits.await() - } - val detail = result.first - val credits = result.second - if (trigger == ReadTrigger.CACHE_ONLY && detail?.name == null) continue - emit(ConductarrPersonPage(detail?.name?.let { TitlePerson(id.toString(), it, portrait = catalogueImage(detail.profileUrl), biography = detail.biography.orEmpty()) }, - credits?.items.orEmpty().map { it.title.titleItem(identity.provider).copy(character = it.credits.orEmpty().map { credit -> credit.character }.filter { it.isNotBlank() }.distinct().joinToString(" / ")) }, - credits != null && credits.page < (credits.totalPages ?: 1), reads.savedTime(), reads.needsSignIn, reads.failed)) - } - } -} - -internal data class ConductarrPersonPage(val person: TitlePerson?, val credits: List, val hasMore: Boolean, val savedAt: String?, val needsSignIn: Boolean, val failed: Boolean) - -private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem( - "${mediaType.name.lowercase()}:$tmdbId", title, mediaType.name.lowercase(), releaseDate?.take(4)?.toIntOrNull(), - overview = overview, poster = catalogueImage(posterUrl), backdrop = catalogueImage(backdropUrl), - otherOwners = provider.holders(this).toList(), tmdbId = tmdbId, -) - -internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle( - item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year, - genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, - runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, - seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, - heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), -) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt deleted file mode 100644 index 439bca3d..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.flow.Flow -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.* - -/** Titles can adopt these reads without importing transport or owning another login. */ -interface ConductarrTitleProvider { - fun detail(tmdb: Long, media: MediaType): Flow> - fun cast(tmdb: Long, media: MediaType): Flow> - fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow> - fun season(tmdb: Long, number: Int): Flow> - fun performer(id: Long): Flow> - fun filmography(id: Long, page: Int = 1): Flow> - fun activity(tmdb: Long, media: MediaType, viewer: String): Flow> - fun requests(tmdb: Long, media: MediaType): Flow> - fun enrichment(tmdb: Long, media: MediaType): Flow> - fun display(tmdb: Long, media: MediaType): Flow> -} - -/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */ -internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider { - override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN) - override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN) - override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN) - override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN) - override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN) - override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN) - override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN) - override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE) - override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN) - override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN) -} 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 ee29266b..b107bd97 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -2,106 +2,54 @@ package org.justinvisser.wolfskin.tv import android.content.Context -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.github.damontecres.wholphin.BuildConfig import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.design.DiscoverFixture +import org.justinvisser.wolfskin.design.DiscoverOperations +import org.justinvisser.wolfskin.design.DiscoverRequest +import org.justinvisser.wolfskin.design.DiscoverRequestResult +import org.justinvisser.wolfskin.design.DiscoverSnapshot +import org.justinvisser.wolfskin.design.DiscoverState +import org.justinvisser.wolfskin.design.DiscoverTitle +import org.justinvisser.wolfskin.design.DiscoverStatus +import org.justinvisser.wolfskin.design.RequestPhase +import org.justinvisser.wolfskin.design.ShellState internal val LocalDiscoverState = compositionLocalOf { error("Discover requires the Wolfskin shell") } -/** Production uses the selected login; explicit fixtures remain deterministic and network-free. */ +/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */ @Composable internal fun rememberDiscoverState( shell: ShellState, push: (String) -> Unit, pop: () -> Unit, loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } }, - identity: ConductarrIdentity? = null, - fixtures: Boolean = BuildConfig.DEBUG, - retrySignIn: suspend () -> Unit = {}, ): DiscoverState { val context = LocalContext.current val scope = rememberCoroutineScope() - val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf>() } - val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() - val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) { - if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty() - } - LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { - if (!fixtures && identity?.signedIn == true) identity.provider.profiles() - } - val refresh: suspend () -> DiscoverSnapshot = { - if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) - else { retrySignIn(); DiscoverSnapshot() } - } - val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) { - val operations = identity?.takeIf { it.signedIn }?.let { it.provider.operations(it.operator!!) } - ?: SignedOutDiscoverOperations(refresh) - lateinit var loaded: DiscoverState - loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop, - { service, type -> servicePages["${service.id}:$type"].orEmpty() }, - requesters = requesters, - onMore = if (fixtures || identity?.signedIn != true) null else { lane -> - val service = loaded.service - if (service == null) identity.provider.more(lane) - else { - servicePages["${service.id}:${loaded.filter}"] = identity.provider.service(service, loaded.filter, more = lane) - loaded.snapshot.lanes - } - }) - loaded - } - SideEffect { if (!fixtures) state.updateRequesters(requesters) } - var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf(null) } - LaunchedEffect(state, fixtures) { - if (fixtures) { + var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) } + LaunchedEffect(shell, shell.person.key) { + if (BuildConfig.DEBUG) { val fixture = loadFixture(context) val snapshot = fixture.snapshot(shell.person.key) + val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab) val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service) - fixtureState = loaded - if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) - } else if (identity?.signedIn == true) { - identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) } + state = loaded + if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) } } - LaunchedEffect(state.service?.id, state.filter, state) { - val service = state.service ?: return@LaunchedEffect - if (!fixtures && identity?.signedIn == true) { - servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter) - state.invalidateLanes() - } - } - LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) { - if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> - val service = state.service - if (service == null) { - state.updateLanes(identity.provider.readAhead(lane)) - val focused = state.focusedLane - if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) { - state.updateLanes(identity.provider.more(focused.id)) - } - } else { - val index = state.lanes.indexOfFirst { it.id == lane } - servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2) - val focused = state.focusedLane - if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) { - servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id) - } - state.invalidateLanes() - } - } - } - val result = if (fixtures) fixtureState ?: state else state - LaunchedEffect(shell.route, result) { - result.enterRoute(shell.route) - if (shell.route == "discover" && !fixtures && identity?.signedIn == true && result.snapshot.status != DiscoverStatus.Loading) result.refresh() - } - LaunchedEffect(result.snapshot.savedAt, shell.route) { if (shell.route.startsWith("discover")) shell.view.savedAt = result.snapshot.savedAt } - return result + LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) } + return state } private fun readDiscoverFixture(context: Context): DiscoverFixture { @@ -109,6 +57,7 @@ private fun readDiscoverFixture(context: Context): DiscoverFixture { return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" } } +/** Fixture writes are in-memory only and cannot reach a Conductarr client. */ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations { override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor) override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) @@ -117,10 +66,10 @@ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, pr override suspend fun refresh() = snapshot } -private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations { +private object SignedOutDiscoverOperations : DiscoverOperations { override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused) override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) override suspend fun hide(profile: String, title: DiscoverTitle) = false override suspend fun restore(profile: String, title: DiscoverTitle) = false - override suspend fun refresh() = retry() + override suspend fun refresh() = DiscoverSnapshot() } 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 35f10b1e..e2ac7a91 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -18,12 +18,10 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.CancellationException -import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.coroutineScope -import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache import org.justinvisser.wolfskin.tv.read.HomeReadFacade @@ -36,38 +34,25 @@ class WolfskinHomeViewModel @Inject constructor( private val cache: HomeLibraryCache, private val navigation: NavigationManager, private val seeAll: WolfskinSeeAllLists, - private val conductarr: ConductarrSession, ) : ViewModel() { - val conductarrState get() = conductarr.identity - val audiences get() = home.audiences var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) private set var toast by mutableStateOf(null) private set - private var catalogueJob: kotlinx.coroutines.Job? = null private var sourceScope: String? = null private var loadRevision = 0L - private var catalogueSnapshot = DiscoverSnapshot() - private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null - suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { - catalogueJob?.cancel() - withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } - if (retry && conductarr.identity.value?.signedIn != true) { - withContext(WholphinDispatchers.IO) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) - } - } + suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } loadLibrary(server, user, person, retry) } /** The authenticated read path is shared by release and offline fixture-driven tests. */ - internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { + internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { val revision = ++loadRevision - 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) + val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut)) + if (person != user.name.orEmpty().lowercase()) { + snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) return } val scope = "${server.id}:${user.id}" @@ -76,107 +61,39 @@ class WolfskinHomeViewModel @Inject constructor( toast = null snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading) } - val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } + val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } if (revision != loadRevision) return - var libraryCopy = saved?.first ?: HomeLibrary() - var libraryAvailable = true - var librarySavedAt: String? = null - var watchReady = false - val identity = conductarr.identity.value - var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) - fun publish() { - if (revision != loadRevision || sourceScope != scope) return - val status = when (catalogueCopy.status) { - DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut - DiscoverStatus.Loading -> HomeAvailability.Loading - DiscoverStatus.Unavailable -> HomeAvailability.Unavailable - else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready - } - snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map { - if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it - }, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) + if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation)) + try { + val library = withContext(Dispatchers.IO) { home.read(user.id) } + val savedAt = System.currentTimeMillis() + withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Local storage failure must not turn a reachable library into an outage. */ } } + if (revision != loadRevision) return + snapshot = HomeSnapshot(buildHomeRows(library, continuation)) + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (revision != loadRevision) return + snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second))) + else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) + if (retry) showToast(RecoveryMessage.StillUnreachable) } - catalogueSnapshot = catalogueCopy - publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } - if (saved != null) publish() - if (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch { - if (retry && !identity.signedIn) conductarr.authenticate() - val source = conductarr.identity.value - if (source == null || source.client !== identity.client) return@launch - if (!source.signedIn) { - publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable)) - return@launch - } - source.provider.discover(source.operator!!.recommendationProfile, retry).collect { catalogue -> - if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) - } - } - suspend fun save() { - val library = libraryCopy - withContext(WholphinDispatchers.IO) { - try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ } - } - } - coroutineScope { - launch { - try { - val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) } - if (revision != loadRevision) return@launch - libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp) - watchReady = true - libraryAvailable = true - publish() - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (revision != loadRevision) return@launch - libraryAvailable = false - librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } - publish() - if (retry) showToast(RecoveryMessage.StillUnreachable) - } - } - launch { - try { - val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) } - if (revision != loadRevision) return@launch - libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals) - publish() - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable) - } - } - } - if (revision == loadRevision) save() - } - - suspend fun readAhead(lane: String?) { - val identity = conductarr.identity.value ?: return - if (!identity.signedIn || ConductarrPreference.fixtures) return - val revision = loadRevision - val lanes = identity.provider.readAhead(lane) - if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes)) } 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, retry = true) } - is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) + HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) } + is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Play -> viewModelScope.launch { - val personKey = shell.person.key - val selectedId = shell.person.jellyfinUserId - val matchesUser = if (selectedId != null) selectedId == user.id.toString() else - shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) - if (!snapshot.jellyfinAvailable || !matchesUser) { + if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) { showToast(RecoveryMessage.PlaybackUnavailable) return@launch } try { - val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } - if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) - else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) + val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } + if (id == null) showToast(RecoveryMessage.PlaybackUnavailable) + else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) } catch (exception: Exception) { if (exception is CancellationException) throw exception showToast(RecoveryMessage.PlaybackUnavailable) @@ -185,18 +102,6 @@ class WolfskinHomeViewModel @Inject constructor( } } - private fun openCatalogueGrid(row: HomeRow, shell: ShellState) { - val identity = conductarr.identity.value - val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id } - val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ -> - if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed") - val next = identity.provider.more(lane.id).first { it.id == lane.id } - if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable") - LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore) - } else null - seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore) - } - private fun openRoot(shell: ShellState, route: String) { shell.enterDestination(route) shell.focusNav(shell.tabs.indexOf(route)) @@ -231,17 +136,9 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( /** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */ @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { - val identity by model.conductarrState.collectAsState() - val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") - LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { 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 - model.readAhead(lane) - } + LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) } Box(modifier) { - val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot - HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } + HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } model.toast?.let { RecoveryToast(it) } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt deleted file mode 100644 index 899341e5..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import com.github.damontecres.wholphin.data.MostRecentServer -import com.github.damontecres.wholphin.data.MostRecentServerProvider -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.justinvisser.wolfskin.conductarr.PersistentDns - -/** Process warm-up never blocks application creation or serializes the two service lookups. */ -@Singleton -class NetworkStartup @Inject constructor( - @param:ApplicationContext private val context: Context, - private val recent: Provider, - private val dns: PersistentDns, - @param:IoCoroutineScope private val scope: CoroutineScope, -) { - fun start() { - scope.launch { - val url = when (val server = recent.get().get()) { - is MostRecentServer.Server -> server.serverUrl - is MostRecentServer.ServerAndUser -> server.serverUrl - MostRecentServer.None -> null - } - warm(url) - } - scope.launch { - ConductarrPreference.initialize(context) - warm(ConductarrPreference.baseUrl) - } - } - - private fun warm(url: String?) { - val host = url?.toHttpUrlOrNull()?.host ?: return - try { dns.lookup(host) } catch (_: Exception) { - // An offline launch must still render the saved people. - } - } -} 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 a678ca21..2c88cc9b 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.hasUnsettledRequests) refresh() + if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh() } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt index 5984cb5a..2b7562df 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt @@ -8,7 +8,7 @@ import org.justinvisser.wolfskin.design.ShellPerson /** Prototype identities are packaged exclusively in debug assets, never release resources. */ fun readShellPeople(context: Context, saved: List): List { - if (ConductarrPreference.fixtures) { + if (BuildConfig.DEBUG) { val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) } val profiles = json.getJSONArray("profiles") return List(profiles.length()) { index -> @@ -18,6 +18,6 @@ fun readShellPeople(context: Context, saved: List): List val name = user.name.orEmpty() - ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString()) + ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false) } } 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 4db5512d..913f51e7 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -9,14 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.foundation.layout.Box -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.type -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.justinvisser.wolfskin.design.DiscoverOverlay -import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.design.readShellKey import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -33,8 +25,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.github.damontecres.wholphin.data.ServerRepository -import org.justinvisser.wolfskin.conductarr.MediaType import org.justinvisser.wolfskin.design.PersonState import org.justinvisser.wolfskin.design.PersonSurface import org.justinvisser.wolfskin.design.RecoveryMessage @@ -48,7 +38,6 @@ import org.justinvisser.wolfskin.design.TitleReadStatus import org.justinvisser.wolfskin.design.TitleState import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.justinvisser.wolfskin.tv.read.playbackPositionMs import java.util.UUID import javax.inject.Inject @@ -63,8 +52,6 @@ class WolfskinTitleViewModel @Inject constructor( private val facade: TitleReadFacade, private val fixtureProvider: TitleFixtureProvider, private val navigation: NavigationManager, - val conductarr: ConductarrSession, - private val repository: ServerRepository, ) : ViewModel() { var title by mutableStateOf(null) private set @@ -78,40 +65,23 @@ class WolfskinTitleViewModel @Inject constructor( private var personReadFinished = false private var loadJob: Job? = null private val saveMutex = Mutex() - private var performerPage = 1 - var conductarrAction: ((TitleAction) -> Unit)? = null - val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" - - fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) - fun popRoute() = navigation.goBack() private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } - /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ - fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { - if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return - if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && - title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true && - (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && - !destination.startsWith("person/") && fixture == null) { - loadJob?.cancel() - preferences = userPreferences - title?.let { it.update(it.data.copy(watchReady = false)) } - loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) } - return - } + /** Fixture reads are exclusively debug assets; real ids always use the authenticated Jellyfin facade. */ + fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) { + if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return route = destination loadJob?.cancel() person = null fixture = null personReadPending = false personReadFinished = false - performerPage = 1 viewer = selected preferences = userPreferences val id = Uri.decode(destination.substringAfter('/')) title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) loadJob = viewModelScope.launch { - if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) { + if (BuildConfig.DEBUG && id.toUuidOrNull() == null) { val source = fixtureProvider.read() fixture = source if (destination.startsWith("person/")) { @@ -134,40 +104,23 @@ class WolfskinTitleViewModel @Inject constructor( } private suspend fun readTitle(id: String, retry: Boolean = false) { + val uuid = id.toUuidOrNull() ?: return val current = title ?: return - val uuid = id.toUuidOrNull() - var held: TitlePageData? = null - val catalogue = if (uuid == null) id.split(':').takeIf { it.size == 2 && it[0] in listOf("movie", "tv") && it[1].toLongOrNull() != null } else null - if (uuid == null && catalogue == null) { current.update(current.data.copy(status = TitleReadStatus.Unavailable)); return } try { - val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } - if (title !== current) return - if (heldId != null) { - if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } - held = facade.read(heldId, current.requestedSeason, preferences) - current.update(held) - } + if (current.data.status != TitleReadStatus.Ready) facade.readSaved(uuid, preferences)?.let { current.update(it) } + val data = facade.read(uuid, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) + current.update(data) } catch (exception: Exception) { if (exception is CancellationException) throw exception if (current.data.status == TitleReadStatus.Ready) { if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() - held = current.data - } else if (uuid != null) current.update(current.data.copy(status = TitleReadStatus.Unavailable)) - if (uuid != null && (current.data.status == TitleReadStatus.Ready || retry)) current.toast = RecoveryMessage.StillUnreachable + } else current.update(current.data.copy(status = TitleReadStatus.Unavailable)) + if (current.data.status == TitleReadStatus.Ready || retry) current.toast = RecoveryMessage.StillUnreachable } - val identity = conductarr.identity.value?.takeIf { it.signedIn } - val tmdb = catalogue?.get(1)?.toLong() ?: held?.item?.tmdbId - if (identity != null && tmdb != null) { - val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV - ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> - if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) - } - } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) } private suspend fun readPerson(id: String, retry: Boolean = false) { if (personReadPending || personReadFinished) return - if (id.toLongOrNull() != null) { readPerformer(id.toLong(), retry); return } val uuid = id.toUuidOrNull() ?: return val owner = viewer.key personReadPending = true @@ -194,40 +147,6 @@ class WolfskinTitleViewModel @Inject constructor( } finally { if (owner == viewer.key) personReadPending = false } } - private suspend fun readPerformer(id: Long, retry: Boolean) { - val identity = conductarr.identity.value?.takeIf { it.signedIn } ?: run { - title?.let { it.update(it.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut)) } - return - } - val currentTitle = title - personReadPending = true - var failed = false - try { - ConductarrTitlePages(identity).person(id, performerPage).collect { page -> - if (title !== currentTitle || conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return@collect - if (page.person != null) { - val current = person - val credits = if (performerPage == 1) page.credits else (current?.credits.orEmpty() + page.credits).distinctBy { it.id } - if (current == null) person = PersonState(page.person, credits, ::handle) { viewModelScope.launch { readPerson(id.toString()) } } - person?.update(page.person, credits, page.savedAt) - if (page.failed && retry) person?.toast = RecoveryMessage.StillUnreachable - } else title?.update(requireNotNull(title).data.copy(status = TitleReadStatus.Unavailable)) - personReadFinished = !page.hasMore && !page.failed - failed = page.failed - } - if (!personReadFinished && !failed) performerPage++ - } finally { personReadPending = false } - } - - internal suspend fun retrySignIn() { - val user = repository.currentUser ?: return - val server = repository.currentServer ?: return - withContext(Dispatchers.IO) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) - } - conductarr.authenticate() - } - private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List) = PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } @@ -235,21 +154,13 @@ class WolfskinTitleViewModel @Inject constructor( when (action) { TitleAction.Pop -> navigation.goBack() is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) - is TitleAction.OpenTitle -> { - val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { - "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE - "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE - else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES - }) } ?: Destination.Wolfskin(titleRoute(action.item.id)) - if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination) - else navigation.navigateTo(destination) - } - is TitleAction.Play -> { - val opened = title?.data?.watchTarget - play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), - restart = action.restart) - } - is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) + is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { + "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE + "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE + else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES + }) } ?: Destination.Wolfskin(titleRoute(action.item.id))) + is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) + is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { @@ -267,29 +178,22 @@ class WolfskinTitleViewModel @Inject constructor( } } TitleAction.Retry -> viewModelScope.launch { - if (conductarr.identity.value?.signedIn != true) retrySignIn() - personReadFinished = false - performerPage = 1 if (fixture == null) { if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true) } else title?.toast = RecoveryMessage.StillUnreachable } - is TitleAction.Request, is TitleAction.Requests -> requireNotNull(conductarrAction).invoke(action) + is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr." is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply() - else requireNotNull(conductarrAction).invoke(action) } } - private fun play(id: UUID?, restart: Boolean) { - val current = title - val playbackScope = watchScope + private fun play(id: UUID?, position: Long) { viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") - val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") - if (title !== current || watchScope != playbackScope) return@launch - navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + facade.preparePlayback(id) + navigation.navigateTo(Destination.Playback(id, position)) } catch (exception: Exception) { if (exception is CancellationException) throw exception title?.toast = RecoveryMessage.PlaybackUnavailable @@ -329,29 +233,18 @@ class WolfskinTitleViewModel @Inject constructor( /** The screen's own Back handler closes its sheet before delegating a navigation pop. */ @Composable -fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) { - val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() - val watchRefresh = rememberWatchRefresh(viewModel.watchScope) - LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } +fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel()) { + LaunchedEffect(route, shell.person.key) { viewModel.load(route, shell.person, preferences) } val title = viewModel.title?.takeIf { it.person.key == shell.person.key } - val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } val person = viewModel.person.takeIf { title != null } BackHandler(enabled = title != null) { - if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) - } - Box(modifier.onPreviewKeyEvent { event -> - if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { - if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) - true - } ?: false - }) { - if (person != null && route.startsWith("person/")) PersonSurface(person, shell) - else if (title != null) TitleSurface(title, shell) - if (requests != null) DiscoverOverlay(requests) + title?.back(shell) } + if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier) + else if (title != null) TitleSurface(title, shell, modifier) } private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt deleted file mode 100644 index f4188e3f..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import androidx.compose.runtime.* -import org.justinvisser.wolfskin.design.* - -/** Title reads do not depend on a successful recommendation feed to authorize a request. */ -@Composable -internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String, - identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? { - val scope = rememberCoroutineScope() - val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() - val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() } - LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { - if (!fixtures && identity?.signedIn == true) identity.provider.profiles() - } - val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute, - identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn) - else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) { - identity?.takeIf { it.signedIn }?.let { - DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!), - scope, viewModel::openRoute, viewModel::popRoute, - requesters = requesters) - } - } - SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } - LaunchedEffect(requests, title?.data) { - val data = title?.data ?: return@LaunchedEffect - if (!fixtures && requests != null && data.item.tmdbId != null) { - val item = data.requestTitle(shell.person) - requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(), - hidden = if (data.hidden) listOf(item) else emptyList(), - status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready, - savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed)) - } - } - val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) } - LaunchedEffect(requests, hidden, requests?.toast) { - val data = title?.data ?: return@LaunchedEffect - if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden)) - } - val receipt = (requests?.overlay as? DiscoverOverlayState.Request)?.state - LaunchedEffect(receipt?.phase, receipt?.result) { - val data = title?.data ?: return@LaunchedEffect - if (receipt?.phase == RequestPhase.Receipt) title.update(data.copy(requestedStage = receipt.result?.stage ?: "Requested", - requestActor = receipt.result?.actor, requestFor = receipt.selectedPerson.name.takeIf { receipt.selectedPerson.key != shell.person.key })) - } - SideEffect { - viewModel.conductarrAction = { action -> - val current = viewModel.title - when (action) { - is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person)) - 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, showToast = false) - requests.overlay = null // TitleState already owns the native hide receipt and Undo. - } - else -> error("Not a Conductarr title action") - } - } - } - return requests -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt deleted file mode 100644 index fb44b681..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.github.damontecres.wholphin.util.PlaybackWatchState - -/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */ -@Composable -internal fun rememberWatchRefresh(scope: String?): Pair { - val revisions by PlaybackWatchState.revisions.collectAsState() - var foreground by remember(scope) { mutableIntStateOf(0) } - val owner = LocalLifecycleOwner.current - DisposableEffect(owner, scope) { - var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - if (initialResume) initialResume = false else foreground++ - } - } - owner.lifecycle.addObserver(observer) - onDispose { owner.lifecycle.removeObserver(observer) } - } - return (revisions[scope] ?: 0L) to foreground -} 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 aed8ec7b..63e29f9b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -39,9 +39,6 @@ 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 @@ -63,7 +60,6 @@ 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 = {} } @@ -78,43 +74,24 @@ class WolfskinShellViewModel @Inject constructor( private val backdrop: BackdropService, val libraries: LibraryReadFacade, val seeAll: WolfskinSeeAllLists, - val conductarr: ConductarrSession, - artwork: WolfskinArtwork, ) : ViewModel() { - init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow private val requestsSnapshot by lazy { readShellRequests(context) } var libraryDestinations by mutableStateOf>(emptyMap()) private set var signedInPerson by mutableStateOf("") private set - private var identityJob: kotlinx.coroutines.Job? = null - private var personSwitchJob: kotlinx.coroutines.Job? = null - private val personSwitchMutex = Mutex() suspend fun load(server: JellyfinServer, user: JellyfinUser) { - withContext(Dispatchers.IO) { - WolfskinScreenPreference.initialize(context) - ConductarrPreference.initialize(context) - } - identityJob?.cancel() - libraries.audiences = null - if (ConductarrPreference.fixtures) conductarr.close() - 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() || session.fixtureMode != ConductarrPreference.fixtures) { - session.serverId = server.id.toString() - session.fixtureMode = ConductarrPreference.fixtures - val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } - session.state = ShellState(people, signedInPerson) { session.onAction(it) } + WolfskinScreenPreference.initialize(context) + signedInPerson = user.name.orEmpty().lowercase() + if (session.state == null || session.serverId != server.id.toString()) { val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } - val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } - session.state?.updatePeople(savedPeople) - } - identityJob = viewModelScope.launch { - if (!ConductarrPreference.fixtures) loadHousehold(user) + val people = readShellPeople(context, saved.ifEmpty { listOf(user) }) + session.serverId = server.id.toString() + session.state = ShellState(people, signedInPerson) { session.onAction(it) } } + session.onAction = { action -> handleAction(action, server, user) } try { libraryDestinations = libraries.readDestinations(user.id) } catch (exception: Exception) { @@ -123,68 +100,18 @@ class WolfskinShellViewModel @Inject constructor( } } - private suspend fun loadHousehold(user: JellyfinUser) { - conductarr.authenticate() - val identity = conductarr.identity.value ?: return - if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return - val profiles = identity.provider.profiles()?.profiles.orEmpty() - val audiences = identity.provider.audiences() - 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 = if (current) hasAnime else person.hasAnime) - } - session.state?.updatePeople(updated) - signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson - } - - /** Foreground recovery retains the current identity and any usable audience mapping. */ - fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? { - if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null - identityJob?.cancel() - return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it } - } - - suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { - withContext(Dispatchers.IO) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, - retry = conductarr.identity.value?.signedIn != true) - } - loadHousehold(user) - } - fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } - /** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */ + /** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ fun refreshRequests() = refreshRequests(retry = false) private fun refreshRequests(retry: Boolean) { val shell = session.state ?: return val target = shell.requests - val identity = conductarr.identity.value viewModelScope.launch { - if (ConductarrPreference.fixtures) { - target.update(withContext(Dispatchers.IO) { requestsSnapshot }) - } else if (identity?.signedIn != true) { - target.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) - } else identity.provider.requests().collect { snapshot -> - if (conductarr.identity.value?.client === identity.client) { - target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot()) - } - } - if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) { + val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } + target.update(snapshot) + if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) { shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable } } @@ -192,49 +119,34 @@ class WolfskinShellViewModel @Inject constructor( private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { when (action) { - 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) - } - } + 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)) ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) - 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) - refreshRequests(retry = true) - } + is ShellAction.Person -> selectPerson(action.person, server, user) + ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } + ShellAction.RetryRequests -> refreshRequests(retry = true) is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}"))) } } - private fun selectPerson(person: ShellPerson, server: JellyfinServer) { + private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { navigation.replace(Destination.Home()) - if (person.key == signedInPerson) return - identityJob?.cancel() - personSwitchJob?.cancel() - signedInPerson = "" - conductarr.close() - libraries.audiences = null - 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 } - } + if (person.key == user.name.orEmpty().lowercase()) return + viewModelScope.launch { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { 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 } } } } @@ -258,18 +170,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) } - androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } - LaunchedEffect(identity?.signedIn, identity?.person) { - if (!ConductarrPreference.fixtures) { - if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) - else viewModel.refreshRequests() - } - } + LaunchedEffect(server.id, user.id) { viewModel.load(server, user) } val loading = remember(user.id) { ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } } @@ -278,16 +180,10 @@ fun WolfskinApp( state, { route -> navigationManager.navigateTo(Destination.Wolfskin(route)) }, navigationManager::goBack, - identity = identity, - fixtures = ConductarrPreference.fixtures, - retrySignIn = { - viewModel.retryConductarr(server, user) - }, ) RequestsRefresh(state, viewModel::refreshRequests) WolfskinTheme { - CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, - org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { + CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) { Box(modifier) { val current = navigationManager.backStack.last() if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { @@ -304,23 +200,17 @@ 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" } - val isCurrent = navigationManager.backStack.lastOrNull() == destination - LaunchedEffect(destination, state, isCurrent) { - if (isCurrent && !destination.fullScreen) state.enterDestination(route) - } + LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { - BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } + BackHandler { 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 @@ -333,7 +223,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 -> homeContent(state, server, user, contentModifier) + destination is Destination.Home -> HomeDestination(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/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt deleted file mode 100644 index e546a09b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import coil3.ComponentRegistry -import coil3.network.ConnectivityChecker -import coil3.network.okhttp.OkHttpNetworkFetcherFactory -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import javax.inject.Inject -import javax.inject.Singleton -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import org.justinvisser.wolfskin.design.WolfskinImages - -/** Strip credentials after upstream auth injection, including each redirect's network request. */ -@OptIn(coil3.annotation.ExperimentalCoilApi::class) -@Singleton -class WolfskinArtwork @Inject constructor( - @param:AuthOkHttpClient client: OkHttpClient, - repository: ServerRepository, -) { - private val artworkClient = client.newBuilder().addNetworkInterceptor { chain -> - val request = chain.request() - val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull() - val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme && - request.url.host == jellyfin.host && request.url.port == jellyfin.port - chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build()) - }.build() - private val configure: ComponentRegistry.Builder.() -> Unit = { - add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } })) - } - - /** Install host-scoped networking before any Wolfskin artwork is rendered. */ - fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure) -} 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 c73a68ee..0a9a56e5 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -53,15 +53,14 @@ fun WolfskinLibraryContent( modifier: Modifier = Modifier, ) { val view = shell.rememberedView(route) - val fixture by produceState(null, context, ConductarrPreference.fixtures) { - value = null - if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { + val fixture by produceState(null, context) { + if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) { context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } } } - val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } } + val snapshots = remember(context) { 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( + view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/")) else LibraryGridState( title = route.replaceFirstChar { it.uppercase() }, items = emptyList(), onTitle = { navigation.navigateTo(libraryTitleDestination(it)) }, onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) }, @@ -72,32 +71,25 @@ fun WolfskinLibraryContent( ShellPlaceholder("See all list unavailable", modifier) return } - val watchRefresh = rememberWatchRefresh(cacheScope) - LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) { - if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { + LaunchedEffect(grid, grid.refreshRevision, fixture) { + if (route in LIBRARY_DESTINATIONS && (!BuildConfig.DEBUG || 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 { - val store = withContext(Dispatchers.IO) { snapshots.value } - store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot -> - val focusedId = grid.focusedTitle?.id + snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> grid.source = snapshot.items - 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.status = if (snapshot.unreachable && 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.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false + if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false } } - LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view) + LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view) } val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime") 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 10422c09..6e69ac54 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 by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) } + private val store = FileCacheStore(File(context.filesDir, "wolfskin-home")) fun read(scope: String): Pair? { val entry = store.read(scope, KEY) ?: return null diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index bb86e68a..1696930a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -21,52 +21,46 @@ import org.justinvisser.wolfskin.design.HomeLibrary import org.justinvisser.wolfskin.design.HomeTitle /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ -class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { - val audiences get() = libraries.audiences - /** Watch rows are publishable without waiting for any library shelf. */ - suspend fun readWatch(userId: UUID): HomeLibrary { - val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items - val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items - val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> - try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } - catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - null - } - } - val resume = resumeItems.map { title(it, series) } - val next = nextItems.map { title(it, series) } - return HomeLibrary(resume = resume, nextUp = next) - } - - /** Library failures do not discard an already successful watch read. */ - suspend fun readLibrary(userId: UUID): HomeLibrary { +class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) { + suspend fun read(userId: UUID): HomeLibrary { + val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title) + val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title) val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val destinations = linkedMapOf>() val newest = mutableListOf() val totals = linkedMapOf() for (library in libraries) { - val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue + val key = when { + "anime" in library.name.orEmpty().lowercase() -> "anime" + library.collectionType == CollectionType.MOVIES -> "movies" + library.collectionType == CollectionType.TVSHOWS -> "shows" + else -> continue + } val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content - destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) }) + destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title)) totals[key] = (totals[key] ?: 0) + response.totalRecordCount - newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) }) + newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title)) } - return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals) + return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) } - /** Resolve the current checkpoint even when the hero was painted from disk. */ - suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? { - val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run { - val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) - api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { - it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) - }?.id - } ?: return null - return readPlayback(api, userId, id) + suspend fun playableId(userId: UUID, title: HomeTitle): UUID? { + title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id -> + val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content + if (item.type == BaseItemKind.SERIES) { + return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull() + ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull()) + ?.takeIf { it.type == BaseItemKind.EPISODE }?.id + } + return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE } + } + val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) + return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { + it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) + }?.id } - private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { + private fun title(item: BaseItemDto): HomeTitle { val wrapped = BaseItem(item) return HomeTitle( id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, @@ -75,8 +69,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720), seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, - addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, - tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), + addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(), ) } 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 3c1d01ce..fbb037b9 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 @@ -1,11 +1,5 @@ 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 import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -22,48 +16,33 @@ import java.util.UUID import javax.inject.Inject /** SDK reads are confined here so shell and screen state never depend on SDK responses. */ -@javax.inject.Singleton class LibraryReadFacade @Inject constructor(private val api: ApiClient) { - var audiences by mutableStateOf?>(null) - - fun destination(name: String, collection: CollectionType?): String? { - audiences?.let { known -> - val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null - return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows" - } - return when { - name.contains("anime", ignoreCase = true) -> "anime" - collection == CollectionType.MOVIES -> "movies" - collection == CollectionType.TVSHOWS -> "shows" - else -> null - } - } suspend fun readDestinations(userId: UUID): Map { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() return buildMap { libraries.forEach { library -> - val key = destination(library.name.orEmpty(), library.collectionType) + val name = library.name.orEmpty().lowercase() + val key = when { + "anime" in name -> "anime" + library.collectionType == CollectionType.MOVIES -> "movies" + library.collectionType == CollectionType.TVSHOWS -> "shows" + else -> null + } if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) } } } - /** 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 { + suspend fun readLibrary(userId: UUID, destination: String): List { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> - destination(library.name.orEmpty(), library.collectionType) == destination + val anime = library.name.orEmpty().contains("anime", ignoreCase = true) + when (destination) { + "anime" -> anime + "movies" -> !anime && library.collectionType == CollectionType.MOVIES + "shows" -> !anime && library.collectionType == CollectionType.TVSHOWS + else -> false + } } val titles = linkedMapOf() for (library in libraries) { @@ -72,7 +51,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { val response = api.itemsApi.getItems( userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), ).content @@ -90,9 +69,8 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { ) } offset += chunk.size - if (chunk.isNotEmpty()) emit(titles.values.toList()) } while (chunk.isNotEmpty() && offset < response.totalRecordCount) } - if (titles.isEmpty()) emit(emptyList()) + return titles.values.toList() } } 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 fffe1f2c..263922eb 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,25 +19,21 @@ 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, invalidatedAtMillis: Long = 0L, load: () -> Flow>): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> List): 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 && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow - var current: List = emptyList() + if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow try { - load().collect { chunk -> - current = chunk - if (titles == null) emit(LibrarySnapshot(chunk, null, false)) - } + val current = load() 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 ?: current, saved?.savedAtMillis.takeIf { titles != null }, true)) + emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true)) } }.flowOn(Dispatchers.IO) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt deleted file mode 100644 index ad17d57b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import java.util.UUID - -/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */ -internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? { - val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content - val target = if (opened.type == BaseItemKind.SERIES) { - api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true) - .content.items.firstOrNull()?.id - ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id - } else opened.id - val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content } - return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE } -} - -/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */ -internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L 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 1530b8df..bdad22a5 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 @@ -35,7 +35,6 @@ import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.TitlePerson import org.justinvisser.wolfskin.design.TitleTrack import org.justinvisser.wolfskin.design.TitleVersion -import org.justinvisser.wolfskin.design.WolfskinTokens import java.io.File import java.util.Locale import java.util.UUID @@ -53,21 +52,7 @@ class TitleReadFacade @Inject constructor( @param:ApplicationContext private val context: Context, ) { private val json = Json { ignoreUnknownKeys = true } - private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW) - private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE) - - /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ - suspend fun findHeld(tmdb: Long, media: String): UUID? { - val response = api.request(org.jellyfin.sdk.api.client.HttpMethod.GET, "/Items", - mapOf("UserId" to repository.currentUser?.id, "Recursive" to true, - "IncludeItemTypes" to if (media == "movie") "Movie" else "Series", - "AnyProviderIdEquals" to "Tmdb.$tmdb", "Fields" to "ProviderIds", "Limit" to 1), emptyMap(), null) - .createContent() - return response.items?.firstOrNull { row -> - row.type == (if (media == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) && - row.providerIds?.entries?.any { it.key.equals("Tmdb", true) && it.value?.toLongOrNull() == tmdb } == true - }?.id - } + private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) /** A disk copy is decoded before any network read; ownership is isolated by server and user. */ suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) { @@ -78,23 +63,23 @@ class TitleReadFacade @Inject constructor( val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items")) val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } } build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences) - .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false) + .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }) } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } } /** 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 = readItem(id) - val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened + 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 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, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) } + 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 } } val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() - val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty() - val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) } + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -103,29 +88,13 @@ class TitleReadFacade @Inject constructor( val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) .put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString()) .put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty()) - if (cacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Title identity changed") file.parentFile?.mkdirs() try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ } 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) - } - - /** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */ - suspend fun preparePlayback(id: UUID): BaseItemDto? = - readPlayback(api, repository.currentUser?.id, id) + /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ + suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } /** Track selection uses the same repository consumed by the upstream player. */ suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { @@ -166,10 +135,9 @@ class TitleReadFacade @Inject constructor( } else emptyList() val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } - if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") file.parentFile?.mkdirs() try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) } catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ } @@ -192,44 +160,26 @@ class TitleReadFacade @Inject constructor( streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) } - val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) - return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) }, - rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, + return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) }, + rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1, item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url }, cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) }, - episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) }, + episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) }, connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) } - private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem( + private fun mapItem(item: BaseItemDto): TitleItem = TitleItem( item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" }, item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(), poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) }, - backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) }, + backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) }, held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000, seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0, - tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(), ) - private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? { - val type = when { - item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY - item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB - else -> null - } - if (type != null) return images.getItemImageUrl(item.id, type, - maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type)) - return seriesBackdrop ?: item.parentBackdropItemId?.let { parent -> - item.parentBackdropImageTags?.firstOrNull()?.let { tag -> - images.getItemImageUrl(parent, ImageType.BACKDROP, - maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag) - } - } - } - private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json") private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())) diff --git a/conductarr-client/README.md b/conductarr-client/README.md index a589b36d..573929a2 100644 --- a/conductarr-client/README.md +++ b/conductarr-client/README.md @@ -1,10 +1,10 @@ # Conductarr TV client -A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app supplies the currently selected Jellyfin login through its person-scoped `ConductarrSession`. +A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app depends on this module; no screen, navigation, login flow or production service is changed. ## Pinned contract and generator decision -`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `7e8f3f0a1c9a88d67b2a9518c1161db84ade531a`. `openapi/commit.sha` records the source. It was read with `git show :openapi.json` from the Conductarr clone, not downloaded from a running service. +`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `481492d3a8a0651a89901de548cd511218c0562e`. `openapi/commit.sha` records the source. It was read with `git show :openapi.json` from the Conductarr clone, not downloaded from a running service. The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation. @@ -18,8 +18,8 @@ Thus the generator does **not** handle every construct in this pinned document. `ContractTest` checks: -- All 22 TV method/path pairs against the pinned paths, including response and mutation body schemas. -- All 70 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. +- All 20 TV method/path pairs against the pinned paths, including response and mutation body schemas. +- All 68 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. - A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority. When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema. @@ -28,7 +28,7 @@ When updating the pin, update the models and reviewed schema snapshot together. Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token=""`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved. -`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/ratings/display/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. +`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. `client.read(call, trigger)` is a cold `Flow>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists. @@ -49,9 +49,9 @@ Cache keys partition by normalized server URL, credential digest, endpoint, path | Feed | Server `expires_at`; conditional ETag revalidation | | Catalogue options | `FOREGROUND` refreshes | | Discovery pages | 30 minutes per parameterized lane/page | -| Ratings-only read | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | -| Title, cast, related, enrichment, display, performer, filmography, dismissals, readiness | `OPEN` refreshes | -| Seasons | 6 hours for episode scores; `OPEN` refreshes on title-season focus; retained per season, 300 entries | +| Enrichment ratings | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | +| Title, cast, related, performer, filmography, dismissals, readiness | `OPEN` refreshes | +| Seasons | `OPEN` on season focus; retained per season, 300 entries | | Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` | | Title requests and request mutations | No cache | diff --git a/conductarr-client/openapi/commit.sha b/conductarr-client/openapi/commit.sha index 9944c1d5..67a3f347 100644 --- a/conductarr-client/openapi/commit.sha +++ b/conductarr-client/openapi/commit.sha @@ -1 +1 @@ -7e8f3f0a1c9a88d67b2a9518c1161db84ade531a +481492d3a8a0651a89901de548cd511218c0562e diff --git a/conductarr-client/openapi/openapi.json b/conductarr-client/openapi/openapi.json index 5486db7c..6a467051 100644 --- a/conductarr-client/openapi/openapi.json +++ b/conductarr-client/openapi/openapi.json @@ -1097,16 +1097,6 @@ "unknown" ], "type": "string" - }, - "vote_average": { - "description": "Provider episode score on a 0–10 scale; absence is not zero.", - "format": "double", - "type": "number" - }, - "vote_count": { - "description": "Provider episode vote count; explicit zero is retained.", - "format": "int64", - "type": "integer" } }, "required": [ @@ -7068,33 +7058,6 @@ ], "type": "object" }, - "TitleRatings": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleRatings.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "ratings" - ], - "type": "object" - }, "TitleRequestsBody": { "additionalProperties": false, "properties": { @@ -8336,7 +8299,7 @@ "paths": { "/api/v1/activity": { "get": { - "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-activity", "parameters": [ { @@ -8348,14 +8311,6 @@ "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", "type": "string" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -8367,26 +8322,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -8439,7 +8375,7 @@ }, "/api/v1/activity/titles/{tmdb}/{type}": { "get": { - "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-activity-title", "parameters": [ { @@ -8473,14 +8409,6 @@ "description": "Household member to read readiness for. Defaults to the signed-in operator", "type": "string" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -8492,26 +8420,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -9607,7 +9516,7 @@ }, "/catalogue/discover": { "get": { - "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-discover", "parameters": [ { @@ -9675,14 +9584,6 @@ ], "type": "string" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -9694,26 +9595,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -9766,7 +9648,7 @@ }, "/catalogue/options": { "get": { - "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-options", "parameters": [ { @@ -9781,14 +9663,6 @@ ], "type": "string" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -9800,26 +9674,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -9872,7 +9727,7 @@ }, "/catalogue/performers/{performer}": { "get": { - "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-performer", "parameters": [ { @@ -9884,14 +9739,6 @@ "minimum": 1, "type": "integer" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -9903,26 +9750,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -9975,7 +9803,7 @@ }, "/catalogue/performers/{performer}/filmography": { "get": { - "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-filmography", "parameters": [ { @@ -10011,14 +9839,6 @@ "minimum": 1, "type": "integer" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -10030,26 +9850,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -10205,7 +10006,7 @@ }, "/catalogue/titles/{tmdb}/{type}": { "get": { - "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-title", "parameters": [ { @@ -10229,14 +10030,6 @@ ], "type": "string" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -10248,26 +10041,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -10320,7 +10094,7 @@ }, "/catalogue/titles/{tmdb}/{type}/cast": { "get": { - "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-cast", "parameters": [ { @@ -10344,14 +10118,6 @@ ], "type": "string" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -10363,26 +10129,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -10697,97 +10444,9 @@ "summary": "Retained title genres and NL streaming offers" } }, - "/catalogue/titles/{tmdb}/{type}/ratings": { - "get": { - "description": "Authenticated ratings-only display read with the same source observations as enrichment. Server cache: 6 hours for positive results, 1 minute for missing results, at most 300 typed title identities; concurrent identical reads share a fetch. Provider failures are not cached. Expired entries are never served. Reads no Arr, files, mirrors, memberships or watch state and never authorizes a title action. Enrichment and committed title reads remain fresh. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-catalogue-ratings", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleRatings" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Cached title source ratings" - } - }, "/catalogue/titles/{tmdb}/{type}/related": { "get": { - "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-related", "parameters": [ { @@ -10823,14 +10482,6 @@ "minimum": 1, "type": "integer" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -10842,26 +10493,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -10914,7 +10546,7 @@ }, "/catalogue/tv/{tmdb}/seasons/{season}": { "get": { - "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-catalogue-season", "parameters": [ { @@ -10936,14 +10568,6 @@ "minimum": 0, "type": "integer" } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -10955,26 +10579,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { @@ -12121,18 +11726,8 @@ }, "/curation/library-audiences": { "get": { - "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", + "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "operationId": "get-library-audiences", - "parameters": [ - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "content": { @@ -12142,26 +11737,7 @@ } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "401": { "content": { diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt index 59e0d163..5674638c 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt @@ -13,7 +13,7 @@ enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity: DISCOVERY(30 * 60 * 1000L), RATINGS(6 * 60 * 60 * 1000L, 300), ON_OPEN(Long.MAX_VALUE), - SEASON(6 * 60 * 60 * 1000L, 300), + SEASON(Long.MAX_VALUE, 300), ACTIVITY(30 * 1000L), NO_CACHE(0), ; diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt index 8030fcb9..5559f15a 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt @@ -67,7 +67,6 @@ class ConductarrClient( timeouts: ClientTimeouts = ClientTimeouts(), private val nowMillis: () -> Long = System::currentTimeMillis, private val onEvent: (ClientEvent) -> Unit = {}, - dns: PersistentDns? = null, ) : Closeable { private val base = baseUrl.toHttpUrl() private val scope: String @@ -75,7 +74,6 @@ class ConductarrClient( private val cacheIdentityLock = Any() private val catalogueReads = Semaphore(2) private val http = OkHttpClient.Builder() - .apply { if (dns != null) DnsConnectionRetry.configure(this, dns) } .connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS) .readTimeout(timeouts.readSeconds, TimeUnit.SECONDS) .callTimeout(timeouts.callSeconds, TimeUnit.SECONDS) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt deleted file mode 100644 index 4248a4c0..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.IOException -import java.net.InetSocketAddress -import java.net.Proxy -import java.util.concurrent.ConcurrentHashMap -import okhttp3.Call -import okhttp3.EventListener -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Response - -/** Retry once with fresh DNS only if connection establishment failed before any HTTP headers were sent. */ -class DnsConnectionRetry(private val dns: PersistentDns) : Interceptor, EventListener.Factory { - private class Attempt { - @Volatile var connectionFailed = false - @Volatile var headersSent = false - } - private val attempts = ConcurrentHashMap() - - override fun create(call: Call): EventListener { - val attempt = Attempt() - attempts[call] = attempt - return object : EventListener() { - override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, ioe: IOException) { - attempt.connectionFailed = true - } - override fun requestHeadersStart(call: Call) { attempt.headersSent = true } - override fun callEnd(call: Call) { attempts.remove(call) } - override fun callFailed(call: Call, ioe: IOException) { attempts.remove(call) } - } - } - - override fun intercept(chain: Interceptor.Chain): Response { - // OkHttp deliberately disables event listeners on its derived WebSocket client. - val attempt = attempts[chain.call()] ?: return chain.proceed(chain.request()) - return try { - chain.proceed(chain.request()) - } catch (failure: IOException) { - if (!attempt.connectionFailed || attempt.headersSent || chain.call().isCanceled()) throw failure - try { - dns.lookupFresh(chain.request().url.host) - } catch (lookupFailure: IOException) { - lookupFailure.addSuppressed(failure) - throw lookupFailure - } - chain.proceed(chain.request()) - } - } - - companion object { - /** Install the same resolver and its pre-request recovery on a separately owned client. */ - fun configure(builder: OkHttpClient.Builder, dns: PersistentDns): OkHttpClient.Builder { - val retry = DnsConnectionRetry(dns) - return builder.dns(dns).eventListenerFactory(retry).addInterceptor(retry) - } - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt deleted file mode 100644 index 2c65b657..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.File -import java.io.IOException -import java.net.InetAddress -import java.net.UnknownHostException -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlinx.serialization.encodeToString -import okhttp3.Dns - -/** Last-known system answers stay usable for one day while a single background lookup refreshes them. */ -class PersistentDns( - private val file: () -> File, - private val resolver: Dns = Dns.SYSTEM, - private val executor: Executor = Executors.newFixedThreadPool(2) { task -> Thread(task, "wolfskin-dns").apply { isDaemon = true } }, - private val nowMillis: () -> Long = System::currentTimeMillis, -) : Dns { - private class Host { - @Volatile var answer: Answer? = null - @Volatile var refreshAfter = 0L - var refreshing = false - val resolutionLock = Any() - } - - @Serializable - private data class Answer( - val addresses: List, - val savedAtMillis: Long, - @Transient val resolvedAddresses: List = decodeAddresses(addresses), - ) - private val hosts = ConcurrentHashMap() - private val diskLock = Any() - @Volatile private var loaded = false - - override fun lookup(hostname: String): List { - load() - val host = hosts.computeIfAbsent(hostname) { Host() } - val saved = host.answer - if (saved != null && nowMillis() - saved.savedAtMillis in 0..MAX_STALENESS_MILLIS) { - refresh(hostname, host) - return saved.resolvedAddresses - } - return synchronized(host.resolutionLock) { - val current = host.answer - if (current != null && current !== saved && nowMillis() - current.savedAtMillis in 0..MAX_STALENESS_MILLIS) { - current.resolvedAddresses - } else resolve(hostname, host) - } - } - - /** A failed connection bypasses the cached answer and any completed background refresh. */ - fun lookupFresh(hostname: String): List { - load() - val host = hosts.computeIfAbsent(hostname) { Host() } - return synchronized(host.resolutionLock) { - host.answer = null - resolve(hostname, host) - } - } - - private fun refresh(hostname: String, host: Host) { - synchronized(host) { - if (host.refreshing || nowMillis() < host.refreshAfter) return - host.refreshing = true - } - executor.execute { - try { - synchronized(host.resolutionLock) { resolve(hostname, host) } - } catch (_: Exception) { - // Failed refreshes do not extend the lifetime of the last successful answer. - host.refreshAfter = nowMillis() + FAILURE_BACKOFF_MILLIS - } finally { - synchronized(host) { host.refreshing = false } - } - } - } - - private fun resolve(hostname: String, host: Host): List { - val addresses = resolver.lookup(hostname) - if (addresses.isEmpty()) throw UnknownHostException(hostname) - host.answer = Answer(addresses.map { it.hostAddress!! }, nowMillis(), addresses) - host.refreshAfter = nowMillis() + REFRESH_INTERVAL_MILLIS - persist() - return addresses - } - - private fun load() { - if (loaded) return - synchronized(diskLock) { - if (loaded) return - try { - val saved = file() - if (saved.isFile && saved.length() <= MAX_FILE_BYTES) { - val entries = WireJson.decodeFromString>(saved.readText()) - require(entries.size <= MAX_HOSTS) - entries.forEach { (name, answer) -> hosts[name] = Host().apply { this.answer = answer } } - } - } catch (_: IOException) { - // A missing or damaged hint file cannot prevent normal system resolution. - } catch (_: IllegalArgumentException) { - // Serialization and literal-validation errors invalidate the entire hint file. - } - loaded = true - } - } - - private fun persist() = synchronized(diskLock) { - try { - val entries = hosts.entries.mapNotNull { (name, host) -> host.answer?.let { name to it } } - .sortedByDescending { it.second.savedAtMillis }.take(MAX_HOSTS).toMap() - val target = file() - target.parentFile?.mkdirs() - val temporary = File(target.path + ".tmp") - temporary.writeText(WireJson.encodeToString(entries)) - if (!temporary.renameTo(target)) temporary.delete() - } catch (_: IOException) { - // The in-memory answer remains usable when private storage is unavailable. - } - } - - companion object { - const val MAX_STALENESS_MILLIS = 24 * 60 * 60 * 1000L - private const val REFRESH_INTERVAL_MILLIS = 60_000L - private const val FAILURE_BACKOFF_MILLIS = 2_000L - private const val MAX_HOSTS = 32 - private const val MAX_ADDRESSES = 32 - private const val MAX_FILE_BYTES = 64 * 1024L - private fun decodeAddresses(addresses: List): List { - require(addresses.isNotEmpty() && addresses.size <= MAX_ADDRESSES) - return addresses.map { address -> - // Numeric literals only: decoding the hint file must never perform DNS. - require(address.contains(':') || address.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"))) - InetAddress.getByName(address) - } - } - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt index e2f3bd03..f567fee7 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt @@ -52,8 +52,6 @@ object TvRoutes { private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast") private val related = RouteContract("GET", "${title.path}/related", "Page") private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment") - private val ratings = RouteContract("GET", "${title.path}/ratings", "TitleRatings") - private val display = RouteContract("GET", "${title.path}/display", "Display") private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail") private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail") private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage") @@ -65,7 +63,7 @@ object TvRoutes { val contracts: List = listOf( operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast, - related, enrichment, ratings, display, season, performer, filmography, activity, readiness, requests, request, audiences, + related, enrichment, season, performer, filmography, activity, readiness, requests, request, audiences, ) fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH) @@ -99,9 +97,7 @@ object TvRoutes { fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun ratings(tmdb: Long, media: MediaType) = ReadCall(ratings, TitleRatings.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) - fun display(tmdb: Long, media: MediaType) = ReadCall(display, Display.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) + fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall( related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN, ) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt deleted file mode 100644 index 2837856e..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Display wire contract from the pinned Conductarr schema. */ -@Serializable -data class Display( - @SerialName("media_type") val mediaType: MediaType, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("poster_url") val posterUrl: String? = null, - @SerialName("title") val title: String? = null, - @SerialName("tmdb_id") val tmdbId: Long, -) { - @Serializable - enum class MediaType { @SerialName("movie") MOVIE, @SerialName("tv") TV } - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt index 494d0094..212a0b87 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt @@ -12,8 +12,6 @@ data class Episode( @SerialName("number") val number: Long, @SerialName("overview") val overview: String, @SerialName("recorded") val recorded: Recorded, - @SerialName("vote_average") val voteAverage: Double? = null, - @SerialName("vote_count") val voteCount: Long? = null, ) { /** Wire values for recorded. */ @Serializable diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt deleted file mode 100644 index fa754de0..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** TitleRatings wire contract from the pinned Conductarr schema. */ -@Serializable -data class TitleRatings( - @SerialName("ratings") val ratings: List?, -) diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt index 4e51ba98..e5a18435 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt @@ -13,7 +13,6 @@ class CacheTest { Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L), Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L), Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L), - Triple(CachePolicy.SEASON, ReadTrigger.IF_STALE, 21_600_000L), Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L), ) for ((policy, trigger, lifetime) in rows) { diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt index 8c8495fb..98173fce 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt @@ -2,10 +2,6 @@ package org.justinvisser.wolfskin.conductarr import java.nio.file.Files -import java.io.File -import java.net.InetAddress -import java.util.concurrent.Executor -import okhttp3.Dns import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking @@ -18,24 +14,13 @@ fun main() = runBlocking { try { MockWebServer().use { server -> server.start() - val baseUrl = server.url("/").newBuilder().host("localhost").build().toString() - var resolutions = 0 - val dead = InetAddress.getByName("127.0.0.2") - val live = InetAddress.getByName("127.0.0.1") - val dns = PersistentDns({ File(directory, "dns.json") }, Dns { - resolutions++ - if (resolutions == 1) listOf(dead) else listOf(live) - }, Executor { it.run() }) - check(dns.lookup("localhost") == listOf(dead)) + val baseUrl = server.url("/").toString() server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build()) - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).use { client -> + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value check(live.saved.value!!.profiles!!.single().name == "Justin") - check(resolutions == 2) - println("DNS SMOKE: cached 127.0.0.2 refused -> fresh 127.0.0.1 -> profile Justin; requests=${server.requestCount}, resolutions=$resolutions") } - val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() }) - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).use { client -> + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value check(saved.saved.fromDisk) server.enqueue(MockResponse.Builder().code(304).build()) diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt index 937543a9..3d2143a4 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt @@ -121,7 +121,7 @@ class ClientTest { @Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking { val client = client() - val call = TvRoutes.ratings(42, MediaType.MOVIE) + val call = TvRoutes.enrichment(42, MediaType.MOVIE) server.enqueue(response("missing", 404)) assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) now += 6 * 60 * 60 * 1000L - 1 diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt index fcebb09f..b93be09c 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt @@ -29,7 +29,7 @@ class ContractTest { for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name]) } - @Test fun `all twenty two TV operations exist with their typed request and response contracts`() { + @Test fun `all twenty TV operations exist with their typed request and response contracts`() { val expected = setOf( "GET /api/v1/operator", "GET /api/v1/recommendations/profiles", "GET /api/v1/recommendations/profiles/{profile}/feed", @@ -39,7 +39,6 @@ class ContractTest { "GET /catalogue/options", "GET /catalogue/discover", "GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast", "GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment", - "GET /catalogue/titles/{tmdb}/{type}/ratings", "GET /catalogue/titles/{tmdb}/{type}/display", "GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}", "GET /catalogue/performers/{performer}/filmography", "GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}", @@ -48,19 +47,6 @@ class ContractTest { ) assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet()) assertEquals(expected.size, TvRoutes.contracts.size) - val operations = setOf( - "get-cockpit-operator", "get-recommendation-profiles", "get-library-audiences", - "get-recommendation-feed", "get-catalogue-options", "get-catalogue-discover", - "get-recommendation-dismissals", "post-recommendation-dismissal", "delete-recommendation-dismissal", - "get-catalogue-title", "get-catalogue-cast", "get-catalogue-related", "get-catalogue-season", - "get-catalogue-enrichment", "get-catalogue-display", "get-activity-title", "get-title-requests", - "get-catalogue-performer", "get-catalogue-filmography", "post-request-intent", "get-activity", - "get-catalogue-ratings", - ) - assertEquals(operations, TvRoutes.contracts.map { route -> - document.getValue("paths").jsonObject.getValue(route.path).jsonObject - .getValue(route.method.lowercase()).jsonObject.getValue("operationId").jsonPrimitive.content - }.toSet()) val paths = document["paths"]!!.jsonObject for (route in TvRoutes.contracts) { val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt deleted file mode 100644 index 58b72d98..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.net.InetAddress -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.Dispatcher -import okhttp3.Dns -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** OkHttp's listener-free WebSocket client must retain a working upgrade and message path. */ -class DnsConnectionRetryTest { - @get:Rule val directory = TemporaryFolder() - - @Test fun derivedClientOpensWebSocketWithoutAnUncaughtDispatcherException() { - val opened = CompletableFuture() - val message = CompletableFuture() - val uncaught = AtomicReference() - val executor = Executors.newCachedThreadPool { task -> - Thread(task, "dns-websocket-test").apply { - uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, failure -> - uncaught.set(failure) - opened.completeExceptionally(failure) - message.completeExceptionally(failure) - } - } - } - MockWebServer().use { server -> - server.enqueue(MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { webSocket.send("ready") } - }).build()) - server.start() - val dns = PersistentDns({ directory.root.resolve("dns.json") }, Dns { listOf(InetAddress.getByName("127.0.0.1")) }) - val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns) - .dispatcher(Dispatcher(executor)).build().newBuilder().build() - val socket = client.newWebSocket(Request.Builder().url(server.url("/events")).build(), object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(response.code) } - override fun onMessage(webSocket: WebSocket, text: String) { message.complete(text) } - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - opened.completeExceptionally(t) - message.completeExceptionally(t) - } - }) - try { - assertEquals(101, opened.get(5, TimeUnit.SECONDS).toInt()) - assertEquals("ready", message.get(5, TimeUnit.SECONDS)) - assertNull(uncaught.get()) - println("WEBSOCKET SMOKE: derived cached-DNS client opened HTTP 101, received ready, no uncaught dispatcher exception") - } finally { - socket.cancel() - client.connectionPool.evictAll() - executor.shutdownNow() - } - } - } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt index a7384768..ea1272ef 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt @@ -13,7 +13,6 @@ internal val modelSerializers: List> = listOf( CastMember.serializer(), Detail.serializer(), Discovery.serializer(), - Display.serializer(), Enrichment.serializer(), Episode.serializer(), EpisodeFileChip.serializer(), @@ -73,7 +72,6 @@ internal val modelSerializers: List> = listOf( TitleCast.serializer(), TitleMetadata.serializer(), TitleRequestsBody.serializer(), - TitleRatings.serializer(), TitleVersion.serializer(), ViewerReadiness.serializer(), ) diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt deleted file mode 100644 index b167b354..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.File -import java.net.InetAddress -import java.net.Proxy -import java.net.UnknownHostException -import java.util.concurrent.Executor -import java.util.concurrent.TimeUnit -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.ConnectionPool -import okhttp3.Dns -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** Fake time and queued refreshes prove stale answers, disk hints and dead-address recovery. */ -class PersistentDnsTest { - @get:Rule val directory = TemporaryFolder() - private var now = 1_000_000L - private val queued = ArrayDeque() - private val background = Executor { queued.add(it) } - private val old = InetAddress.getByName("127.0.0.2") - private val fresh = InetAddress.getByName("127.0.0.1") - private var answer = listOf(old) - private var lookups = 0 - private val resolver = Dns { lookups++; answer } - private val file get() = File(directory.root, "dns.json") - private fun cache(resolve: Dns = resolver) = PersistentDns({ file }, resolve, background, { now }) - - @Test fun staleAnswerReturnsBeforeBackgroundRefreshAndNextLookupUsesChangedIp() { - val dns = cache() - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - now += 120_000 - answer = listOf(fresh) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - assertEquals(1, lookups) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - assertEquals(1, queued.size) - queued.removeFirst().run() - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - assertEquals(2, lookups) - } - - @Test fun answersOlderThanOneDayCannotBeServedEvenWhenRefreshFails() { - val dns = cache() - dns.lookup("jellyfin.test") - now += PersistentDns.MAX_STALENESS_MILLIS + 1 - val expired = cache(Dns { throw UnknownHostException() }) - assertThrows(UnknownHostException::class.java) { expired.lookup("jellyfin.test") } - } - - @Test fun persistedAnswersRetainTimestampAndSystemAddressOrdering() { - answer = listOf(InetAddress.getByName("::1"), fresh) - cache().lookup("jellyfin.test") - now += 120_000 - val reopened = cache(Dns { error("Disk answer must return before resolving") }) - assertEquals(answer, reopened.lookup("jellyfin.test")) - assertEquals(1, queued.size) - now += PersistentDns.MAX_STALENESS_MILLIS - assertThrows(UnknownHostException::class.java) { - cache(Dns { throw UnknownHostException() }).lookup("jellyfin.test") - } - } - - @Test fun corruptOrNonnumericDiskAnswersAreIgnored() { - for (contents in listOf("not json", "{\"jellyfin.test\":{\"addresses\":[\"not-an-ip.test\"],\"savedAtMillis\":1000000}}")) { - file.writeText(contents) - answer = listOf(fresh) - assertEquals(listOf(fresh), cache().lookup("jellyfin.test")) - } - assertEquals(2, lookups) - } - - @Test fun negativeAnswersDoNotDelayRecoveryAndFailedRefreshDoesNotExtendStaleness() { - var offline = true - val dns = cache(Dns { if (offline) throw UnknownHostException(); listOf(fresh) }) - assertThrows(UnknownHostException::class.java) { dns.lookup("jellyfin.test") } - offline = false - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - offline = true - now += 120_000 - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - assertTrue(queued.isEmpty()) - now += 2_001 - dns.lookup("jellyfin.test") - assertEquals(1, queued.size) - } - - @Test fun unexpectedRefreshExceptionKeepsTheLastAnswerAndAllowsAnotherRefresh() { - var failing = false - val dns = cache(Dns { if (failing) throw IllegalStateException("resolver unavailable"); answer }) - dns.lookup("jellyfin.test") - now += 120_000 - failing = true - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - now += 2_001 - failing = false - answer = listOf(fresh) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - } - - @Test fun failedCachedAddressForcesFreshResolutionAndRetriesBeforeSendingPost() { - MockWebServer().use { server -> - server.start() - val dns = cache() - dns.lookup("jellyfin.test") - answer = listOf(fresh) - server.enqueue(MockResponse.Builder().body("new address").build()) - val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns) - .proxy(Proxy.NO_PROXY).retryOnConnectionFailure(false).connectTimeout(1, TimeUnit.SECONDS) - .connectionPool(ConnectionPool(0, 1, TimeUnit.MILLISECONDS)).build() - try { - val url = server.url("/").newBuilder().host("jellyfin.test").build() - client.newCall(Request.Builder().url(url).post("body".toRequestBody()).build()).execute().use { - assertEquals("new address", it.body.string()) - } - assertEquals(2, lookups) - assertEquals(1, server.requestCount) - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - } finally { - client.connectionPool.evictAll() - client.dispatcher.executorService.shutdown() - } - } - } - - @Test fun backgroundIpChangeIsUsedByTheNextHttpCall() { - MockWebServer().use { server -> - server.start() - val dns = cache() - dns.lookup("jellyfin.test") - now += 120_000 - answer = listOf(fresh) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - server.enqueue(MockResponse.Builder().body("refreshed").build()) - val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).proxy(Proxy.NO_PROXY).build() - try { - val url = server.url("/").newBuilder().host("jellyfin.test").build() - client.newCall(Request.Builder().url(url).build()).execute().use { assertEquals("refreshed", it.body.string()) } - assertEquals(2, lookups) - assertEquals(1, server.requestCount) - } finally { - client.connectionPool.evictAll() - client.dispatcher.executorService.shutdown() - } - } - } -} diff --git a/conductarr-client/src/test/resources/model-contracts.json b/conductarr-client/src/test/resources/model-contracts.json index 41a0b411..659c72cb 100644 --- a/conductarr-client/src/test/resources/model-contracts.json +++ b/conductarr-client/src/test/resources/model-contracts.json @@ -444,16 +444,6 @@ "unknown" ], "type": "string" - }, - "vote_average": { - "description": "Provider episode score on a 0\u201310 scale; absence is not zero.", - "format": "double", - "type": "number" - }, - "vote_count": { - "description": "Provider episode vote count; explicit zero is retained.", - "format": "int64", - "type": "integer" } }, "required": [ @@ -3179,77 +3169,5 @@ "sources" ], "type": "object" - }, - "Display": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Display.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "poster_url": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state" - ], - "type": "object" - }, - "TitleRatings": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleRatings.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "ratings" - ], - "type": "object" } } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md deleted file mode 100644 index 4fb76581..00000000 --- a/data/tv-spec/spec.md +++ /dev/null @@ -1,61 +0,0 @@ -# Wolfskin TV specification — revision 5 - -2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`. - -## Who’s watching - -The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person. - -## Focus visibility - -“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule. - -The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation. - -Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it. - -## Episode artwork and dimensions - -Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it. - -Choose available art in this order: - -1. Episode `Primary` image — Jellyfin stores episode stills under this type. -2. Episode `Thumb` image. -3. Series `Backdrop`, centre-cropped to the episode slot. -4. The existing missing-art treatment when none is available. - -A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions. - -Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders. - -## Episode summaries and series header spacing - -In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label. - -A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons. - -## Hero image quality and memory - -| Surface | Requested/decode bound | Format policy | -|---|---|---| -| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound | -| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB | -| Episode card | 400×280 | ARGB, 0.43 MiB | -| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy | - -Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop. - -The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box. - -Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080. - -## Discover lane spacing - -Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged. - -## Sources and verification - -Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. - -The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/data/tv-v2/feedback-2026-11-06/delta.json b/data/tv-v2/feedback-2026-11-06/delta.json deleted file mode 100644 index 5e732f73..00000000 --- a/data/tv-v2/feedback-2026-11-06/delta.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "specRevision": 5, - "base": "data/tv-v2/focus-2026-11-06/index.html", - "baseSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", - "stagePx": [1920, 1080], - "density": 2, - "picker": { - "gearAlignment": "center of the complete people row", - "gearRing": "same accent straddle ring as picker people", - "gearFocusScale": 1.08, - "navigation": "unchanged: Down from the final person only" - }, - "focus": { - "horizontalRule": "second-last-slot lookahead; clamp first/last to real contents", - "minimumScreenSideMarginPx": 40, - "headingPinPx": 168, - "geometricCentering": false, - "discoverPosterSlots": 6 - }, - "episode": { - "artPx": [400, 280], - "shelfHeightPx": 432, - "gapBeforeCastPx": 68, - "artFallback": ["episode Primary", "episode Thumb", "series Backdrop crop", "missing art"], - "summaryContext": ["focused episode", "explicitly opened episode", "up-next episode", "show when no episode context"], - "focusedCardSummaryLines": 2 - }, - "seriesHeader": { - "height": "measured copy, actions and caption band plus gap", - "gapBeforeSeasonsPx": 40, - "otherTitleHeaders": "unchanged" - }, - "discover": { - "allLaneHeadingBandPx": 104, - "allLaneHeadingToCardGapPx": 60, - "posterShelfHeightPx": 652, - "serviceShelfHeightPx": 316, - "homeHeadingBand": "unchanged" - }, - "images": { - "heroDecodeBoundPx": [1920, 1080], - "heroPosterDecodeBoundPx": [420, 630], - "jellyfinQuality": 96, - "memoryCacheMiB": 64, - "diskCacheMiB": 128, - "concurrentReads": 4 - }, - "nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-fb1-visual/", - "specification": "../../tv-spec/spec.md" -} diff --git a/data/tv-v2/feedback-2026-11-06/index.html b/data/tv-v2/feedback-2026-11-06/index.html deleted file mode 100644 index 7b715570..00000000 --- a/data/tv-v2/feedback-2026-11-06/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - -Wolfskin TV · feedback delta · revision 5 - -

Wolfskin TV · feedback delta

-

Specification revision 5. The approved frames remain the baseline; these native 1920×1080 frames show only the requested feedback changes. This is a frame prototype, not a simulated Jellyfin client.

- -

Who’s watching

Gear centred beneath everyone, with the person-style focus ring.

-Native picker after: Settings centred below the complete people row -
BeforeNative picker before: gear below the final person
-

Home

Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.

-Native Home after: focused card safely inside a long shelf -
BeforeNative Home baseline
-
Real-source hero request comparison

Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.

Native hero using the old 720p source responseNative hero using the panel-resolution source response
-

Discover

60 px from lane title to cards; all lanes use the same 104 px heading band.

-Native Discover after: more space above every lane's cards -
BeforeNative Discover before: smaller heading gap
-

Series header

Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.

-Native Bear series header after: episode overview and visible thumbnail row -
BeforeNative Bear series header before: show overview and empty space before seasons
-

Episode shelf

Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.

-Native Bear episodes after: taller thumbnail cards, focused overview and Cast below -
BeforeNative Bear episodes before: monitor placeholders
-

Person

Six full-width poster columns; no shrinking of the final column.

-Native Person after -
BeforeNative Person before
- diff --git a/docker/README.md b/docker/README.md index 2c1c4c56..cc822873 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, `realpath` (GNU coreutils), and `flock` -(util-linux), not Java or the Android SDK. +host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the +Android SDK. ```sh git fetch origin --tags @@ -27,143 +27,6 @@ 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. -### Full-suite test diagnostics - -The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate -from the forked test-worker heap. The default test worker uses 512 MiB; changing -the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve -the person-picker fixture's missing Compose snapshot notifications. - -Gradle 9.8.2 runs previously failed test classes first on the next invocation. -An immediate green `make test` rerun can therefore hide an order-sensitive -failure rather than prove a fix. For comparisons, clear only generated binary -test-result history (`build/test-results//binary` in each module) before -each run and record actual class-start order; XML file listing order is not -execution order. Keep worker heap and parallelism unchanged between samples. -`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone -or promoted to the front by previous failures. - -## Saved-person launch and DNS - -Launch restores an unprotected saved Jellyfin login from local storage before -network validation. The existing Wolfskin picker paints the saved names and -identity-derived colors, then refreshes the household in the background. PIN and -require-login profiles still go through the existing protected sign-in flow. -`Users/Me` and `System/Info/Public` now start concurrently; a late launch response -cannot replace a different selected person. Name revalidation updates only the -name column and reloads the saved user, preserving newer settings and PINs. - -Jellyfin, Conductarr, artwork, playback and crash-report clients share one -`PersistentDns` instance through the application graph. It keeps system-ordered -IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts). -Saved answers return immediately for up to 24 hours and refresh off main after -60 seconds; failed background lookups back off for 2 seconds without extending -the last successful timestamp. Unexpected resolver exceptions are contained at -the background refresh and warm-up boundaries. Negative answers are not persisted or cached. -Missing, oversized or corrupt hint files fall back to system resolution. -Application creation warms the saved Jellyfin host and configured Conductarr -host on parallel IO jobs; it does not change Android Private DNS. - -A connection-establishment failure before any HTTP request headers were sent -gets one fresh system lookup and one retry. Responses, read failures and -uncertain mutation outcomes are not replayed. Conductarr retains its existing -no-automatic-replay policy after a request starts. A DNS refresh changes the next -new connection; already-working pooled connections are not forcibly closed. -OkHttp's derived WebSocket clients disable event listeners; those calls bypass -the HTTP retry bookkeeping while retaining the shared DNS resolver. - -Device acceptance (not established by JVM tests): with saved people present, -cold-launch six times more than 60 seconds apart and require the picker within -0.5 seconds on every launch. Press Left/Right then one OK; the selected Home must -open without a second picker. Open the avatar picker, change person with one OK, -and repeat after an offline launch to confirm saved people still paint. - -Compare timestamps of the launch pair of SDK log lines -`GET https://jelly.justinvisser.org/Users/Me` and -`GET https://jelly.justinvisser.org/System/Info/Public` (ignore later foreground -`Users/Me` refreshes). The spike's two slow launch gaps were 1.508 and 1.509 -seconds. The requests now overlap, so either may log first; the picker must not -wait for either response. The minified SDK log tag is not stable across builds. - -## 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`) 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. 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 -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/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 -`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. -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 -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. -`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug -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 The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and @@ -215,19 +78,12 @@ 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 ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. -The small gear centred beneath the complete people row opens Settings with Requests focused. +The small gear under the final person opens Settings with Requests focused. Settings groups hand off to existing upstream pages; Requests opens the household Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent rollout switch: turning it off renders upstream Home and library destinations with @@ -236,34 +92,10 @@ Settings list. Native Up traversal stays in content until the first focusable ro then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. -Saved Jellyfin identities use the existing upstream session. Conductarr supplies -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. - -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 → -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. +Debug people, tabs and initials come from the shell-only projection of the locked +prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources +do not package it. Saved Jellyfin identities use existing upstream services. The +shell makes no Conductarr calls and does not add Conductarr sign-in. Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` @@ -271,21 +103,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. -The mutation adapter accepts the selected person's `ConductarrClient` and uses -only typed routes. Request submission first reads title receipts. Unknown outcomes -are checked, never blindly re-sent, including after closing a sheet or choosing -the same uncertain recipient again. Whole-series requests require season metadata. +`app/src/debug/assets/wolfskin-discover/`. Release builds remain on the explicit +Conductarr sign-in state. The fixture host cannot construct a network client: +debug request outcomes and dismissals stay in memory. The real mutation adapter +accepts an injected `ConductarrClient` and uses only its typed routes; it is not +wired to a live server. Unknown request outcomes force a fresh title-requests read +instead of blindly submitting again, including after closing a sheet or choosing +the same uncertain recipient from another chooser. Whole-series requests require every season. Discover hero and Restore text use explicit per-line CSS allocations so native paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles @@ -373,35 +198,22 @@ an untouched fresh entry focuses hero button zero; intervening nav input prevent that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` release/repeat guard and Back ladder. -`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up -publish independently of slow or failed library shelves; saved metadata still -paints immediately, but a saved hero offers Details until watch data revalidates. -Home and title Play actions resolve the current Jellyfin item and its current -checkpoint at the click, preserving millisecond positions. Series actions use -live NextUp with resumable episodes enabled; an explicitly opened episode stays -that episode, and Restart deliberately starts that displayed episode at zero. -Played flags do not discard a positive checkpoint for an intentional rewatch. -No season, recommendation stub or different person's session becomes playable. +`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release +variants read the selected user's Resume, Next up, newest titles and destination +shelves, with app-private last-known-good projections through the shared atomic +disk cache. A missing saved library becomes a recovery panel; a saved library +stays visible when Jellyfin is unreachable. Playback resolves an actual film or +episode and checks Jellyfin before opening upstream's player. No season, +recommendation stub or different person's session is promoted to playback. An unresolved person switch guards Home and both owned/upstream library content before mounting any source read with the prior person's Jellyfin session. -Home and title watch reads refresh on entry and foreground. A successful -PlaybackStopped acknowledgement publishes a revision for the captured server/user, -so a return read that beat the stop write is refreshed again after that write. -The acknowledgement also removes that user's persisted library grid snapshots -instead of waiting for their ten-minute TTL; an in-memory revision bypasses any -older snapshot that raced invalidation. Stop-report failures remain logged and -do not publish a successful acknowledgement. External players refresh after a -supported stop result; an external app that returns no position cannot supply a -missing checkpoint. Another client's changes after the final read are outside -that click's snapshot. - Debug variants additionally package the locked fixture and local artwork under `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. -The explicit fixture provider supplies approved household data and Home states -without network reads in tests. Real signed-out Home hides every rating and shows -"Discover needs sign-in"; signed-in Home reads the Conductarr continuation and -ratings-only endpoint. No Seerr fallback is used. +The fixture provider supplies the approved household data and Home states without +network reads in tests. Conductarr sign-in is not implemented here: real signed-out +Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is +used. Catalogue data and ratings in debug captures are fixture-backed. Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`; those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters, @@ -430,31 +242,22 @@ are recorded differences, not a claim of browser pixel equality. ## Requests under Settings -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. +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”. -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 -acceptance states; the identical projection is in design-test resources. -No “sample” or “example stages” pill is drawn. The default provider instead maps -Conductarr household activity, authenticated actors and display artwork into -the same sheet. Release excludes all fixture assets. There is no Seerr fallback. +The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and +its eight local posters, projected from the locked prototype fixture. Six stage +rows feed the running debug shell; the Shared and focused-rating records feed +native acceptance states. The identical projection is in design-test resources. +No “sample” or “example stages” pill is drawn. Release builds package none of +these assets and show Requests unavailable until Conductarr sign-in supplies +the generated-client data path. There is no Seerr fallback or handwritten HTTP. Foreground refreshes once; an open sheet or unsettled requests refresh every 30 seconds while started. The immutable debug projection is loaded once, not decoded again on every poll. @@ -499,22 +302,12 @@ 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 -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 +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 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 @@ -530,8 +323,8 @@ The debug-only `wolfskin-library.json` and `library/assets/` contain the locked prototype's titles and local poster art. Release libraries read through `LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person snapshots using the existing atomic disk cache. Saved data is painted before a -quiet refresh; a failed refresh keeps it. Production ratings remain hidden before -Conductarr sign-in and use the shared ratings-only provider afterwards. +quiet refresh; a failed refresh keeps it. Production ratings remain hidden until +Conductarr sign-in exists. Home/Discover shelves open a full list through `WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`. @@ -549,109 +342,6 @@ 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. Card lift parameters are unchanged; -cast portraits and episode tiles now receive that lift too. The stroke and art -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. - -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/`. -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. - -### TV feedback corrections (spec revision 5) - -The [revision-5 amendment](../data/tv-spec/spec.md) and -[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) -retain the approved frames except for the requested feedback changes: - -- The picker gear is centred under everyone and uses the picker-person ring. -- Shelf tracks share second-last-slot lookahead, count clamping and settled - offsets. Home records completed frame positioning rather than an animation - that may still be cancelled; first mount restores the intended shelf item. - Discover uses all six approved poster slots. Person keeps the final column - at full poster width. -- Episode images choose episode Primary, then Thumb, then the already-read - series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance. - Episode context uses the episode overview, with two summary lines on the - focused card. Held-series headers measure their copy/actions and leave 40 px - before seasons instead of reserving a fixed empty 900 px header. -- Every Discover heading uses a 104 px band, including a 60 px title-to-card - gap. Home heading geometry is unchanged. -- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - 420×630. The shared request builder replaces conflicting Jellyfin size/encoding - parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG - quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and - 400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and - authentication query parameters remain intact. TMDB uses w342 for posters and - portraits, w780 for landscape cards and w1280 for hero backdrops, never original. - Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks - decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory - cache, 128 MiB disk cache and four image-read limit remain unchanged. - -`HomeViewportTest` covers short/long poster, tile and wide rows, both row -directions, first focus and native Back from the actual title surface. Native -proof records real Bear season-three metadata through the title read facade; -the proof-only harness is not part of the shipped tests or APK. - -Episode lists request only ProviderIds and Overview. Next up reads only identity, -then hydrates the selected playback target through the existing single-item read, -including when Next up is empty. Media sources/streams and cast remain on detail -reads for header track/version controls. Performer credits omit media sources, -streams and ParentId but retain People because their cards show character roles. -Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName -ordering does not require returning SortName on every item. - - ## Title pages and people `TitleSurface` owns the film/series/episode header, season chips, episode tiles, @@ -671,33 +361,25 @@ Selections persist through the same playback repository as the upstream player. Marking a whole series asks first; an explicit episode remains the exact play and watched target even while another season is selected. Nested person/title visits retain their entry's focus and selection state. -Choosing another episode from an episode page replaces only the top navigation -entry, preserving its series/Home parent. Choosing the first episode from a -series, related titles, cast and performer credits still push normal drill-ins. -Down into season chips initially focuses the selected playable season, including -offscreen later seasons; the row scrolls that chip into view and remembers -subsequent deliberate browsing. Saved title watch data cannot enable Play/Resume -or Restart before source revalidation. Hardware and remote Back share the sheet/picker/request-overlay precedence, then pop a pushed title/person from content **or** nav. Initial loading has no content target: Down stays in nav, while Back can still leave the title. A missing rating set emits no layout node, so it cannot reserve an invisible extra facts gap. `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, -person credits and playback mutations to one boundary. `ConductarrTitlePages` -adds real detail, cast, related titles, performer/filmography, readiness/receipts -and source ratings. A catalogue title gains held/play controls only after an -exact TMDB lookup returns this Jellyfin user's actual item identity. Episode -votes enrich those real episode identities; no catalogue stub becomes playable. -Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit -Advanced fixture switch; release never packages or selects them. +person credits and playback mutations to one boundary. Cached content appears +before refresh. Production ratings stay hidden until Conductarr sign-in exists. +Debug-only `wolfskin-titles.json` and local `wolfskin-titles/assets/` project the +locked household fixture, including portraits; release never packages them. +Those synthetic IDs are not invented Jellyfin playback IDs. Real held-title +playback uses the existing player; debug catalogue identities cannot play a file. Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an installed YouTube handler such as SmartTube, never a browser. No handler leaves the title visible with a calm message. Titles nobody holds have no Trailer -button until Conductarr's trailer field exists. Title requests use the same -actor-safe chooser and identity-lifetime uncertain-submission ledger as Discover. -Signed-out and offline pages cannot submit; no Seerr fallback is used. +button until Conductarr's trailer field exists. Conductarr sign-in and request +submission/receipt workflows belong to their separate slices; title pages do not +pretend those operations succeeded or fall back to Seerr. `TitleCaptureTest` writes the 34 approved title/person/options frames plus native text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved @@ -758,133 +440,10 @@ real upstream playback destination with offline fixtures. New native captures an rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score captures configure the Android resource locale as well as the JVM locale. -Conductarr transport and identity live outside the state components. A revoked -sign-in discards the current person's authenticated projections; an outage keeps -last-known-good (LKG) data and its Saved timestamp. No Seerr fallback or offline -request queue is used. Title and performer pages use the same authenticated cache. - -## Conductarr production wiring - -The default endpoint is `BuildConfig.CONDUCTARR_BASE_URL`: -`https://wolfskin-tv.justinvisser.org`. Override **Conductarr server URL** beside -**Wolfskin screens** in Advanced settings; blank restores the build default. -HTTPS is required except for loopback fake servers. **Wolfskin preview fixtures** -is a debug-only, off-by-default switch; it never sends HTTP mutations. - -`ConductarrSession` borrows the currently selected user's existing Jellyfin access -token. It sends only `Authorization: MediaBrowser Token=""`, with no cookie, -API key or second credential store. Person/server/token/endpoint changes discard -the old client and in-memory providers. LKG files are isolated by person and client -identity in app-private, backup-excluded storage. A 401 clears that identity's -cache and returns to sign-in states; Try again reconstructs and verifies the client. - -Home publishes Jellyfin/LKG rows independently of Conductarr, including retry -authentication, and paints new Jellyfin data before writing its disk copy. -Production calls use 3-second connect, 5-second read and 8-second total limits. -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. -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. -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 -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. -When availability changes, focus follows the avatar or a surviving destination; -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 | -| --- | --- | -| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | -| Home continuation | Recommendation feed, catalogue options/discovery; Coming soon stays Discover-only | -| Discover | Feed/contributions, services, New on, Trending, genres, Coming soon, parameterized service shelves, dismissal reads/writes | -| Catalogue paging | Two-shelf read-ahead, distinct title identities, per-parameter disk pages, retry/duplicate-only tails; no page counters | -| Request sheet | Capability-limited requesters, season metadata, POST receipt, receipt lookup before any resubmit; actor comes only from the token | -| Requests | Household activity, stage precedence, actor/requester evidence and display posters; foreground/30-second refresh | -| Ratings | Ratings-only read after 350 ms card dwell; immediate hero/title/Coming-in reads; per-type six-hour/300-entry cache; episode season votes | -| Titles and people | Detail/enrichment/display, cast, related, real Jellyfin season episodes plus votes, performer/filmography, title activity and uncached receipts; shared request/hide workflows | - -The pinned client exposes exactly the 22 TV operations, including ratings and -display. Conditional reads send ETags and reuse the saved body on 304. The fake -server tests use representative wire JSON, never a live Conductarr or Jellyfin. -`ConductarrSessionTest`, `ConductarrProjectionTest`, `ConductarrTitlePagesTest`, -`ConductarrSurfaceTest`, `ConductarrHomeSurfaceTest` and `ConductarrTitleSurfaceTest` cover identity/cache -isolation, 401/retry, saved offline data, actor-safe requests, ratings timing, -title playback permissions/episode votes and native real-provider surfaces. -The existing fixture screen tests remain in the normal `make test` gate. -`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts -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. -`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. -`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. -Unheld trailers remain absent because the server contract has no trailer field. -Saved Jellyfin logins are not fabricated from Conductarr profiles: a person -without an upstream token remains signed out. Fixtures require explicit debug opt-in. - -### Manual smoke after endpoint deployment (no build command) - -Use an already authorized test build and a sandbox that accepts its selected -Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin. - -1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared, - then another saved person; verify their operator/requester and independent views. - Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or - IP addresses are refused even with the correct token. -2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast - member and filmography credit; check real facts, related titles, episode votes, - stage/actor labels, card dwell ratings and continuous See all loading. -3. Make the sandbox return 503 after a successful read. Reopen: saved content and - Saved time remain, requests cannot submit, and Try again performs another read. -4. Return 401, then restore authentication. Verify ratings/request actions disappear - and this person's saved Conductarr data is cleared. Try again restores data - without changing Jellyfin login or copying a token. -5. Have the sandbox accept a Shared-on-behalf request but lose its POST response. - Use Check again: the receipt says Shared, and the sandbox records one POST. -6. Restore the blank/default URL when the dedicated TV hostname and server's - 22-operation authority are deployed. This checklist is manual, not a live gate. - +Production Conductarr TV sign-in/provider wiring remains outside these state +components: release Discover is signed out and release Requests is unavailable. +The generated client's identity-scoped disk cache is the Conductarr cache boundary; +no Seerr fallback, live-test server, handwritten HTTP or offline request queue is added. Title and person surfaces publish saved timestamps through the same per-person `RememberedView` partition and nav Saved indicator. Their timed notices render the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage` @@ -893,48 +452,6 @@ 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 @@ -964,41 +481,6 @@ 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 461018bc..b5624cbf 100755 --- a/docker/gradle.sh +++ b/docker/gradle.sh @@ -35,36 +35,5 @@ 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 diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index bf45e265..83266ff5 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -1,6 +1,5 @@ { "discover": { - "shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"}, "sheetPaddingX": { "$type": "dimension", "px": 64, 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 4c7e5f8d..db0a22f6 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.ringCardRing, FocusLift.Card) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, 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/ComingInRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt index bd990e32..3f2b0da1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.foundation.background import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.tv.material3.Text import java.util.Locale @@ -32,8 +31,6 @@ fun ComingInRatings(ratings: List, color: Color) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { val mark = when (rating.mark) { "fresh" -> R.drawable.coming_in_rating_fresh - "tomato" -> R.drawable.coming_in_rating_fresh - "popcorn" -> R.drawable.coming_in_rating_upright "rotten" -> R.drawable.coming_in_rating_rotten "upright" -> R.drawable.coming_in_rating_upright "spilled" -> R.drawable.coming_in_rating_spilled @@ -43,8 +40,7 @@ fun ComingInRatings(ratings: List, color: Color) { val wide = rating.mark == "imdb" || rating.mark == "tmdb" Image(painterResource(mark), contentDescription = rating.mark, modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f) - .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3) - .graphicsLayer { alpha = if (rating.mark == "tomato" || rating.mark == "popcorn") WolfskinTokens.Library.unknownCriticOpacity else 1f }, + .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3), contentScale = ContentScale.Fit) Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) } 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 d02b4835..40ed8f3c 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 @@ -38,17 +38,8 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { val state = shell.requests val data = state.snapshot - val ratingsReader = LocalScreenRatings.current - LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { - if (ratingsReader == null) state.awaitFocusedRatings() - else if (data.signedIn) state.focused?.let { item -> - ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> - state.updateRatings(item.id, values.map { rating -> - val mapped = rating.homeRating() - ComingInRating(mapped.mark, mapped.score, mapped.percentage) - }) - } - } + LaunchedEffect(state.focusedId, data.signedIn, data.status) { + state.awaitFocusedRatings() } Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) Column(modifier.fillMaxHeight().width(L.sheetWidth) @@ -157,8 +148,7 @@ 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) - val actor = item.actor(viewer) - Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), + Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", 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)) { @@ -169,17 +159,14 @@ 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.readiness.forEachIndexed { index, word -> + item.stage.readiness.forEachIndexed { index, word -> Row { - 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" } + val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> 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 feddf7b8..1325cc0f 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,21 +6,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.delay -/** History states distinguish acquisition work from library evidence and playback health. */ +/** Household acquisition stages, ordered independently from their display wording. */ 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")), } @@ -40,15 +33,13 @@ 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?.takeIf { it.isNotBlank() } + else -> actorName ?: "Unknown" } } @@ -63,13 +54,7 @@ data class ComingInSnapshot( val savedAt: String? = null, ) { val ordered = items.sortedBy { it.stage.rank } - 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 - } - } + val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } } /** A bounded window and stable request id preserve focus across refreshes and title round trips. */ @@ -107,12 +92,6 @@ class ComingInState { ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings } - fun updateRatings(id: String, values: List) { - snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it }) - snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) } - ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings - } - /** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */ suspend fun awaitFocusedRatings() { val item = focused ?: return diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index 77e26655..92e63ad9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -50,11 +50,8 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } }) else null } val context = LocalContext.current - val request = remember(context, path, poster, dim) { - val upright = poster || dim - WolfskinImages.request(context, path, - if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth, - if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright) + val request = remember(context, path, poster) { + WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster) } if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) else AsyncImage( diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt index 488d90d3..a638c6e6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt @@ -19,7 +19,7 @@ internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle { } return LibraryTitle(id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster, - ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability, + ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability, badge = when { held -> "held" receipts.isEmpty() -> null 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 2136e5ed..0b8d52e7 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 @@ -13,7 +13,7 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = { } private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title) val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll") - .mapIndexed { index, name -> DiscoverService(index.toLong(), name, discoverServiceHue(name)) } + .mapIndexed { index, name -> DiscoverService(index.toLong(), name, listOf(355f, 200f, 230f, 265f, 0f, 20f, 140f, 25f, 30f)[index]) } fun snapshot(person: String): DiscoverSnapshot { val profile = profiles.first { it.getString("key") == person } @@ -35,8 +35,7 @@ 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.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) }, - services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) + return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, 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 9558b735..bda06cdc 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,9 +10,7 @@ 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.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() + val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3) 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 } @@ -20,9 +18,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List