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 ec61583d..6b3403c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -32,6 +32,7 @@ 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 @@ -576,12 +577,22 @@ class MainActivityViewModel val restoredSession = if (signInAutomatically) { Timber.v("Sign in automatically") - serverRepository.restoreLastSession() + serverRepository.restoreSavedSession() } 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 6ec9d4b4..aba2a0a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -17,6 +17,7 @@ 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) @@ -54,6 +55,7 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + networkStartup.start() if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy @@ -110,6 +112,9 @@ 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 f72eb3b7..68af5123 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,6 +51,10 @@ 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 2c24c8f3..0930129e 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,6 +8,8 @@ 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 @@ -101,15 +103,7 @@ class ServerRepository } Timber.v("Changing user to ${user.name} on ${server.url}") apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - 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 - } + val (updatedServer, userDto) = readSession(server) var updatedUser = user.copy( id = userDto.id, @@ -125,6 +119,45 @@ 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/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 3208f4ec..87095f8b 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,6 +24,13 @@ 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 3a114017..0591f4a1 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,6 +26,9 @@ 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 @@ -91,12 +94,14 @@ object AppModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient() = - OkHttpClient - .Builder() - .apply { - // TODO user agent, timeouts, logging, etc - }.build() + 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") }) @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 b17036b8..421cc279 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,9 +82,11 @@ 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() @@ -358,6 +360,7 @@ 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 24bd8ee9..9cbbe551 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,10 +830,12 @@ 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 d83ae269..e0c8005f 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,6 +4,11 @@ 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 @@ -73,10 +78,7 @@ class CrashReportSender : ReportSender { private fun MostRecentServer.createApi(context: Context): ApiClient? = (this as? MostRecentServer.ServerAndUser)?.let { - val okHttpClient = - OkHttpClient - .Builder() - .build() + val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client() val okHttpFactory = OkHttpFactory(okHttpClient) val api = createJellyfin { @@ -89,3 +91,11 @@ 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 new file mode 100644 index 00000000..3306ca18 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt @@ -0,0 +1,27 @@ +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 2ab4c2f7..859a04aa 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,6 +7,8 @@ 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 @@ -31,9 +33,12 @@ 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() { @@ -70,19 +75,23 @@ class TrackActivityPlaybackListener( val delay = 5.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true + if (!released) { + TIMER.schedule(task, delay, delay) + initialized = true + } } } } fun release() { -// player.removeListener(this) + if (released) return + released = true task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds + val stoppedState = getState.invoke() launch("reportPlaybackStopped") { - getState.invoke()?.let { state -> + stoppedState?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") if (position < Duration.ZERO) { Timber.w("Negative position when reporting playback stopped: %s", position) @@ -96,6 +105,7 @@ class TrackActivityPlaybackListener( liveStreamId = state.liveStreamId, ), ) + onStopped() } } } @@ -117,6 +127,7 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { + if (released) return@launch getState.invoke()?.let { state -> val calcPosition = withContext(WholphinDispatchers.Main) { @@ -152,7 +163,7 @@ class TrackActivityPlaybackListener( ) { coroutineScope.launchIO { try { - block.invoke(this) + reports.withLock { block.invoke(this) } } catch (ex: Exception) { Timber.w(ex, "Exception during %s", name) } 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 1de5becd..d8fe5695 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -32,6 +32,8 @@ 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 @@ -116,9 +118,6 @@ 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() @@ -132,7 +131,7 @@ class TestMainActivityViewModel { destination as SetupDestination.AppContent Assert.assertEquals(currentUser, destination.current) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.restoreSavedSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @@ -150,7 +149,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.restoreSavedSession() } coVerify(exactly = 1) { serverRepository.getMostRecentServer() } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } @@ -165,6 +164,20 @@ 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 { @@ -178,7 +191,7 @@ class TestMainActivityViewModel { coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreLastSession() } returns + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.ServerOnly(server) viewModel.handleAppStart() @@ -204,7 +217,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreLastSession() } returns + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.ServerOnly(server) coEvery { serverRepository.tryChangeUser(serverId, userId) @@ -212,7 +225,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSavedSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -232,11 +245,11 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.restoreSavedSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -254,7 +267,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreLastSession() } returns + coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp @@ -271,7 +284,7 @@ class TestMainActivityViewModel { // destination as SetupDestination.AppContent // Assert.assertEquals(currentUser, destination.current) // } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.restoreSavedSession() } 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 new file mode 100644 index 00000000..2724cc61 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt @@ -0,0 +1,136 @@ +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/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index b266bb96..3b4d3efa 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,6 +62,7 @@ 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 @@ -91,12 +92,11 @@ object TestModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient() = - OkHttpClient - .Builder() - .apply { - // TODO user agent, timeouts, logging, etc - }.build() + fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns) + + @Provides + @Singleton + fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context) @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 511dbaa2..c6bc32f0 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,6 +114,7 @@ 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 new file mode 100644 index 00000000..73791251 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt @@ -0,0 +1,95 @@ +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/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index c4e63d27..7842289d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest { session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } val home = mockk() - coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf( + 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) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index aa1faf95..07cd1e37 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -84,7 +84,7 @@ class FirstSignInNavigationTest { coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, - includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + 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) }) } 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 dc0b780d..128dae1e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -43,11 +43,13 @@ class HomeRecoveryTest { val started = CompletableDeferred() val response = CompletableDeferred() val facade = mockk() - coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() } + 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) } 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) @@ -62,8 +64,9 @@ class HomeRecoveryTest { cache.write("${server.id}:${personA.id}", library, 1000L) val startedA = CompletableDeferred() val responseA = CompletableDeferred() - coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } - coEvery { facade.read(personB.id) } throws IOException("fixture offline") + 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) } startedA.await() @@ -80,7 +83,8 @@ class HomeRecoveryTest { @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { val facade = mockk() - coEvery { facade.read(personA.id) } returns library + coEvery { facade.readWatch(personA.id) } returns library + coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") @@ -93,7 +97,8 @@ class HomeRecoveryTest { @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { val facade = mockk() - coEvery { facade.read(personA.id) } returns library + 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())) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt index 92afbe07..a2fb8b0d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt @@ -51,7 +51,7 @@ class LibraryReadFacadeTest { val offsets = mutableListOf() coEvery { items.getItems(userId = user, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { assertEquals(200, offsets.last()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt index 279c3966..4e2917c3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest { 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") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 8e13dca4..95fcbb52 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -128,6 +128,8 @@ class PersonPickerSessionTest { 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 && diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 20ea6d9b..6a5936f8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -214,8 +214,9 @@ class PersonSwitchThreadingTest { every { repository.currentUser } returns user every { repository.currentServer } returns server val home = mockk() - coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( + 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) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt new file mode 100644 index 00000000..b8e86ca0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt @@ -0,0 +1,9 @@ +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 92c0a248..dca356a7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -58,7 +58,7 @@ class PlayerHandoffTest { @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") val title = snapshot.rows.first().cards.first() - coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline") + coEvery { facade.preparePlayback(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,14 +80,16 @@ 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.playableId(user.id, title) } returns id + 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)) val view = shell.view view.cards["hero"] = 0 - rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) } + rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } awaitModel { navigation.backStack.last() is Destination.Playback } val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) - assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs) + assertEquals(1_136_274L, destination.positionMs) assertTrue(destination.fullScreen) navigation.goBack() assertTrue(navigation.backStack.single() is Destination.Home) @@ -99,11 +101,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, resume = true), shell, server, otherUser) } + rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) } awaitModel { model.toast != null } assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) assertTrue(navigation.backStack.single() is Destination.Home) - coVerify(exactly = 0) { facade.playableId(any(), any()) } + coVerify(exactly = 0) { facade.preparePlayback(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 new file mode 100644 index 00000000..1fb29d31 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -0,0 +1,92 @@ +// 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/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 7aa9bfd7..0b6dac33 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -11,6 +11,9 @@ 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 @@ -62,6 +65,27 @@ 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 } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt new file mode 100644 index 00000000..75c92ed5 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -0,0 +1,136 @@ +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 fde2ff9c..7ea534e6 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -33,6 +33,8 @@ import com.github.damontecres.wholphin.test.TestTracks import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.After import org.junit.Before @@ -128,7 +130,7 @@ class TitleReadFacadeTest { } @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L))) facade.read(firstId, null, preferences) coEvery { library.getItem(any()) } throws java.io.IOException("offline") val saved = facade.readSaved(firstId, preferences)!! @@ -139,6 +141,7 @@ 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 { @@ -218,6 +221,43 @@ class TitleReadFacadeTest { assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } + @Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest { + val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42")) + val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1) + coEvery { library.getItem(seriesId) } returns successResponse(series) + coEvery { library.getItem(firstId) } returns successResponse(episode) + coEvery { shows.getNextUp(any()) } coAnswers { + assertTrue(firstArg().fields.orEmpty().isEmpty()) + successQueryResult(emptyList()) + } + coEvery { items.getItems(any()) } returns successQueryResult( + listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3))) + coEvery { shows.getEpisodes(any()) } coAnswers { + val fields = firstArg().fields.orEmpty() + assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) }) + successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis"))) + } + val page = facade.read(seriesId, 3, preferences) + assertEquals(firstId.toString(), page.playTarget?.id) + assertEquals("MKV", page.versions.single().container) + assertEquals(listOf(3), page.versions.single().audio.map { it.index }) + val saved = requireNotNull(facade.readSaved(seriesId, preferences)) + assertEquals(page.versions, saved.versions) + } + + @Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest { + coEvery { library.getItem(firstId) } returns successResponse( + BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor")) + coEvery { items.getItems(any()) } coAnswers { + val fields = firstArg().fields.orEmpty() + assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) }) + assertTrue(ItemFields.PEOPLE in fields) + successQueryResult(listOf(file(secondId).copy(people = listOf( + org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef"))))) + } + assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character) + } + @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { playback.savePlayVersion(any(), any()) } returns null diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt new file mode 100644 index 00000000..a7901727 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -0,0 +1,135 @@ +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 new file mode 100644 index 00000000..494c8412 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -0,0 +1,77 @@ +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/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt index 559b4365..b794391b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -21,6 +21,7 @@ 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 { @@ -54,8 +55,8 @@ object ConductarrPreference { object ConductarrModule { @Provides @Singleton - fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession = - ConductarrSession(ConductarrDiskCache(context), scope) + 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. */ diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt index 5ba52caa..ce639277 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -29,6 +29,7 @@ data class ConductarrIdentity( 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() @@ -50,7 +51,7 @@ class ConductarrSession( } try { val client = if (token.isNullOrBlank()) null else try { - ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> + 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 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 c8ab24cc..35f10b1e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -18,10 +18,12 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers +import com.github.damontecres.wholphin.util.WholphinDispatchers 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 @@ -50,9 +52,9 @@ class WolfskinHomeViewModel @Inject constructor( suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() - withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } + withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { - withContext(Dispatchers.IO) { + withContext(WholphinDispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) } } @@ -74,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor( toast = null snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading) } - val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } + val saved = withContext(WholphinDispatchers.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() { @@ -89,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor( DiscoverStatus.Unavailable -> HomeAvailability.Unavailable else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready } - snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable), - status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) + 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) } catalogueSnapshot = catalogueCopy publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } @@ -107,20 +111,43 @@ class WolfskinHomeViewModel @Inject constructor( if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) } } - try { - val library = withContext(Dispatchers.IO) { home.read(user.id) } - val savedAt = System.currentTimeMillis() - libraryCopy = library - publish() - withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (revision != loadRevision) return - libraryAvailable = false - librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } - publish() - if (retry) showToast(RecoveryMessage.StillUnreachable) + 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?) { @@ -138,6 +165,7 @@ class WolfskinHomeViewModel @Inject constructor( is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) is HomeAction.Play -> viewModelScope.launch { + 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) @@ -146,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor( return@launch } try { - 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)) + 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())) } catch (exception: Exception) { if (exception is CancellationException) throw exception showToast(RecoveryMessage.PlaybackUnavailable) @@ -204,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { val identity by model.conductarrState.collectAsState() - LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) } + 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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt new file mode 100644 index 00000000..899341e5 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt @@ -0,0 +1,46 @@ +// 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/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index e457aa86..4db5512d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -48,6 +48,7 @@ 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 @@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor( 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() @@ -87,6 +89,16 @@ class WolfskinTitleViewModel @Inject constructor( /** 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 + } route = destination loadJob?.cancel() person = null @@ -132,7 +144,7 @@ class WolfskinTitleViewModel @Inject constructor( 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.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) + held = facade.read(heldId, current.requestedSeason, preferences) current.update(held) } } catch (exception: Exception) { @@ -223,13 +235,21 @@ 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 -> 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) + 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) 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 -> { @@ -261,12 +281,15 @@ class WolfskinTitleViewModel @Inject constructor( } } - private fun play(id: UUID?, position: Long) { + private fun play(id: UUID?, restart: Boolean) { + val current = title + val playbackScope = watchScope viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") - facade.preparePlayback(id) - navigation.navigateTo(Destination.Playback(id, position)) + 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())) } catch (exception: Exception) { if (exception is CancellationException) throw exception title?.toast = RecoveryMessage.PlaybackUnavailable @@ -308,7 +331,8 @@ class WolfskinTitleViewModel @Inject constructor( @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() - LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } + 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) } 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) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt new file mode 100644 index 00000000..fb44b681 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt @@ -0,0 +1,32 @@ +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/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 0301d3dc..c73a68ee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -72,7 +72,8 @@ fun WolfskinLibraryContent( ShellPlaceholder("See all list unavailable", modifier) return } - LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { + 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)) { val wasLoading = grid.status == LibraryStatus.Loading if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) @@ -82,7 +83,7 @@ fun WolfskinLibraryContent( grid.status = LibraryStatus.Ready } else { val store = withContext(Dispatchers.IO) { snapshots.value } - store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> + store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot -> val focusedId = grid.focusedTitle?.id grid.source = snapshot.items focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 1867ae60..bb86e68a 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 @@ -23,7 +23,8 @@ 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 - suspend fun read(userId: UUID): HomeLibrary { + /** 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 -> @@ -35,6 +36,11 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val } 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 { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val destinations = linkedMapOf>() val newest = mutableListOf() @@ -46,23 +52,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val 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) }) } - return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) + return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals) } - 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 + /** 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) } private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 775235f2..3c1d01ce 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { val response = api.itemsApi.getItems( userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), ).content diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt index 85395ba0..fffe1f2c 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,13 +19,13 @@ data class LibrarySnapshot(val items: List, val savedAtMillis: Lon /** Library snapshots share the existing atomic disk store, partitioned by server and person. */ class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { - fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow>): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow>): Flow = flow { val key = "library:$destination" val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() - if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow + if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow var current: List = emptyList() try { load().collect { chunk -> 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 new file mode 100644 index 00000000..ad17d57b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt @@ -0,0 +1,23 @@ +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 13676b12..1530b8df 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -53,7 +53,8 @@ class TitleReadFacade @Inject constructor( @param:ApplicationContext private val context: Context, ) { private val json = Json { ignoreUnknownKeys = true } - private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) + private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW) + private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE) /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ suspend fun findHeld(tmdb: Long, media: String): UUID? { @@ -77,7 +78,7 @@ 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() }) + .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false) } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } } @@ -88,12 +89,12 @@ class TitleReadFacade @Inject constructor( val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { - api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } + api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) } } val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() - val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() - val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -122,8 +123,9 @@ class TitleReadFacade @Inject constructor( return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) } - /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ - suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } + /** 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) /** Track selection uses the same repository consumed by the upstream player. */ suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { @@ -164,7 +166,7 @@ class TitleReadFacade @Inject constructor( } else emptyList() val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200, sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt index 5559f15a..8030fcb9 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,6 +67,7 @@ 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 @@ -74,6 +75,7 @@ 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 new file mode 100644 index 00000000..4248a4c0 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt @@ -0,0 +1,60 @@ +// 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 new file mode 100644 index 00000000..2c65b657 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt @@ -0,0 +1,142 @@ +// 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/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt index 98173fce..8c8495fb 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,6 +2,10 @@ 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 @@ -14,13 +18,24 @@ fun main() = runBlocking { try { MockWebServer().use { server -> server.start() - val baseUrl = server.url("/").toString() + 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)) server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build()) - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).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") } - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> + val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() }) + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).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/DnsConnectionRetryTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt new file mode 100644 index 00000000..58b72d98 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt @@ -0,0 +1,68 @@ +// 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/PersistentDnsTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt new file mode 100644 index 00000000..b167b354 --- /dev/null +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt @@ -0,0 +1,160 @@ +// 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/docker/README.md b/docker/README.md index b68d58c6..2c1c4c56 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,6 +27,64 @@ 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, @@ -315,16 +373,29 @@ 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` 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. +`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. 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 @@ -557,15 +628,29 @@ retain the approved frames except for the requested feedback changes: - Every Discover heading uses a 104 px band, including a 60 px title-to-card gap. Home heading geometry is unchanged. - Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request - 420×630. The shared request builder removes conflicting Jellyfin size caps, - preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB - memory cache, 128 MiB disk cache and four image-read limit remain unchanged. + 420×630. The shared request builder replaces conflicting Jellyfin size/encoding + parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG + quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and + 400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and + authentication query parameters remain intact. TMDB uses w342 for posters and + portraits, w780 for landscape cards and w1280 for hero backdrops, never original. + Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks + decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory + cache, 128 MiB disk cache and four image-read limit remain unchanged. `HomeViewportTest` covers short/long poster, tile and wide rows, both row directions, first focus and native Back from the actual title surface. Native proof records real Bear season-three metadata through the title read facade; the proof-only harness is not part of the shipped tests or APK. +Episode lists request only ProviderIds and Overview. Next up reads only identity, +then hydrates the selected playback target through the existing single-item read, +including when Next up is empty. Media sources/streams and cast remain on detail +reads for header track/version controls. Performer credits omit media sources, +streams and ParentId but retain People because their cards show character roles. +Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName +ordering does not require returning SortName on every item. + ## Title pages and people @@ -586,6 +671,13 @@ 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index eefd5945..1078d8db 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -69,7 +69,7 @@ class HomeFocus( private fun activate(row: HomeRow) { when (row.kind) { HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title -> - onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title)) + onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title)) } HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row)) HomeRowKind.State -> onAction(HomeAction.Retry) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index eb487f62..8aad42d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List = cards, + val watchReady: Boolean = true, ) { val hasSeeAll: Boolean get() = total > cards.size - val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 } + val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 } val focusable: Boolean get() = !skeleton && count > 0 } @@ -84,7 +85,7 @@ data class HomeSnapshot( /** Home emits integration intents, never owns the upstream player or later-slice pages. */ sealed interface HomeAction { - data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction + data class Play(val title: HomeTitle) : HomeAction data class Details(val title: HomeTitle) : HomeAction data class SeeAll(val row: HomeRow) : HomeAction data object Discover : HomeAction diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 103e8818..4630ce75 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -91,6 +91,7 @@ data class TitlePageData( val episodesStatus: TitleReadStatus = TitleReadStatus.Ready, val requestsAllowed: Boolean = true, val requestFor: String? = null, + val watchReady: Boolean = true, ) /** Effects are handled by the app's authenticated facade and navigation layer. */ @@ -101,7 +102,7 @@ sealed interface TitleAction { data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction data class Trailer(val url: String) : TitleAction data class Person(val person: TitlePerson) : TitleAction - data class OpenTitle(val item: TitleItem) : TitleAction + data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction data class Season(val number: Int) : TitleAction data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index e2deee4a..c683c8e7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -182,7 +182,12 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { @Composable private fun TitleSeasons(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current - androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + val list = androidx.compose.foundation.lazy.rememberLazyListState() + val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) + LaunchedEffect(visibleChip, state.data.seasonNumbers) { + if (visibleChip >= 0) list.animateScrollToItem(visibleChip) + } + androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index -> val number = state.data.seasonNumbers[index] val focused = active && state.column == index diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 2e5da10c..d8a6d560 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -31,6 +31,8 @@ class TitleState( var watched by mutableStateOf(initial.watchTarget.watched) var toast by mutableStateOf(null) private var armedCard: String? = null + private var seasonChosen = false + val requestedSeason: Int? get() = season.takeIf { seasonChosen } private var okHeld = false val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held } val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull() @@ -56,7 +58,7 @@ class TitleState( val pendingAudio = audioIndex != data.selectedAudio val pendingSubtitle = subtitleIndex != data.selectedSubtitle data = value - if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 + if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 if (!pendingWatched) watched = value.watchTarget.watched if (!pendingVersion) versionId = value.selectedVersion if (!pendingAudio) audioIndex = value.selectedAudio @@ -152,7 +154,8 @@ class TitleState( val next = rows[index] val x = centers[row]?.get(column) val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null - focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) + val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null + focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) } private fun rowSize(id: String): Int = when (id) { @@ -168,15 +171,15 @@ class TitleState( when (row) { "cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) } "related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } + "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) } + "seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } "header" -> options.getOrNull(column)?.let { activateOption(it.id) } } } private fun activateOption(id: String) { when (id) { - "play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } @@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List = buildList { return@buildList } if (item.held) { - if (target != null) { + if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) + if (target != null && data.watchReady) { val verb = if (target.resumeSeconds > 0) "Resume" else "Play" add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false)) if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning")) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index 349fd2d2..e9b63760 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,6 +1,8 @@ package org.justinvisser.wolfskin.design import android.content.Context +import android.graphics.ImageDecoder +import android.os.Build import android.net.Uri import coil3.ComponentRegistry import coil3.ImageLoader @@ -23,6 +25,8 @@ object WolfskinImages { private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 + private const val CARD_QUALITY = "80" + private const val HERO_QUALITY = "96" private val readPermits = Semaphore(IMAGE_READS) /** The app supplies authenticated networking without exposing its server/session to design code. */ @@ -78,32 +82,34 @@ object WolfskinImages { private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE) private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/") - private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight") + private val imageParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight", "quality", "format") + // Lossy WebP is mandatory from API 14; minSdk is 23. Newer platforms expose a decoder capability query. + private val supportsWebp by lazy { Build.VERSION.SDK_INT < 28 || ImageDecoder.isMimeTypeSupported("image/webp") } private fun sourceForSize(source: Any, width: Int, height: Int): Any { if (source !is String) return source val uri = Uri.parse(source) if (uri.scheme != "https" && uri.scheme != "http") return source if (uri.host == "image.tmdb.org") { - val size = if (height > width) when { - width <= 500 -> "w500" - width <= 780 -> "w780" - else -> "original" - } else when { - width <= 780 -> "w780" - width <= 1280 -> "w1280" - else -> "original" + val size = when { + height >= width -> "w342" + width > WolfskinTokens.Image.wideRequestWidth -> "w1280" + else -> "w780" } return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString() } if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source + val hero = width == WolfskinTokens.Image.heroRequestWidth && height == WolfskinTokens.Image.heroRequestHeight || + width == WolfskinTokens.Image.heroPosterRequestWidth && height == WolfskinTokens.Image.heroPosterRequestHeight val builder = uri.buildUpon().clearQuery() for (name in uri.queryParameterNames) { - if (name.lowercase() !in dimensionParameters) { + if (name.lowercase() !in imageParameters) { uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) } } } return builder.appendQueryParameter("fillWidth", width.toString()) - .appendQueryParameter("fillHeight", height.toString()).build().toString() + .appendQueryParameter("fillHeight", height.toString()) + .appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp") + .appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString() } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index fa730cd2..6b77b2ef 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -220,10 +220,10 @@ object WolfskinTokens { object Image { const val posterRequestWidth = 256 const val posterRequestHeight = 384 - const val tileRequestWidth = 416 - const val tileRequestHeight = 234 - const val wideRequestWidth = 560 - const val wideRequestHeight = 315 + const val tileRequestWidth = 400 + const val tileRequestHeight = 225 + const val wideRequestWidth = 544 + const val wideRequestHeight = 306 const val heroRequestWidth = 1920 const val heroRequestHeight = 1080 const val heroPosterRequestWidth = 420 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 01c953b8..a23faa73 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -36,8 +36,8 @@ class HomeKeysTest { for (key in 0 until keys.length()) press(keys.getString(key)) assertEquals(path.getString("goal"), path.getInt("presses"), keys.length()) when (index) { - 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) } - 1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } + 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) } + 1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } 2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id) 3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) } 4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) } @@ -170,7 +170,6 @@ class HomeKeysTest { assertEquals(0, shell.view.cards["hero"]) press("OK") val play = action as HomeAction.Play - assertFalse(play.resume) assertEquals("Yu Yu Hakusho", play.title.seriesTitle) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt new file mode 100644 index 00000000..dfefe549 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt @@ -0,0 +1,61 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.net.Uri +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** Card URLs bound server work and transfer size, while hero quality and cache identity stay intact. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class WolfskinImagesTest { + private data class Surface(val name: String, val width: Int, val height: Int, val tier: String, val hero: Boolean = false) + private val surfaces = listOf( + Surface("grid/Home/Discover/person poster", 256, 384, "w342"), + Surface("Home tile", 400, 225, "w780"), + Surface("Home wide", 544, 306, "w780"), + Surface("episode still", 400, 280, "w780"), + Surface("cast portrait", 200, 200, "w342"), + Surface("person portrait", 240, 240, "w342"), + Surface("hero poster", 420, 630, "w342", true), + Surface("Home/Discover/title hero", 1920, 1080, "w1280", true), + ) + + private fun request(source: Any, surface: Surface): Any = WolfskinImages.request( + RuntimeEnvironment.getApplication(), source, surface.width, surface.height, surface.height > surface.width).data + + @Test fun jellyfinSurfacesReplaceStaleDimensionsAndEncodingWithoutLosingIdentity() { + for (surface in surfaces) { + val source = "https://jelly.example/Items/123/Images/Backdrop/0?tag=stable&api_key=kept&MaxWidth=1280&height=720&FillHeight=720&Quality=96&Format=Png" + val rewritten = request(source, surface).toString() + val uri = Uri.parse(rewritten) + assertEquals(surface.name, surface.width.toString(), uri.getQueryParameter("fillWidth")) + assertEquals(surface.name, surface.height.toString(), uri.getQueryParameter("fillHeight")) + assertEquals(surface.name, if (surface.hero) "Jpg" else "Webp", uri.getQueryParameter("format")) + assertEquals(surface.name, if (surface.hero) "96" else "80", uri.getQueryParameter("quality")) + assertEquals("stable", uri.getQueryParameter("tag")) + assertEquals("kept", uri.getQueryParameter("api_key")) + assertEquals(setOf("tag", "api_key", "fillWidth", "fillHeight", "format", "quality"), uri.queryParameterNames) + assertEquals(rewritten, request(rewritten, surface)) + } + } + + @Test fun tmdbSurfacesNeverDownloadOriginalArtwork() { + for (surface in surfaces) { + for (oldTier in listOf("original", "w500", "w780")) { + assertEquals(surface.name, "https://image.tmdb.org/t/p/${surface.tier}/art.jpg?identity=kept", + request("https://image.tmdb.org/t/p/$oldTier/art.jpg?identity=kept", surface)) + } + } + } + + @Test fun localAndUnrelatedSourcesRemainUntouched() { + for (source in listOf("file:///android_asset/poster.webp", "https://cdn.example/poster.jpg?width=800", "https://jelly.example/Users/123/Images/Primary", Uri.parse("content://art/1"))) { + assertEquals(source, request(source, surfaces.first())) + } + } +} diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 70571dc3..cc1a845b 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1492,8 +1492,8 @@ }, "tileRequest": { "px": [ - 416, - 234 + 400, + 225 ], "decode": "ARGB_8888", "source": "index.html:549", @@ -1501,8 +1501,8 @@ }, "wideRequest": { "px": [ - 560, - 315 + 544, + 306 ], "decode": "ARGB_8888", "source": "index.html:401", diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 713ae2f4..405b0abe 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json 36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css -3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json +226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json