diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 6b3403c6..ec61583d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -32,7 +32,6 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -577,22 +576,12 @@ class MainActivityViewModel val restoredSession = if (signInAutomatically) { Timber.v("Sign in automatically") - serverRepository.restoreSavedSession() + serverRepository.restoreLastSession() } else { serverRepository.getMostRecentServer() } Timber.d("Restore last session result=%s", restoredSession) setupNavigationManager.navigateTo(restoredSession.destination) - if (signInAutomatically && restoredSession is RestoredSession.Success) { - viewModelScope.launchDefault { - try { - serverRepository.revalidateSession(restoredSession.currentUser) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - Timber.w(exception, "Saved launch session revalidation failed") - } - } - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index aba2a0a6..6ec9d4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -17,7 +17,6 @@ import org.acra.ktx.initAcra import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber -import org.justinvisser.wolfskin.tv.NetworkStartup import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @@ -55,7 +54,6 @@ class WholphinApplication : override fun onCreate() { super.onCreate() - networkStartup.start() if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy @@ -112,9 +110,6 @@ class WholphinApplication : @Inject lateinit var workerFactory: HiltWorkerFactory - @Inject - lateinit var networkStartup: NetworkStartup - override val workManagerConfiguration: Configuration get() = Configuration diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt index 68af5123..f72eb3b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/JellyfinServerDao.kt @@ -51,10 +51,6 @@ interface JellyfinServerDao { return user.copy(rowId = result.toInt()) } - /** Network name refreshes must not overwrite settings or protection changed since launch. */ - @Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId") - fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int - @Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId") fun getUser( serverId: UUID, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 0930129e..2c24c8f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -8,8 +8,6 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -103,7 +101,15 @@ class ServerRepository } Timber.v("Changing user to ${user.name} on ${server.url}") apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val (updatedServer, userDto) = readSession(server) + val userDto by apiClient.userApi.getCurrentUser() + val updatedServer = + try { + val sysInfo by apiClient.systemApi.getPublicSystemInfo() + server.copy(name = sysInfo.serverName, version = sysInfo.version) + } catch (ex: Exception) { + Timber.w(ex, "Exception fetching public system info") + server + } var updatedUser = user.copy( id = userDto.id, @@ -119,45 +125,6 @@ class ServerRepository return@withContext currentUser } - /** Restore only an unprotected saved login; the launch picker must not wait for the network. */ - suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) { - val recent = mostRecentServerProvider.get() - if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent) - val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None - val user = saved.users.firstOrNull { it.id == recent.userId } - if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server) - apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken) - val current = CurrentUser(saved.server, user) - _currentUserDto.value = null - _current.value = current - RestoredSession.Success(current) - } - - /** A late launch refresh must never replace a person selected while it was in flight. */ - suspend fun revalidateSession(expected: CurrentUser) = withContext(ioDispatcher) { - val (server, userDto) = readSession(expected.server) - if (_current.value !== expected || userDto.id != expected.user.id) return@withContext - serverDao.addOrUpdateServer(server) - serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name) - val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext - if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto) - } - - private suspend fun readSession(server: JellyfinServer): Pair = coroutineScope { - val system = async { - try { - val info by apiClient.systemApi.getPublicSystemInfo() - server.copy(name = info.serverName, version = info.version) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - Timber.w(exception, "Exception fetching public system info") - server - } - } - val user by apiClient.userApi.getCurrentUser() - system.await() to user - } - /** * Try to restore the most recent session * diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 87095f8b..3208f4ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -24,13 +24,6 @@ class NavigationManager log() } - /** Replace only the current page, preserving every parent entry. */ - fun replaceTop(destination: Destination) { - if (backStack.isEmpty()) backStack.add(destination) - else backStack[backStack.lastIndex] = destination - log() - } - /** * Go to the specified [Destination], but reset the back stack to Home first */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 0591f4a1..3a114017 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -26,9 +26,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo -import org.justinvisser.wolfskin.conductarr.PersistentDns -import org.justinvisser.wolfskin.conductarr.DnsConnectionRetry -import java.io.File import javax.inject.Qualifier import javax.inject.Singleton @@ -94,14 +91,12 @@ object AppModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient(dns: PersistentDns) = - DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build() - - /** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */ - @Provides - @Singleton - fun dns(@ApplicationContext context: Context): PersistentDns = - PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") }) + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() @AuthOkHttpClient @Provides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 421cc279..b17036b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -82,11 +82,9 @@ class PlayExternalViewModel ) : ViewModel() { val launched = savedStateHandle.getMutableStateFlow("launched", false) val state = MutableStateFlow(PlayExternalState()) - private var watchScope: String? = null fun init(destination: Destination) { Timber.v("init called: %s", destination) - watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { val prefs = userPreferencesService.getCurrent() @@ -360,7 +358,6 @@ class PlayExternalViewModel failed = false, ), ) - watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) } } } else { Timber.w( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 9cbbe551..24bd8ee9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -830,12 +830,10 @@ class PlaybackViewModel } val playbackItemState = PlaybackItemState(playback) - val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}" val activityListener = TrackActivityPlaybackListener( api = api, player = player, - onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) }, getState = { playbackItemState }, ) player.addListener(activityListener) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index e0c8005f..d83ae269 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -4,11 +4,6 @@ import android.content.Context import com.github.damontecres.wholphin.data.MostRecentServer import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule -import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -78,7 +73,10 @@ class CrashReportSender : ReportSender { private fun MostRecentServer.createApi(context: Context): ApiClient? = (this as? MostRecentServer.ServerAndUser)?.let { - val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client() + val okHttpClient = + OkHttpClient + .Builder() + .build() val okHttpFactory = OkHttpFactory(okHttpClient) val api = createJellyfin { @@ -91,11 +89,3 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? = }.createApi(baseUrl = serverUrl, accessToken = accessToken) api } - -/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */ -@EntryPoint -@InstallIn(SingletonComponent::class) -interface CrashNetwork { - @StandardOkHttpClient - fun client(): OkHttpClient -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt deleted file mode 100644 index 3306ca18..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackWatchState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.content.Context -import org.justinvisser.wolfskin.conductarr.FileCacheStore -import java.io.File -import java.io.IOException -import timber.log.Timber -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -/** Only an acknowledged stop invalidates the captured server/user's watch projections. */ -object PlaybackWatchState { - private val mutableRevisions = MutableStateFlow>(emptyMap()) - val revisions = mutableRevisions.asStateFlow() - - /** Remove persisted watch grids before publishing the captured user's acknowledgement. */ - fun stopped(context: Context, scope: String) { - try { - FileCacheStore(File(context.filesDir, "wolfskin-library-cache")).clear(scope) - } catch (exception: IOException) { - Timber.w(exception, "Could not invalidate playback watch snapshots") - } - val time = System.currentTimeMillis() - mutableRevisions.update { previous -> previous + (scope to maxOf(time, (previous[scope] ?: 0L) + 1L)) } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 859a04aa..2ab4c2f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -7,8 +7,6 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.model.api.PlayMethod @@ -33,12 +31,9 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, - private val onStopped: () -> Unit = {}, private val getState: () -> PlaybackItemState?, ) : Player.Listener { private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) - private val reports = Mutex() - @Volatile private var released = false private val task: TimerTask = object : TimerTask() { override fun run() { @@ -75,23 +70,19 @@ class TrackActivityPlaybackListener( val delay = 5.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing - if (!released) { - TIMER.schedule(task, delay, delay) - initialized = true - } + TIMER.schedule(task, delay, delay) + initialized = true } } } fun release() { - if (released) return - released = true +// player.removeListener(this) task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds - val stoppedState = getState.invoke() launch("reportPlaybackStopped") { - stoppedState?.let { state -> + getState.invoke()?.let { state -> Timber.v("reportPlaybackStopped for ${state.itemId} at $position") if (position < Duration.ZERO) { Timber.w("Negative position when reporting playback stopped: %s", position) @@ -105,7 +96,6 @@ class TrackActivityPlaybackListener( liveStreamId = state.liveStreamId, ), ) - onStopped() } } } @@ -127,7 +117,6 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { - if (released) return@launch getState.invoke()?.let { state -> val calcPosition = withContext(WholphinDispatchers.Main) { @@ -163,7 +152,7 @@ class TrackActivityPlaybackListener( ) { coroutineScope.launchIO { try { - reports.withLock { block.invoke(this) } + block.invoke(this) } catch (ex: Exception) { Timber.w(ex, "Exception during %s", name) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index d8fe5695..1de5becd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -32,8 +32,6 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkStatic import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow @@ -118,6 +116,9 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.Success(currentUser) viewModel.handleAppStart() @@ -131,7 +132,7 @@ class TestMainActivityViewModel { destination as SetupDestination.AppContent Assert.assertEquals(currentUser, destination.current) } - coVerify(exactly = 0) { serverRepository.restoreSavedSession() } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @@ -149,7 +150,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } - coVerify(exactly = 0) { serverRepository.restoreSavedSession() } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } coVerify(exactly = 1) { serverRepository.getMostRecentServer() } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } @@ -164,20 +165,6 @@ class TestMainActivityViewModel { } } - @Test - fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) { - setupPreferences { signInAutomatically = true } - every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) - val release = CompletableDeferred() - coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } - viewModel.handleAppStart() - runCurrent() - verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) } - assertFalse(release.isCompleted) - release.complete(Unit) - } - @Test fun `Test auto sign in, hot load, with protected profile`() = runTest { @@ -191,7 +178,7 @@ class TestMainActivityViewModel { coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreSavedSession() } returns + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.ServerOnly(server) viewModel.handleAppStart() @@ -217,7 +204,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.ServerOnly(server) coEvery { serverRepository.tryChangeUser(serverId, userId) @@ -225,7 +212,7 @@ class TestMainActivityViewModel { viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSavedSession() } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -245,11 +232,11 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSavedSession() } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> @@ -267,7 +254,7 @@ class TestMainActivityViewModel { signInAutomatically = true } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSavedSession() } returns + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.Success(currentUser) coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp @@ -284,7 +271,7 @@ class TestMainActivityViewModel { // destination as SetupDestination.AppContent // Assert.assertEquals(currentUser, destination.current) // } - coVerify(exactly = 0) { serverRepository.restoreSavedSession() } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt deleted file mode 100644 index 2724cc61..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerSavedSessionTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.app.Application -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import android.content.Context -import com.github.damontecres.wholphin.data.* -import com.github.damontecres.wholphin.data.model.* -import com.github.damontecres.wholphin.ui.successResponse -import io.mockk.* -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.systemApi -import org.jellyfin.sdk.api.client.extensions.userApi -import org.jellyfin.sdk.api.operations.SystemApi -import org.jellyfin.sdk.api.operations.UserApi -import org.jellyfin.sdk.model.api.PublicSystemInfo -import org.jellyfin.sdk.model.api.UserDto -import org.junit.Assert.* -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ServerSavedSessionTest { - private val dispatcher = StandardTestDispatcher() - private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0") - private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token") - private val users = mockk() - private val system = mockk() - private val api = mockk(relaxed = true) - private val recent = mockk() - private val database by lazy { - Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) - .allowMainThreadQueries().build() - } - private val dao by lazy { database.serverDao() } - private val dto = UserDto(id = user.id, name = "Refreshed name", - hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false) - private val info = PublicSystemInfo(serverName = "Refreshed server", version = "12.1.0") - private val repository by lazy { ServerRepository(mockk(), mockk(), dao, api, dispatcher, recent) } - - @Before fun savedLogin() { - every { recent.get() } returns MostRecentServer.from(CurrentUser(server, user)) - dao.addOrUpdateServer(server) - user = dao.addOrUpdateUser(user) - every { api.userApi } returns users - every { api.systemApi } returns system - coEvery { users.getCurrentUser() } returns successResponse(dto) - coEvery { system.getPublicSystemInfo() } returns successResponse(info) - } - - @After fun closeDatabase() { database.close() } - - @Test fun savedSessionIsUsableWhileUserRevalidationWaitsAndSystemInfoRunsInParallel() = runTest(dispatcher) { - val pendingUser = CompletableDeferred() - val systemStarted = CompletableDeferred() - coEvery { users.getCurrentUser() } coAnswers { pendingUser.await(); successResponse(dto) } - coEvery { system.getPublicSystemInfo() } coAnswers { systemStarted.complete(Unit); successResponse(info) } - val restored = repository.restoreSavedSession() as RestoredSession.Success - assertEquals(user, restored.currentUser.user) - assertEquals(restored.currentUser, repository.current.value) - val refresh = async { repository.revalidateSession(restored.currentUser) } - runCurrent() - systemStarted.await() - assertFalse(refresh.isCompleted) - pendingUser.complete(Unit) - refresh.await() - assertEquals("Refreshed name", repository.current.value!!.user.name) - assertEquals("Refreshed server", repository.current.value!!.server.name) - } - - @Test fun savedProtectedPeopleRemainLocked() = runTest(dispatcher) { - for (protected in listOf(user.copy(pin = "1234"), user.copy(requireLogin = true))) { - dao.updateUser(protected) - assertTrue(repository.restoreSavedSession() is RestoredSession.ServerOnly) - assertNull(repository.current.value) - } - } - - @Test fun lateLaunchRefreshCannotReplaceTheSelectedPerson() = runTest(dispatcher) { - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - val entered = CompletableDeferred() - val pending = CompletableDeferred() - coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) } - val refresh = async { repository.revalidateSession(first) } - entered.await() - val selected = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Other person")) - every { recent.get() } returns MostRecentServer.from(CurrentUser(server, selected)) - repository.restoreSavedSession() - pending.complete(Unit) - refresh.await() - assertEquals(selected, repository.current.value!!.user) - assertNull(repository.currentUserDto) - } - - @Test fun launchNameRefreshPreservesSettingsAndPinChangedWhileTheRequestWaits() = runTest(dispatcher) { - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - val entered = CompletableDeferred() - val pending = CompletableDeferred() - coEvery { users.getCurrentUser() } coAnswers { entered.complete(Unit); pending.await(); successResponse(dto) } - val refresh = async { repository.revalidateSession(first) } - entered.await() - val edited = user.copy( - pin = "4567", - appPreferences = user.appPreferences.copy(preferredAudioLanguage = "nld"), - config = user.config.copy(homeSettingsSource = HomePageSettingsSource.LOCAL), - ) - dao.updateUser(edited) - pending.complete(Unit) - refresh.await() - val expected = edited.copy(name = dto.name) - assertEquals(expected, dao.getUser(server.id, user.id)) - assertEquals(expected, repository.current.value!!.user) - } - - @Test fun anotherUsersResponseCannotRenameTheSavedPerson() = runTest(dispatcher) { - val first = (repository.restoreSavedSession() as RestoredSession.Success).currentUser - coEvery { users.getCurrentUser() } returns successResponse(dto.copy(id = UUID.randomUUID())) - repository.revalidateSession(first) - assertEquals(user, repository.current.value!!.user) - assertNull(repository.currentUserDto) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 3b4d3efa..b266bb96 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -62,7 +62,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo import timber.log.Timber -import org.justinvisser.wolfskin.conductarr.PersistentDns import javax.inject.Singleton @Module @@ -92,11 +91,12 @@ object TestModule { @StandardOkHttpClient @Provides @Singleton - fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns) - - @Provides - @Singleton - fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context) + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() @AuthOkHttpClient @Provides diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt index c6bc32f0..511dbaa2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -114,7 +114,6 @@ class PlayExternalViewModelTest { every { api.subtitleApi } returns subtitleApi every { api.playStateApi } returns playStateApi every { serverRepository.currentUser } returns null - every { serverRepository.currentServer } returns null coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt deleted file mode 100644 index 73791251..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/PlaybackWatchStateTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.app.Application -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import androidx.media3.common.Player -import io.mockk.* -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.test.* -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.playStateApi -import org.jellyfin.sdk.api.operations.PlayStateApi -import org.jellyfin.sdk.model.api.PlayMethod -import com.github.damontecres.wholphin.ui.successResponse -import org.junit.Assert.* -import org.junit.Test -import java.util.UUID -import org.justinvisser.wolfskin.conductarr.FileCacheStore -import org.justinvisser.wolfskin.conductarr.CacheEntry -import java.io.File - -/** Return refresh waits for the stop write rather than racing its acknowledgement. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class PlaybackWatchStateTest { - @Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val previous = WholphinDispatchers.IO - Dispatchers.setMain(dispatcher) - WholphinDispatchers.IO = dispatcher - val api = mockk() - val reports = mockk() - every { api.playStateApi } returns reports - val player = mockk { every { currentPosition } returns 751_246L } - val scope = "server:${UUID.randomUUID()}" - val another = "server:${UUID.randomUUID()}" - val context = RuntimeEnvironment.getApplication() - val cache = FileCacheStore(File(context.filesDir, "wolfskin-library-cache")) - val saved = CacheEntry("old progress", null, 1L, 1L, group = "library") - cache.write(scope, "library:movies", saved) - cache.write(another, "library:movies", saved) - val acknowledgement = CompletableDeferred() - coEvery { reports.reportPlaybackStopped(any()) } coAnswers { - acknowledgement.await() - successResponse(Unit) - } - val state = PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) - val listener = TrackActivityPlaybackListener(api, player, onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { state }) - try { - listener.release() - runCurrent() - assertNull(PlaybackWatchState.revisions.value[scope]) - assertNotNull(cache.read(scope, "library:movies")) - acknowledgement.complete(Unit) - advanceUntilIdle() - assertNotNull(PlaybackWatchState.revisions.value[scope]) - assertNull(PlaybackWatchState.revisions.value[another]) - assertNull(cache.read(scope, "library:movies")) - assertEquals(saved, cache.read(another, "library:movies")) - coVerify(exactly = 1) { reports.reportPlaybackStopped(match { it.itemId == state.itemId && it.positionTicks == 7_512_460_000L }) } - listener.release() - advanceUntilIdle() - coVerify(exactly = 1) { reports.reportPlaybackStopped(any()) } - } finally { - WholphinDispatchers.IO = previous - Dispatchers.resetMain() - } - } - - @Test fun failedStopDoesNotPublishAFictitiousAcknowledgement() = runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - val previous = WholphinDispatchers.IO - Dispatchers.setMain(dispatcher) - WholphinDispatchers.IO = dispatcher - val api = mockk() - val reports = mockk() - every { api.playStateApi } returns reports - coEvery { reports.reportPlaybackStopped(any()) } throws java.io.IOException("Stop rejected") - val scope = "server:${UUID.randomUUID()}" - val listener = TrackActivityPlaybackListener(api, mockk { every { currentPosition } returns 751_246L }, - onStopped = { PlaybackWatchState.stopped(RuntimeEnvironment.getApplication(), scope) }, getState = { PlaybackItemState(UUID.randomUUID(), PlayMethod.DIRECT_PLAY) }) - try { - listener.release() - advanceUntilIdle() - assertNull(PlaybackWatchState.revisions.value[scope]) - } finally { - WholphinDispatchers.IO = previous - Dispatchers.resetMain() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index 7842289d..c4e63d27 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -52,9 +52,8 @@ class ConductarrHomeSurfaceTest { session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } val home = mockk() - coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf( + coEvery { home.read(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 07cd1e37..aa1faf95 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.DATE_CREATED), + includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index 128dae1e..dc0b780d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -43,13 +43,11 @@ class HomeRecoveryTest { val started = CompletableDeferred() val response = CompletableDeferred() val facade = mockk() - coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } - coEvery { facade.readLibrary(any()) } returns HomeLibrary() + coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() } val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, 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) @@ -64,9 +62,8 @@ class HomeRecoveryTest { cache.write("${server.id}:${personA.id}", library, 1000L) val startedA = CompletableDeferred() val responseA = CompletableDeferred() - coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } - coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") - coEvery { facade.readLibrary(any()) } returns HomeLibrary() + coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } + coEvery { facade.read(personB.id) } throws IOException("fixture offline") val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() @@ -83,8 +80,7 @@ class HomeRecoveryTest { @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { val facade = mockk() - coEvery { facade.readWatch(personA.id) } returns library - coEvery { facade.readLibrary(any()) } returns HomeLibrary() + coEvery { facade.read(personA.id) } returns library val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") @@ -97,8 +93,7 @@ class HomeRecoveryTest { @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { val facade = mockk() - coEvery { facade.readWatch(personA.id) } returns library - coEvery { facade.readLibrary(any()) } returns HomeLibrary() + coEvery { facade.read(personA.id) } returns library val cache = mockk(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 a2fb8b0d..92afbe07 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.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { assertEquals(200, offsets.last()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt index 4e2917c3..279c3966 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt @@ -48,21 +48,6 @@ 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 95fcbb52..8e13dca4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -128,8 +128,6 @@ 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 6a5936f8..20ea6d9b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -214,9 +214,8 @@ class PersonSwitchThreadingTest { every { repository.currentUser } returns user every { repository.currentServer } returns server val home = mockk() - coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( + coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) - 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 deleted file mode 100644 index b8e86ca0..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlaybackUserData.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import org.jellyfin.sdk.model.api.UserItemDataDto -import java.util.UUID - -/** Checkpoints use the SDK's required identity and watch fields. */ -internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto = - UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false, - lastPlayedDate = null, played = played, key = "", itemId = itemId) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index dca356a7..92c0a248 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.preparePlayback(user.id, title) } throws IOException("fixture offline") + coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline") rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } @@ -80,16 +80,14 @@ class PlayerHandoffTest { @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val id = UUID.fromString("00000000-0000-0000-0000-000000000003") - coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( - id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, - userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) + coEvery { facade.playableId(user.id, title) } returns id val view = shell.view view.cards["hero"] = 0 - rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } + rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) } awaitModel { navigation.backStack.last() is Destination.Playback } val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) - assertEquals(1_136_274L, destination.positionMs) + assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs) assertTrue(destination.fullScreen) navigation.goBack() assertTrue(navigation.backStack.single() is Destination.Home) @@ -101,11 +99,11 @@ class PlayerHandoffTest { @Test fun anotherPersonsSessionCannotResolveOrStartPlayback() { val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() val otherUser = user.copy(name = "Justin") - rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) } + rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) } awaitModel { model.toast != null } assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) assertTrue(navigation.backStack.single() is Destination.Home) - coVerify(exactly = 0) { facade.preparePlayback(any(), any()) } + coVerify(exactly = 0) { facade.playableId(any(), any()) } } private fun awaitModel(condition: () -> Boolean) { rule.waitUntil(5000) { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt deleted file mode 100644 index 1fb29d31..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinServerUsers -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import io.mockk.coEvery -import io.mockk.mockk -import java.util.UUID -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.cancel -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.PeopleStatus -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The actual picker displays every saved login while household revalidation has no response. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class SavedPeoplePickerTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun savedPeopleAreVisibleBeforeTheFirstNetworkResponse() { - ConductarrPreference.initialize(rule.activity) - val previousUrl = ConductarrPreference.baseUrl - val previousFixtures = ConductarrPreference.fixtures - val release = CountDownLatch(1) - val entered = AtomicBoolean(false) - FakeConductarr().use { fake -> - fake.beforeResponse = { entered.set(true); release.await() } - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrPreference.updateFixtures(false) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Saved server", fake.url, null) - val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) -> - JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) - } - val repository = mockk(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) - val libraries = mockk(relaxed = true) - coEvery { libraries.readDestinations(any()) } returns emptyMap() - val session = WolfskinSession() - val navigation = NavigationManager() - val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - try { - rule.setContent { - WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - viewModel = model, homeContent = { _, _, _, _ -> }) - } - rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 } - users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } - rule.runOnIdle { - assertTrue(session.state!!.pickerVisible) - assertEquals(PeopleStatus.Ready, session.state!!.peopleStatus) - assertEquals(users.map { it.id.toString() }, session.state!!.people.map { it.jellyfinUserId }) - assertEquals(listOf("justin", "mindel", "shared"), session.state!!.people.map { it.key }) - assertEquals(1L, release.count) - } - val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(captures, "saved-people-before-network.png")) - } finally { - release.countDown() - model.viewModelScope.cancel() - ConductarrPreference.updateBaseUrl(previousUrl) - ConductarrPreference.updateFixtures(previousFixtures) - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 0b6dac33..7aa9bfd7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -11,9 +11,6 @@ import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasText import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.text.TextLayoutResult @@ -65,27 +62,6 @@ class TitleKeysTest { } } } - @Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() { - render("Andor") { page -> - page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29, - playTarget = page.playTarget!!.copy(season = 29)) - } - press(Key.DirectionDown) - assertEquals("seasons", state.row) - assertEquals(28, state.column) - rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed() - press(Key.DirectionLeft) - assertEquals(27, state.column) - rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed() - press(Key.DirectionUp) - press(Key.DirectionDown) - assertEquals(27, state.column) - press(Key.DirectionCenter) - assertEquals(28, state.season) - assertEquals(28, state.requestedSeason) - assertEquals(TitleAction.Season(28), effects.last()) - } - private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } private fun replay(end: String): Int { val path = paths.single { it.getString("end") == end } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt deleted file mode 100644 index 75c92ed5..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import java.util.UUID - -/** Production title actions preserve parents and resolve live playback instead of cached offsets. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class TitlePlaybackNavigationTest { - private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") - private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") - private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") - private val person = ShellPerson("justin", "Justin", "J", false) - private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true) - private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12) - private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2) - - @Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val navigation = NavigationManager() - val facade = mockk() - coEvery { facade.readSaved(any(), any()) } returns null - coEvery { facade.read(any(), any(), any()) } coAnswers { - val id = firstArg() - TitlePageData(series, playTarget = first, watchTarget = if (id == seriesId) series else first.copy(id = id.toString()), - episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) - } - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) - fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) - try { - navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) - open(seriesId) - advanceUntilIdle() - model.title!!.click("episodes", 0) - assertEquals(3, navigation.backStack.size) - assertEquals(firstId, (navigation.backStack.last() as Destination.MediaItem).itemId) - open(firstId) - advanceUntilIdle() - for (id in listOf(secondId, firstId, secondId)) { - model.title!!.click("episodes", if (id == firstId) 0 else 1) - assertEquals(3, navigation.backStack.size) - assertEquals(id, (navigation.backStack.last() as Destination.MediaItem).itemId) - open(id) - advanceUntilIdle() - } - model.title!!.click("related", 0) - assertEquals(4, navigation.backStack.size) - navigation.goBack() - model.title!!.click("cast", 0) - assertEquals(4, navigation.backStack.size) - navigation.goBack() - model.title!!.back(ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false }) - assertEquals(listOf(Destination.Home(), Destination.Wolfskin(titleRoute(seriesId.toString()))), navigation.backStack.toList()) - navigation.goBack() - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - navigation.navigateTo(Destination.Wolfskin(titleRoute(firstId.toString()))) - open(firstId) - advanceUntilIdle() - model.title!!.click("episodes", 1) - navigation.goBack() - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - } finally { - model.viewModelScope.cancel() - session.close() - Dispatchers.resetMain() - } - } - - @Test fun primarySeriesActionReResolvesNextUpButRestartAndExplicitEpisodesKeepTheirIdentity() = runTest { - Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val navigation = NavigationManager() - val facade = mockk() - coEvery { facade.readSaved(any(), any()) } returns null - coEvery { facade.read(any(), any(), any()) } coAnswers { - TitlePageData(series, first, watchTarget = if (firstArg() == seriesId) series else first) - } - coEvery { facade.preparePlayback(seriesId) } returns BaseItemDto(id = secondId, type = BaseItemKind.EPISODE, - userData = playbackUserData(secondId, 11_362_740_000L, playCount = 2)) - coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, - userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true)) - try { - model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) - advanceUntilIdle() - model.title!!.click("header", 0) - advanceUntilIdle() - assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) - navigation.goBack() - model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) - advanceUntilIdle() - assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) - navigation.goBack() - model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) - advanceUntilIdle() - model.title!!.click("header", 0) - advanceUntilIdle() - assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) - coVerify(exactly = 1) { facade.preparePlayback(seriesId) } - } finally { - model.viewModelScope.cancel() - session.close() - Dispatchers.resetMain() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index 7ea534e6..fde2ff9c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -33,8 +33,6 @@ import com.github.damontecres.wholphin.test.TestTracks import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.junit.Assert.* import org.junit.After import org.junit.Before @@ -130,7 +128,7 @@ class TitleReadFacadeTest { } @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L))) + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) facade.read(firstId, null, preferences) coEvery { library.getItem(any()) } throws java.io.IOException("offline") val saved = facade.readSaved(firstId, preferences)!! @@ -141,7 +139,6 @@ class TitleReadFacadeTest { assertEquals(TitleConnection.SignedOut, saved.connection) assertTrue(saved.item.ratings.isEmpty()) assertEquals(firstId.toString(), saved.watchTarget.id) - assertFalse(screen.options.any { it.id == "play" || it.id == "restart" }) } @Test fun diskCopiesNeverCrossViewerIdentity() = runTest { @@ -221,43 +218,6 @@ class TitleReadFacadeTest { assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } - @Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest { - val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42")) - val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1) - coEvery { library.getItem(seriesId) } returns successResponse(series) - coEvery { library.getItem(firstId) } returns successResponse(episode) - coEvery { shows.getNextUp(any()) } coAnswers { - assertTrue(firstArg().fields.orEmpty().isEmpty()) - successQueryResult(emptyList()) - } - coEvery { items.getItems(any()) } returns successQueryResult( - listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3))) - coEvery { shows.getEpisodes(any()) } coAnswers { - val fields = firstArg().fields.orEmpty() - assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) }) - successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis"))) - } - val page = facade.read(seriesId, 3, preferences) - assertEquals(firstId.toString(), page.playTarget?.id) - assertEquals("MKV", page.versions.single().container) - assertEquals(listOf(3), page.versions.single().audio.map { it.index }) - val saved = requireNotNull(facade.readSaved(seriesId, preferences)) - assertEquals(page.versions, saved.versions) - } - - @Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest { - coEvery { library.getItem(firstId) } returns successResponse( - BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor")) - coEvery { items.getItems(any()) } coAnswers { - val fields = firstArg().fields.orEmpty() - assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) }) - assertTrue(ItemFields.PEOPLE in fields) - successQueryResult(listOf(file(secondId).copy(people = listOf( - org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef"))))) - } - assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character) - } - @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { playback.savePlayVersion(any(), any()) } returns null diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt deleted file mode 100644 index a7901727..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.content.Context -import com.github.damontecres.wholphin.WholphinApplication -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.ImageUrlService -import com.github.damontecres.wholphin.ui.successQueryResult -import com.github.damontecres.wholphin.ui.successResponse -import io.mockk.* -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.* -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.operations.ItemsApi -import org.jellyfin.sdk.api.operations.TvShowsApi -import org.jellyfin.sdk.api.operations.UserLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.BaseItemKind -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import java.util.UUID -import com.github.damontecres.wholphin.util.WholphinDispatchers -import com.github.damontecres.wholphin.util.configure -import com.github.damontecres.wholphin.util.reset - -/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class WatchReadTest { - private val api = mockk() - private val library = mockk() - private val shows = mockk() - private val items = mockk() - private val images = mockk(relaxed = true) - private val userId = UUID.fromString("167649f8-00c9-44b5-a86a-f371eccc2d2e") - private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") - private val json = Json { ignoreUnknownKeys = true } - // Selected fields from the read-only Resume and NextUp replies, without credentials. - private val resumeWire = """{"Items":[{"Id":"a8ed46755a50a5dfb443dd5cdd7125a3","Name":"Sex and the Country","Type":"Episode","ParentIndexNumber":4,"IndexNumber":9,"RunTimeTicks":17507200000,"UserData":{"PlaybackPositionTicks":960780000,"Played":false,"PlayCount":1,"IsFavorite":false,"LastPlayedDate":null,"Key":"","ItemId":"a8ed46755a50a5dfb443dd5cdd7125a3"}}],"TotalRecordCount":1,"StartIndex":0}""" - private val nextWire = """{"Items":[{"Id":"d6a9588e91a4a22ecb9ae279eb7e09d6","Name":"Me and My Town","Type":"Episode","SeriesName":"Desperate Housewives","ParentIndexNumber":5,"IndexNumber":9,"RunTimeTicks":25368960000,"UserData":{"PlaybackPositionTicks":11362740000,"Played":false,"PlayCount":2,"IsFavorite":false,"LastPlayedDate":"2026-11-06T10:39:27.1772471Z","Key":"","ItemId":"d6a9588e91a4a22ecb9ae279eb7e09d6"}}],"TotalRecordCount":1,"StartIndex":0}""" - private fun decode(wire: String) = json.decodeFromString(BaseItemDtoQueryResult.serializer(), wire).items.first() - - @Before fun setup() { - every { api.userLibraryApi } returns library - every { api.tvShowsApi } returns shows - every { api.itemsApi } returns items - mockkObject(WholphinApplication.Companion) - every { WholphinApplication.instance } returns mockk { - every { resources } returns RuntimeEnvironment.getApplication().resources - } - } - - @After fun teardown() { unmockkObject(WholphinApplication.Companion) } - - @Test fun bothFacadesUseTheLiveCheckpointAndSeriesNextUpWhileExplicitEpisodeKeepsItsIdentity() = runTest { - val resume = decode(resumeWire) - val next = decode(nextWire) - val series = BaseItemDto(id = seriesId, type = BaseItemKind.SERIES) - coEvery { library.getItem(itemId = seriesId, userId = userId) } returns successResponse(series) - coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(resume) - coEvery { library.getItem(itemId = next.id, userId = userId) } returns successResponse(next) - coEvery { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } returns successQueryResult(listOf(next)) - val home = HomeReadFacade(api, images, mockk()) - val repository = mockk { - every { currentUser } returns mockk { every { id } returns userId } - } - val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk()) - val cached = HomeTitle(resume.id.toString(), "Sex and the Country", "episode", resumeSeconds = 10.0) - assertEquals(96_078L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) - assertEquals(96_078L, title.preparePlayback(resume.id)!!.playbackPositionMs()) - assertEquals(next.id, home.preparePlayback(userId, HomeTitle(seriesId.toString(), "Series", "tv"))!!.id) - val selected = title.preparePlayback(seriesId)!! - assertEquals(next.id, selected.id) - assertEquals(1_136_274L, selected.playbackPositionMs()) - coVerify(exactly = 2) { shows.getNextUp(userId = userId, seriesId = seriesId, limit = 1, enableResumable = true, enableUserData = true) } - val completed = resume.copy(userData = resume.userData!!.copy(playbackPositionTicks = 0, played = true)) - coEvery { library.getItem(itemId = resume.id, userId = userId) } returns successResponse(completed) - assertEquals(0L, home.preparePlayback(userId, cached)!!.playbackPositionMs()) - assertEquals(true, title.preparePlayback(resume.id)!!.userData?.played) - } - - @Test fun freshResumeAndNextUpPaintBeforeSlowLibraryAndSurviveItsFailure() = runTest { - WholphinDispatchers.configure(StandardTestDispatcher(testScheduler)) - val server = JellyfinServer(seriesId, "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = userId, serverId = server.id, name = "Mindel", accessToken = null) - val person = ShellPerson("mindel", "Mindel", "M", false, userId.toString()) - val resume = decode(resumeWire) - val next = decode(nextWire) - coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(resume)) - coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next)) - val facade = spyk(HomeReadFacade(api, images, mockk())) - val releaseLibrary = CompletableDeferred() - coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") } - val cache = mockk(relaxed = true) - every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) - try { - val loading = launch { model.loadLibrary(server, user, person) } - runCurrent() - assertFalse(loading.isCompleted) - assertTrue(model.snapshot.rows.first().watchReady) - assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) - releaseLibrary.complete(Unit) - loading.join() - assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) - assertTrue(model.snapshot.jellyfinAvailable) - } finally { - model.viewModelScope.cancel() - session.close() - WholphinDispatchers.reset() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt deleted file mode 100644 index 494c8412..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.util.PlaybackWatchState -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.cancel -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.HomeLibraryCache -import org.justinvisser.wolfskin.tv.read.HomeReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import java.util.UUID -import java.util.concurrent.atomic.AtomicReference - -/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class WatchReturnTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun acknowledgedStopRefreshesVisibleHeroWithoutRecreatingHomeOrLosingFocus() { - val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null) - val person = ShellPerson("mindel", "Mindel", "M", false, user.id.toString()) - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false } - val episode = HomeTitle("episode", "Me and My Town", "episode", runtimeSeconds = 2536, resumeSeconds = 10.0, held = true, seriesTitle = "Desperate Housewives", season = 5, episode = 9) - val current = AtomicReference(episode) - val facade = mockk() - every { facade.audiences } returns null - coEvery { facade.readWatch(user.id) } coAnswers { HomeLibrary(resume = listOf(current.get())) } - coEvery { facade.readLibrary(user.id) } returns HomeLibrary() - val cache = mockk(relaxed = true) - every { cache.read(any()) } returns null - val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) - val oldFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(false) - try { - rule.setContent { WolfskinTheme { - ShellSurface(shell) { HomeDestination(shell, server, user, it, model) } - } } - rule.waitUntil(5000) { - shadowOf(Looper.getMainLooper()).idle() - model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 10.0 - } - val view = shell.rememberedView("home") - rule.runOnIdle { shell.navFocused = false; view.cards["hero"] = 0; current.set(episode.copy(resumeSeconds = 1136.274)) } - PlaybackWatchState.stopped(rule.activity, "${server.id}:${user.id}") - rule.waitUntil(5000) { - shadowOf(Looper.getMainLooper()).idle() - model.snapshot.rows.firstOrNull()?.cards?.firstOrNull()?.resumeSeconds == 1136.274 - } - assertSame(view, shell.rememberedView("home")) - assertEquals(0, view.cards["hero"]) - assertTrue(model.snapshot.rows.first().watchReady) - } finally { - model.viewModelScope.cancel() - session.close() - ConductarrPreference.updateFixtures(oldFixtures) - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt index b794391b..559b4365 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -21,7 +21,6 @@ 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 { @@ -55,8 +54,8 @@ object ConductarrPreference { object ConductarrModule { @Provides @Singleton - fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession = - ConductarrSession(ConductarrDiskCache(context), scope, dns) + fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession = + ConductarrSession(ConductarrDiskCache(context), scope) } /** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */ 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 ce639277..5ba52caa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -29,7 +29,6 @@ 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() @@ -51,7 +50,7 @@ class ConductarrSession( } try { val client = if (token.isNullOrBlank()) null else try { - ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event -> + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> if (event == ClientEvent.NEEDS_SIGN_IN) { mutableIdentity.update { if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 35f10b1e..c8ab24cc 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -18,12 +18,10 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.CancellationException -import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.coroutineScope -import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache import org.justinvisser.wolfskin.tv.read.HomeReadFacade @@ -52,9 +50,9 @@ class WolfskinHomeViewModel @Inject constructor( suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() - withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } + withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { - withContext(WholphinDispatchers.IO) { + withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) } } @@ -76,12 +74,11 @@ class WolfskinHomeViewModel @Inject constructor( toast = null snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading) } - val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } + val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } if (revision != loadRevision) return var libraryCopy = saved?.first ?: HomeLibrary() var libraryAvailable = true var librarySavedAt: String? = null - var watchReady = false val identity = conductarr.identity.value var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) fun publish() { @@ -92,9 +89,8 @@ 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).map { - if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it - }, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) + snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable), + status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) } catalogueSnapshot = catalogueCopy publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } @@ -111,43 +107,20 @@ class WolfskinHomeViewModel @Inject constructor( if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) } } - suspend fun save() { - val library = libraryCopy - withContext(WholphinDispatchers.IO) { - try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ } - } + 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) } - 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?) { @@ -165,7 +138,6 @@ 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) @@ -174,9 +146,9 @@ class WolfskinHomeViewModel @Inject constructor( return@launch } try { - val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } - if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) - else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) + val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } + if (id == null) showToast(RecoveryMessage.PlaybackUnavailable) + else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) } catch (exception: Exception) { if (exception is CancellationException) throw exception showToast(RecoveryMessage.PlaybackUnavailable) @@ -232,8 +204,7 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { val identity by model.conductarrState.collectAsState() - 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) } + LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt deleted file mode 100644 index 899341e5..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/NetworkStartup.kt +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import com.github.damontecres.wholphin.data.MostRecentServer -import com.github.damontecres.wholphin.data.MostRecentServerProvider -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Provider -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.justinvisser.wolfskin.conductarr.PersistentDns - -/** Process warm-up never blocks application creation or serializes the two service lookups. */ -@Singleton -class NetworkStartup @Inject constructor( - @param:ApplicationContext private val context: Context, - private val recent: Provider, - private val dns: PersistentDns, - @param:IoCoroutineScope private val scope: CoroutineScope, -) { - fun start() { - scope.launch { - val url = when (val server = recent.get().get()) { - is MostRecentServer.Server -> server.serverUrl - is MostRecentServer.ServerAndUser -> server.serverUrl - MostRecentServer.None -> null - } - warm(url) - } - scope.launch { - ConductarrPreference.initialize(context) - warm(ConductarrPreference.baseUrl) - } - } - - private fun warm(url: String?) { - val host = url?.toHttpUrlOrNull()?.host ?: return - try { dns.lookup(host) } catch (_: Exception) { - // An offline launch must still render the saved people. - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 4db5512d..e457aa86 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -48,7 +48,6 @@ import org.justinvisser.wolfskin.design.TitleReadStatus import org.justinvisser.wolfskin.design.TitleState import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.justinvisser.wolfskin.tv.read.playbackPositionMs import java.util.UUID import javax.inject.Inject @@ -80,7 +79,6 @@ 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() @@ -89,16 +87,6 @@ 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 @@ -144,7 +132,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.requestedSeason, preferences) + held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) current.update(held) } } catch (exception: Exception) { @@ -235,21 +223,13 @@ class WolfskinTitleViewModel @Inject constructor( when (action) { TitleAction.Pop -> navigation.goBack() is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) - is TitleAction.OpenTitle -> { - val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { - "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE - "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE - else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES - }) } ?: Destination.Wolfskin(titleRoute(action.item.id)) - if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination) - else navigation.navigateTo(destination) - } - is TitleAction.Play -> { - val opened = title?.data?.watchTarget - play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), - restart = action.restart) - } - is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) + is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { + "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE + "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE + else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES + }) } ?: Destination.Wolfskin(titleRoute(action.item.id))) + is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) + is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { @@ -281,15 +261,12 @@ class WolfskinTitleViewModel @Inject constructor( } } - private fun play(id: UUID?, restart: Boolean) { - val current = title - val playbackScope = watchScope + private fun play(id: UUID?, position: Long) { viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") - val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") - if (title !== current || watchScope != playbackScope) return@launch - navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + facade.preparePlayback(id) + navigation.navigateTo(Destination.Playback(id, position)) } catch (exception: Exception) { if (exception is CancellationException) throw exception title?.toast = RecoveryMessage.PlaybackUnavailable @@ -331,8 +308,7 @@ 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() - 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) } + LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { 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 deleted file mode 100644 index fb44b681..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WatchRefresh.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.github.damontecres.wholphin.util.PlaybackWatchState - -/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */ -@Composable -internal fun rememberWatchRefresh(scope: String?): Pair { - val revisions by PlaybackWatchState.revisions.collectAsState() - var foreground by remember(scope) { mutableIntStateOf(0) } - val owner = LocalLifecycleOwner.current - DisposableEffect(owner, scope) { - var initialResume = owner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - if (initialResume) initialResume = false else foreground++ - } - } - owner.lifecycle.addObserver(observer) - onDispose { owner.lifecycle.removeObserver(observer) } - } - return (revisions[scope] ?: 0L) to foreground -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index c73a68ee..0301d3dc 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -72,8 +72,7 @@ fun WolfskinLibraryContent( ShellPlaceholder("See all list unavailable", modifier) return } - val watchRefresh = rememberWatchRefresh(cacheScope) - LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) { + LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { val wasLoading = grid.status == LibraryStatus.Loading if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) @@ -83,7 +82,7 @@ fun WolfskinLibraryContent( grid.status = LibraryStatus.Ready } else { val store = withContext(Dispatchers.IO) { snapshots.value } - store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot -> + store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> val focusedId = grid.focusedTitle?.id grid.source = snapshot.items focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index bb86e68a..1867ae60 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,8 +23,7 @@ import org.justinvisser.wolfskin.design.HomeTitle /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { val audiences get() = libraries.audiences - /** Watch rows are publishable without waiting for any library shelf. */ - suspend fun readWatch(userId: UUID): HomeLibrary { + suspend fun read(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 -> @@ -36,11 +35,6 @@ 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() @@ -52,18 +46,23 @@ 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(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals) + return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) } - /** Resolve the current checkpoint even when the hero was painted from disk. */ - suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? { - val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run { - val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) - api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { - it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) - }?.id - } ?: return null - return readPlayback(api, userId, id) + suspend fun playableId(userId: UUID, title: HomeTitle): UUID? { + title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id -> + val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content + if (item.type == BaseItemKind.SERIES) { + return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull() + ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull()) + ?.takeIf { it.type == BaseItemKind.EPISODE }?.id + } + return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE } + } + val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) + return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { + it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) + }?.id } private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 3c1d01ce..775235f2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -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.DATE_CREATED), + fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), ).content diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt index fffe1f2c..85395ba0 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt @@ -19,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, invalidatedAtMillis: Long = 0L, load: () -> Flow>): Flow = flow { + fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow>): Flow = flow { val key = "library:$destination" val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() - if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow + if (!force && saved != null && titles != null && 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 deleted file mode 100644 index ad17d57b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/PlaybackRead.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import java.util.UUID - -/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */ -internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? { - val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content - val target = if (opened.type == BaseItemKind.SERIES) { - api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true) - .content.items.firstOrNull()?.id - ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id - } else opened.id - val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content } - return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE } -} - -/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */ -internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 1530b8df..13676b12 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,8 +53,7 @@ class TitleReadFacade @Inject constructor( @param:ApplicationContext private val context: Context, ) { private val json = Json { ignoreUnknownKeys = true } - private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW) - private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE) + private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ suspend fun findHeld(tmdb: Long, media: String): UUID? { @@ -78,7 +77,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() }, watchReady = false) + .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }) } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } } @@ -89,12 +88,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, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) } + api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } } val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() - val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty() - val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) } + val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() + val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } val numbers = seasons.mapNotNull { it.indexNumber } val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) @@ -123,9 +122,8 @@ class TitleReadFacade @Inject constructor( return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) } - /** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */ - suspend fun preparePlayback(id: UUID): BaseItemDto? = - readPlayback(api, repository.currentUser?.id, id) + /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ + suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } /** Track selection uses the same repository consumed by the upstream player. */ suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { @@ -166,7 +164,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 = creditFields, startIndex = start, limit = 200, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt index 8030fcb9..5559f15a 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt @@ -67,7 +67,6 @@ class ConductarrClient( timeouts: ClientTimeouts = ClientTimeouts(), private val nowMillis: () -> Long = System::currentTimeMillis, private val onEvent: (ClientEvent) -> Unit = {}, - dns: PersistentDns? = null, ) : Closeable { private val base = baseUrl.toHttpUrl() private val scope: String @@ -75,7 +74,6 @@ class ConductarrClient( private val cacheIdentityLock = Any() private val catalogueReads = Semaphore(2) private val http = OkHttpClient.Builder() - .apply { if (dns != null) DnsConnectionRetry.configure(this, dns) } .connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS) .readTimeout(timeouts.readSeconds, TimeUnit.SECONDS) .callTimeout(timeouts.callSeconds, TimeUnit.SECONDS) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt deleted file mode 100644 index 4248a4c0..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetry.kt +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.IOException -import java.net.InetSocketAddress -import java.net.Proxy -import java.util.concurrent.ConcurrentHashMap -import okhttp3.Call -import okhttp3.EventListener -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Response - -/** Retry once with fresh DNS only if connection establishment failed before any HTTP headers were sent. */ -class DnsConnectionRetry(private val dns: PersistentDns) : Interceptor, EventListener.Factory { - private class Attempt { - @Volatile var connectionFailed = false - @Volatile var headersSent = false - } - private val attempts = ConcurrentHashMap() - - override fun create(call: Call): EventListener { - val attempt = Attempt() - attempts[call] = attempt - return object : EventListener() { - override fun connectFailed(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy, protocol: Protocol?, ioe: IOException) { - attempt.connectionFailed = true - } - override fun requestHeadersStart(call: Call) { attempt.headersSent = true } - override fun callEnd(call: Call) { attempts.remove(call) } - override fun callFailed(call: Call, ioe: IOException) { attempts.remove(call) } - } - } - - override fun intercept(chain: Interceptor.Chain): Response { - // OkHttp deliberately disables event listeners on its derived WebSocket client. - val attempt = attempts[chain.call()] ?: return chain.proceed(chain.request()) - return try { - chain.proceed(chain.request()) - } catch (failure: IOException) { - if (!attempt.connectionFailed || attempt.headersSent || chain.call().isCanceled()) throw failure - try { - dns.lookupFresh(chain.request().url.host) - } catch (lookupFailure: IOException) { - lookupFailure.addSuppressed(failure) - throw lookupFailure - } - chain.proceed(chain.request()) - } - } - - companion object { - /** Install the same resolver and its pre-request recovery on a separately owned client. */ - fun configure(builder: OkHttpClient.Builder, dns: PersistentDns): OkHttpClient.Builder { - val retry = DnsConnectionRetry(dns) - return builder.dns(dns).eventListenerFactory(retry).addInterceptor(retry) - } - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt deleted file mode 100644 index 2c65b657..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDns.kt +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.File -import java.io.IOException -import java.net.InetAddress -import java.net.UnknownHostException -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executor -import java.util.concurrent.Executors -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlinx.serialization.encodeToString -import okhttp3.Dns - -/** Last-known system answers stay usable for one day while a single background lookup refreshes them. */ -class PersistentDns( - private val file: () -> File, - private val resolver: Dns = Dns.SYSTEM, - private val executor: Executor = Executors.newFixedThreadPool(2) { task -> Thread(task, "wolfskin-dns").apply { isDaemon = true } }, - private val nowMillis: () -> Long = System::currentTimeMillis, -) : Dns { - private class Host { - @Volatile var answer: Answer? = null - @Volatile var refreshAfter = 0L - var refreshing = false - val resolutionLock = Any() - } - - @Serializable - private data class Answer( - val addresses: List, - val savedAtMillis: Long, - @Transient val resolvedAddresses: List = decodeAddresses(addresses), - ) - private val hosts = ConcurrentHashMap() - private val diskLock = Any() - @Volatile private var loaded = false - - override fun lookup(hostname: String): List { - load() - val host = hosts.computeIfAbsent(hostname) { Host() } - val saved = host.answer - if (saved != null && nowMillis() - saved.savedAtMillis in 0..MAX_STALENESS_MILLIS) { - refresh(hostname, host) - return saved.resolvedAddresses - } - return synchronized(host.resolutionLock) { - val current = host.answer - if (current != null && current !== saved && nowMillis() - current.savedAtMillis in 0..MAX_STALENESS_MILLIS) { - current.resolvedAddresses - } else resolve(hostname, host) - } - } - - /** A failed connection bypasses the cached answer and any completed background refresh. */ - fun lookupFresh(hostname: String): List { - load() - val host = hosts.computeIfAbsent(hostname) { Host() } - return synchronized(host.resolutionLock) { - host.answer = null - resolve(hostname, host) - } - } - - private fun refresh(hostname: String, host: Host) { - synchronized(host) { - if (host.refreshing || nowMillis() < host.refreshAfter) return - host.refreshing = true - } - executor.execute { - try { - synchronized(host.resolutionLock) { resolve(hostname, host) } - } catch (_: Exception) { - // Failed refreshes do not extend the lifetime of the last successful answer. - host.refreshAfter = nowMillis() + FAILURE_BACKOFF_MILLIS - } finally { - synchronized(host) { host.refreshing = false } - } - } - } - - private fun resolve(hostname: String, host: Host): List { - val addresses = resolver.lookup(hostname) - if (addresses.isEmpty()) throw UnknownHostException(hostname) - host.answer = Answer(addresses.map { it.hostAddress!! }, nowMillis(), addresses) - host.refreshAfter = nowMillis() + REFRESH_INTERVAL_MILLIS - persist() - return addresses - } - - private fun load() { - if (loaded) return - synchronized(diskLock) { - if (loaded) return - try { - val saved = file() - if (saved.isFile && saved.length() <= MAX_FILE_BYTES) { - val entries = WireJson.decodeFromString>(saved.readText()) - require(entries.size <= MAX_HOSTS) - entries.forEach { (name, answer) -> hosts[name] = Host().apply { this.answer = answer } } - } - } catch (_: IOException) { - // A missing or damaged hint file cannot prevent normal system resolution. - } catch (_: IllegalArgumentException) { - // Serialization and literal-validation errors invalidate the entire hint file. - } - loaded = true - } - } - - private fun persist() = synchronized(diskLock) { - try { - val entries = hosts.entries.mapNotNull { (name, host) -> host.answer?.let { name to it } } - .sortedByDescending { it.second.savedAtMillis }.take(MAX_HOSTS).toMap() - val target = file() - target.parentFile?.mkdirs() - val temporary = File(target.path + ".tmp") - temporary.writeText(WireJson.encodeToString(entries)) - if (!temporary.renameTo(target)) temporary.delete() - } catch (_: IOException) { - // The in-memory answer remains usable when private storage is unavailable. - } - } - - companion object { - const val MAX_STALENESS_MILLIS = 24 * 60 * 60 * 1000L - private const val REFRESH_INTERVAL_MILLIS = 60_000L - private const val FAILURE_BACKOFF_MILLIS = 2_000L - private const val MAX_HOSTS = 32 - private const val MAX_ADDRESSES = 32 - private const val MAX_FILE_BYTES = 64 * 1024L - private fun decodeAddresses(addresses: List): List { - require(addresses.isNotEmpty() && addresses.size <= MAX_ADDRESSES) - return addresses.map { address -> - // Numeric literals only: decoding the hint file must never perform DNS. - require(address.contains(':') || address.matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"))) - InetAddress.getByName(address) - } - } - } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt index 8c8495fb..98173fce 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt @@ -2,10 +2,6 @@ package org.justinvisser.wolfskin.conductarr import java.nio.file.Files -import java.io.File -import java.net.InetAddress -import java.util.concurrent.Executor -import okhttp3.Dns import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking @@ -18,24 +14,13 @@ fun main() = runBlocking { try { MockWebServer().use { server -> server.start() - val baseUrl = server.url("/").newBuilder().host("localhost").build().toString() - var resolutions = 0 - val dead = InetAddress.getByName("127.0.0.2") - val live = InetAddress.getByName("127.0.0.1") - val dns = PersistentDns({ File(directory, "dns.json") }, Dns { - resolutions++ - if (resolutions == 1) listOf(dead) else listOf(live) - }, Executor { it.run() }) - check(dns.lookup("localhost") == listOf(dead)) + val baseUrl = server.url("/").toString() server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build()) - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).use { client -> + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value check(live.saved.value!!.profiles!!.single().name == "Justin") - check(resolutions == 2) - println("DNS SMOKE: cached 127.0.0.2 refused -> fresh 127.0.0.1 -> profile Justin; requests=${server.requestCount}, resolutions=$resolutions") } - val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() }) - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).use { client -> + ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value check(saved.saved.fromDisk) server.enqueue(MockResponse.Builder().code(304).build()) diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt deleted file mode 100644 index 58b72d98..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/DnsConnectionRetryTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.net.InetAddress -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.Dispatcher -import okhttp3.Dns -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** OkHttp's listener-free WebSocket client must retain a working upgrade and message path. */ -class DnsConnectionRetryTest { - @get:Rule val directory = TemporaryFolder() - - @Test fun derivedClientOpensWebSocketWithoutAnUncaughtDispatcherException() { - val opened = CompletableFuture() - val message = CompletableFuture() - val uncaught = AtomicReference() - val executor = Executors.newCachedThreadPool { task -> - Thread(task, "dns-websocket-test").apply { - uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { _, failure -> - uncaught.set(failure) - opened.completeExceptionally(failure) - message.completeExceptionally(failure) - } - } - } - MockWebServer().use { server -> - server.enqueue(MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { webSocket.send("ready") } - }).build()) - server.start() - val dns = PersistentDns({ directory.root.resolve("dns.json") }, Dns { listOf(InetAddress.getByName("127.0.0.1")) }) - val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns) - .dispatcher(Dispatcher(executor)).build().newBuilder().build() - val socket = client.newWebSocket(Request.Builder().url(server.url("/events")).build(), object : WebSocketListener() { - override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(response.code) } - override fun onMessage(webSocket: WebSocket, text: String) { message.complete(text) } - override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - opened.completeExceptionally(t) - message.completeExceptionally(t) - } - }) - try { - assertEquals(101, opened.get(5, TimeUnit.SECONDS).toInt()) - assertEquals("ready", message.get(5, TimeUnit.SECONDS)) - assertNull(uncaught.get()) - println("WEBSOCKET SMOKE: derived cached-DNS client opened HTTP 101, received ready, no uncaught dispatcher exception") - } finally { - socket.cancel() - client.connectionPool.evictAll() - executor.shutdownNow() - } - } - } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt deleted file mode 100644 index b167b354..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/PersistentDnsTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.File -import java.net.InetAddress -import java.net.Proxy -import java.net.UnknownHostException -import java.util.concurrent.Executor -import java.util.concurrent.TimeUnit -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.ConnectionPool -import okhttp3.Dns -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** Fake time and queued refreshes prove stale answers, disk hints and dead-address recovery. */ -class PersistentDnsTest { - @get:Rule val directory = TemporaryFolder() - private var now = 1_000_000L - private val queued = ArrayDeque() - private val background = Executor { queued.add(it) } - private val old = InetAddress.getByName("127.0.0.2") - private val fresh = InetAddress.getByName("127.0.0.1") - private var answer = listOf(old) - private var lookups = 0 - private val resolver = Dns { lookups++; answer } - private val file get() = File(directory.root, "dns.json") - private fun cache(resolve: Dns = resolver) = PersistentDns({ file }, resolve, background, { now }) - - @Test fun staleAnswerReturnsBeforeBackgroundRefreshAndNextLookupUsesChangedIp() { - val dns = cache() - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - now += 120_000 - answer = listOf(fresh) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - assertEquals(1, lookups) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - assertEquals(1, queued.size) - queued.removeFirst().run() - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - assertEquals(2, lookups) - } - - @Test fun answersOlderThanOneDayCannotBeServedEvenWhenRefreshFails() { - val dns = cache() - dns.lookup("jellyfin.test") - now += PersistentDns.MAX_STALENESS_MILLIS + 1 - val expired = cache(Dns { throw UnknownHostException() }) - assertThrows(UnknownHostException::class.java) { expired.lookup("jellyfin.test") } - } - - @Test fun persistedAnswersRetainTimestampAndSystemAddressOrdering() { - answer = listOf(InetAddress.getByName("::1"), fresh) - cache().lookup("jellyfin.test") - now += 120_000 - val reopened = cache(Dns { error("Disk answer must return before resolving") }) - assertEquals(answer, reopened.lookup("jellyfin.test")) - assertEquals(1, queued.size) - now += PersistentDns.MAX_STALENESS_MILLIS - assertThrows(UnknownHostException::class.java) { - cache(Dns { throw UnknownHostException() }).lookup("jellyfin.test") - } - } - - @Test fun corruptOrNonnumericDiskAnswersAreIgnored() { - for (contents in listOf("not json", "{\"jellyfin.test\":{\"addresses\":[\"not-an-ip.test\"],\"savedAtMillis\":1000000}}")) { - file.writeText(contents) - answer = listOf(fresh) - assertEquals(listOf(fresh), cache().lookup("jellyfin.test")) - } - assertEquals(2, lookups) - } - - @Test fun negativeAnswersDoNotDelayRecoveryAndFailedRefreshDoesNotExtendStaleness() { - var offline = true - val dns = cache(Dns { if (offline) throw UnknownHostException(); listOf(fresh) }) - assertThrows(UnknownHostException::class.java) { dns.lookup("jellyfin.test") } - offline = false - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - offline = true - now += 120_000 - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - assertTrue(queued.isEmpty()) - now += 2_001 - dns.lookup("jellyfin.test") - assertEquals(1, queued.size) - } - - @Test fun unexpectedRefreshExceptionKeepsTheLastAnswerAndAllowsAnotherRefresh() { - var failing = false - val dns = cache(Dns { if (failing) throw IllegalStateException("resolver unavailable"); answer }) - dns.lookup("jellyfin.test") - now += 120_000 - failing = true - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - now += 2_001 - failing = false - answer = listOf(fresh) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - } - - @Test fun failedCachedAddressForcesFreshResolutionAndRetriesBeforeSendingPost() { - MockWebServer().use { server -> - server.start() - val dns = cache() - dns.lookup("jellyfin.test") - answer = listOf(fresh) - server.enqueue(MockResponse.Builder().body("new address").build()) - val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns) - .proxy(Proxy.NO_PROXY).retryOnConnectionFailure(false).connectTimeout(1, TimeUnit.SECONDS) - .connectionPool(ConnectionPool(0, 1, TimeUnit.MILLISECONDS)).build() - try { - val url = server.url("/").newBuilder().host("jellyfin.test").build() - client.newCall(Request.Builder().url(url).post("body".toRequestBody()).build()).execute().use { - assertEquals("new address", it.body.string()) - } - assertEquals(2, lookups) - assertEquals(1, server.requestCount) - assertEquals(listOf(fresh), dns.lookup("jellyfin.test")) - } finally { - client.connectionPool.evictAll() - client.dispatcher.executorService.shutdown() - } - } - } - - @Test fun backgroundIpChangeIsUsedByTheNextHttpCall() { - MockWebServer().use { server -> - server.start() - val dns = cache() - dns.lookup("jellyfin.test") - now += 120_000 - answer = listOf(fresh) - assertEquals(listOf(old), dns.lookup("jellyfin.test")) - queued.removeFirst().run() - server.enqueue(MockResponse.Builder().body("refreshed").build()) - val client = DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).proxy(Proxy.NO_PROXY).build() - try { - val url = server.url("/").newBuilder().host("jellyfin.test").build() - client.newCall(Request.Builder().url(url).build()).execute().use { assertEquals("refreshed", it.body.string()) } - assertEquals(2, lookups) - assertEquals(1, server.requestCount) - } finally { - client.connectionPool.evictAll() - client.dispatcher.executorService.shutdown() - } - } - } -} diff --git a/docker/README.md b/docker/README.md index 2c1c4c56..b68d58c6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -27,64 +27,6 @@ Upstream enables JVM unit tests only for debug variants; there is no `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor task without changing upstream's test configuration. -### Full-suite test diagnostics - -The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate -from the forked test-worker heap. The default test worker uses 512 MiB; changing -the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve -the person-picker fixture's missing Compose snapshot notifications. - -Gradle 9.8.2 runs previously failed test classes first on the next invocation. -An immediate green `make test` rerun can therefore hide an order-sensitive -failure rather than prove a fix. For comparisons, clear only generated binary -test-result history (`build/test-results//binary` in each module) before -each run and record actual class-start order; XML file listing order is not -execution order. Keep worker heap and parallelism unchanged between samples. -`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone -or promoted to the front by previous failures. - -## Saved-person launch and DNS - -Launch restores an unprotected saved Jellyfin login from local storage before -network validation. The existing Wolfskin picker paints the saved names and -identity-derived colors, then refreshes the household in the background. PIN and -require-login profiles still go through the existing protected sign-in flow. -`Users/Me` and `System/Info/Public` now start concurrently; a late launch response -cannot replace a different selected person. Name revalidation updates only the -name column and reloads the saved user, preserving newer settings and PINs. - -Jellyfin, Conductarr, artwork, playback and crash-report clients share one -`PersistentDns` instance through the application graph. It keeps system-ordered -IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts). -Saved answers return immediately for up to 24 hours and refresh off main after -60 seconds; failed background lookups back off for 2 seconds without extending -the last successful timestamp. Unexpected resolver exceptions are contained at -the background refresh and warm-up boundaries. Negative answers are not persisted or cached. -Missing, oversized or corrupt hint files fall back to system resolution. -Application creation warms the saved Jellyfin host and configured Conductarr -host on parallel IO jobs; it does not change Android Private DNS. - -A connection-establishment failure before any HTTP request headers were sent -gets one fresh system lookup and one retry. Responses, read failures and -uncertain mutation outcomes are not replayed. Conductarr retains its existing -no-automatic-replay policy after a request starts. A DNS refresh changes the next -new connection; already-working pooled connections are not forcibly closed. -OkHttp's derived WebSocket clients disable event listeners; those calls bypass -the HTTP retry bookkeeping while retaining the shared DNS resolver. - -Device acceptance (not established by JVM tests): with saved people present, -cold-launch six times more than 60 seconds apart and require the picker within -0.5 seconds on every launch. Press Left/Right then one OK; the selected Home must -open without a second picker. Open the avatar picker, change person with one OK, -and repeat after an offline launch to confirm saved people still paint. - -Compare timestamps of the launch pair of SDK log lines -`GET https://jelly.justinvisser.org/Users/Me` and -`GET https://jelly.justinvisser.org/System/Info/Public` (ignore later foreground -`Users/Me` refreshes). The spike's two slow launch gaps were 1.508 and 1.509 -seconds. The requests now overlap, so either may log first; the picker must not -wait for either response. The minified SDK log tag is not stable across builds. - ## Optimized side-by-side comparison The `optimized` build type inherits `release`: R8 optimization/minification, @@ -373,29 +315,16 @@ an untouched fresh entry focuses hero button zero; intervening nav input prevent that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` release/repeat guard and Back ladder. -`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up -publish independently of slow or failed library shelves; saved metadata still -paints immediately, but a saved hero offers Details until watch data revalidates. -Home and title Play actions resolve the current Jellyfin item and its current -checkpoint at the click, preserving millisecond positions. Series actions use -live NextUp with resumable episodes enabled; an explicitly opened episode stays -that episode, and Restart deliberately starts that displayed episode at zero. -Played flags do not discard a positive checkpoint for an intentional rewatch. -No season, recommendation stub or different person's session becomes playable. +`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release +variants read the selected user's Resume, Next up, newest titles and destination +shelves, with app-private last-known-good projections through the shared atomic +disk cache. A missing saved library becomes a recovery panel; a saved library +stays visible when Jellyfin is unreachable. Playback resolves an actual film or +episode and checks Jellyfin before opening upstream's player. No season, +recommendation stub or different person's session is promoted to playback. An unresolved person switch guards Home and both owned/upstream library content before mounting any source read with the prior person's Jellyfin session. -Home and title watch reads refresh on entry and foreground. A successful -PlaybackStopped acknowledgement publishes a revision for the captured server/user, -so a return read that beat the stop write is refreshed again after that write. -The acknowledgement also removes that user's persisted library grid snapshots -instead of waiting for their ten-minute TTL; an in-memory revision bypasses any -older snapshot that raced invalidation. Stop-report failures remain logged and -do not publish a successful acknowledgement. External players refresh after a -supported stop result; an external app that returns no position cannot supply a -missing checkpoint. Another client's changes after the final read are outside -that click's snapshot. - Debug variants additionally package the locked fixture and local artwork under `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. The explicit fixture provider supplies approved household data and Home states @@ -628,29 +557,15 @@ 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 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. + 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. `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 @@ -671,13 +586,6 @@ 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 1078d8db..eefd5945 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" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title)) + onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") 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 8aad42d4..eb487f62 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,10 +43,9 @@ 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" || !watchReady) 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") 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 } @@ -85,7 +84,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) : HomeAction + data class Play(val title: HomeTitle, val resume: Boolean) : 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 4630ce75..103e8818 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,7 +91,6 @@ 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. */ @@ -102,7 +101,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, val episodeSelection: Boolean = false) : TitleAction + data class OpenTitle(val item: TitleItem) : 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 c683c8e7..e2deee4a 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,12 +182,7 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { @Composable private fun TitleSeasons(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current - 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)) { + 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)) { 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 d8a6d560..2e5da10c 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,8 +31,6 @@ 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() @@ -58,7 +56,7 @@ class TitleState( val pendingAudio = audioIndex != data.selectedAudio val pendingSubtitle = subtitleIndex != data.selectedSubtitle data = value - 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 (wasLoading || 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 @@ -154,8 +152,7 @@ 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 - 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))) + focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) } private fun rowSize(id: String): Int = when (id) { @@ -171,15 +168,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, episodeSelection = true)) } - "seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } + "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } + "seasons" -> { 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" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "play", "restart" -> 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 } @@ -263,8 +260,7 @@ fun titleOptions(state: TitleState): List = buildList { return@buildList } if (item.held) { - if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) - if (target != null && data.watchReady) { + if (target != null) { 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 e9b63760..349fd2d2 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,8 +1,6 @@ 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 @@ -25,8 +23,6 @@ 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. */ @@ -82,34 +78,32 @@ object WolfskinImages { private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE) private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/") - 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 val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight") 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 = when { - height >= width -> "w342" - width > WolfskinTokens.Image.wideRequestWidth -> "w1280" - else -> "w780" + val size = if (height > width) when { + width <= 500 -> "w500" + width <= 780 -> "w780" + else -> "original" + } else when { + width <= 780 -> "w780" + width <= 1280 -> "w1280" + else -> "original" } 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 imageParameters) { + if (name.lowercase() !in dimensionParameters) { uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) } } } return builder.appendQueryParameter("fillWidth", width.toString()) - .appendQueryParameter("fillHeight", height.toString()) - .appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp") - .appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString() + .appendQueryParameter("fillHeight", height.toString()).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 6b77b2ef..fa730cd2 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 = 400 - const val tileRequestHeight = 225 - const val wideRequestWidth = 544 - const val wideRequestHeight = 306 + const val tileRequestWidth = 416 + const val tileRequestHeight = 234 + const val wideRequestWidth = 560 + const val wideRequestHeight = 315 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 a23faa73..01c953b8 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) } - 1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } + 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) } 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,6 +170,7 @@ 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 deleted file mode 100644 index dfefe549..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 cc1a845b..70571dc3 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1492,8 +1492,8 @@ }, "tileRequest": { "px": [ - 400, - 225 + 416, + 234 ], "decode": "ARGB_8888", "source": "index.html:549", @@ -1501,8 +1501,8 @@ }, "wideRequest": { "px": [ - 544, - 306 + 560, + 315 ], "decode": "ARGB_8888", "source": "index.html:401", diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 405b0abe..713ae2f4 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 -580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json 36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css -226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json +3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json