Compare commits
12 commits
fm/tv-fb1-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 332fc6476d | |||
| d18a05bdb0 | |||
| fd48426fa7 | |||
| 052d0b8898 | |||
| acb72335e3 | |||
| 53a2cff2c8 | |||
| 327f631f0d | |||
| e340d470c1 | |||
| f4fb6e22c9 | |||
| 5bd2affc1a | |||
| 8e40ac0869 | |||
| 54c23e076a |
62 changed files with 1856 additions and 177 deletions
|
|
@ -32,6 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.RestoredSession
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
|
|
@ -576,12 +577,22 @@ class MainActivityViewModel
|
|||
val restoredSession =
|
||||
if (signInAutomatically) {
|
||||
Timber.v("Sign in automatically")
|
||||
serverRepository.restoreLastSession()
|
||||
serverRepository.restoreSavedSession()
|
||||
} else {
|
||||
serverRepository.getMostRecentServer()
|
||||
}
|
||||
Timber.d("Restore last session result=%s", restoredSession)
|
||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
||||
if (signInAutomatically && restoredSession is RestoredSession.Success) {
|
||||
viewModelScope.launchDefault {
|
||||
try {
|
||||
serverRepository.revalidateSession(restoredSession.currentUser)
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
Timber.w(exception, "Saved launch session revalidation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import org.acra.ktx.initAcra
|
|||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.model.ServerVersion
|
||||
import timber.log.Timber
|
||||
import org.justinvisser.wolfskin.tv.NetworkStartup
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||
|
|
@ -54,6 +55,7 @@ class WholphinApplication :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
networkStartup.start()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy
|
||||
|
|
@ -110,6 +112,9 @@ class WholphinApplication :
|
|||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var networkStartup: NetworkStartup
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() =
|
||||
Configuration
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ interface JellyfinServerDao {
|
|||
return user.copy(rowId = result.toInt())
|
||||
}
|
||||
|
||||
/** Network name refreshes must not overwrite settings or protection changed since launch. */
|
||||
@Query("UPDATE users SET name = :name WHERE serverId = :serverId AND id = :userId")
|
||||
fun updateUserName(serverId: UUID, userId: UUID, name: String?): Int
|
||||
|
||||
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||
fun getUser(
|
||||
serverId: UUID,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
|||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -101,15 +103,7 @@ class ServerRepository
|
|||
}
|
||||
Timber.v("Changing user to ${user.name} on ${server.url}")
|
||||
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
||||
val userDto by apiClient.userApi.getCurrentUser()
|
||||
val updatedServer =
|
||||
try {
|
||||
val sysInfo by apiClient.systemApi.getPublicSystemInfo()
|
||||
server.copy(name = sysInfo.serverName, version = sysInfo.version)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception fetching public system info")
|
||||
server
|
||||
}
|
||||
val (updatedServer, userDto) = readSession(server)
|
||||
var updatedUser =
|
||||
user.copy(
|
||||
id = userDto.id,
|
||||
|
|
@ -125,6 +119,45 @@ class ServerRepository
|
|||
return@withContext currentUser
|
||||
}
|
||||
|
||||
/** Restore only an unprotected saved login; the launch picker must not wait for the network. */
|
||||
suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) {
|
||||
val recent = mostRecentServerProvider.get()
|
||||
if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent)
|
||||
val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None
|
||||
val user = saved.users.firstOrNull { it.id == recent.userId }
|
||||
if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server)
|
||||
apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken)
|
||||
val current = CurrentUser(saved.server, user)
|
||||
_currentUserDto.value = null
|
||||
_current.value = current
|
||||
RestoredSession.Success(current)
|
||||
}
|
||||
|
||||
/** A late launch refresh must never replace a person selected while it was in flight. */
|
||||
suspend fun revalidateSession(expected: CurrentUser) = withContext(ioDispatcher) {
|
||||
val (server, userDto) = readSession(expected.server)
|
||||
if (_current.value !== expected || userDto.id != expected.user.id) return@withContext
|
||||
serverDao.addOrUpdateServer(server)
|
||||
serverDao.updateUserName(expected.server.id, expected.user.id, userDto.name)
|
||||
val user = serverDao.getUser(expected.server.id, expected.user.id) ?: return@withContext
|
||||
if (_current.compareAndSet(expected, CurrentUser(server, user))) updateUserDto(userDto)
|
||||
}
|
||||
|
||||
private suspend fun readSession(server: JellyfinServer): Pair<JellyfinServer, UserDto> = 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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ class NavigationManager
|
|||
log()
|
||||
}
|
||||
|
||||
/** Replace only the current page, preserving every parent entry. */
|
||||
fun replaceTop(destination: Destination) {
|
||||
if (backStack.isEmpty()) backStack.add(destination)
|
||||
else backStack[backStack.lastIndex] = destination
|
||||
log()
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the specified [Destination], but reset the back stack to Home first
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
import org.justinvisser.wolfskin.conductarr.DnsConnectionRetry
|
||||
import java.io.File
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -91,12 +94,14 @@ object AppModule {
|
|||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient() =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.apply {
|
||||
// TODO user agent, timeouts, logging, etc
|
||||
}.build()
|
||||
fun okHttpClient(dns: PersistentDns) =
|
||||
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
|
||||
|
||||
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun dns(@ApplicationContext context: Context): PersistentDns =
|
||||
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -82,9 +82,11 @@ class PlayExternalViewModel
|
|||
) : ViewModel() {
|
||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||
val state = MutableStateFlow(PlayExternalState())
|
||||
private var watchScope: String? = null
|
||||
|
||||
fun init(destination: Destination) {
|
||||
Timber.v("init called: %s", destination)
|
||||
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||
state.update { it.copy(loading = LoadingState.Loading) }
|
||||
viewModelScope.launchDefault {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
|
|
@ -358,6 +360,7 @@ class PlayExternalViewModel
|
|||
failed = false,
|
||||
),
|
||||
)
|
||||
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
||||
}
|
||||
} else {
|
||||
Timber.w(
|
||||
|
|
|
|||
|
|
@ -830,10 +830,12 @@ class PlaybackViewModel
|
|||
}
|
||||
|
||||
val playbackItemState = PlaybackItemState(playback)
|
||||
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||
val activityListener =
|
||||
TrackActivityPlaybackListener(
|
||||
api = api,
|
||||
player = player,
|
||||
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
|
||||
getState = { playbackItemState },
|
||||
)
|
||||
player.addListener(activityListener)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import android.content.Context
|
|||
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import com.google.auto.service.AutoService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -73,10 +78,7 @@ class CrashReportSender : ReportSender {
|
|||
|
||||
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||
(this as? MostRecentServer.ServerAndUser)?.let {
|
||||
val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.build()
|
||||
val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client()
|
||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||
val api =
|
||||
createJellyfin {
|
||||
|
|
@ -89,3 +91,11 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
|||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||
api
|
||||
}
|
||||
|
||||
/** Crash reporting shares the application's DNS hints without borrowing authenticated headers. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface CrashNetwork {
|
||||
@StandardOkHttpClient
|
||||
fun client(): OkHttpClient
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import android.content.Context
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/** Only an acknowledged stop invalidates the captured server/user's watch projections. */
|
||||
object PlaybackWatchState {
|
||||
private val mutableRevisions = MutableStateFlow<Map<String, Long>>(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)) }
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import com.github.damontecres.wholphin.ui.launchIO
|
|||
import com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
|
@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds
|
|||
class TrackActivityPlaybackListener(
|
||||
private val api: ApiClient,
|
||||
private val player: Player,
|
||||
private val onStopped: () -> Unit = {},
|
||||
private val getState: () -> PlaybackItemState?,
|
||||
) : Player.Listener {
|
||||
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
||||
private val reports = Mutex()
|
||||
@Volatile private var released = false
|
||||
private val task: TimerTask =
|
||||
object : TimerTask() {
|
||||
override fun run() {
|
||||
|
|
@ -70,19 +75,23 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
val delay = 5.seconds.inWholeMilliseconds
|
||||
// Every x seconds, check if the video is playing
|
||||
TIMER.schedule(task, delay, delay)
|
||||
initialized = true
|
||||
if (!released) {
|
||||
TIMER.schedule(task, delay, delay)
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
// player.removeListener(this)
|
||||
if (released) return
|
||||
released = true
|
||||
task.cancel()
|
||||
TIMER.purge()
|
||||
val position = player.currentPosition.milliseconds
|
||||
val stoppedState = getState.invoke()
|
||||
launch("reportPlaybackStopped") {
|
||||
getState.invoke()?.let { state ->
|
||||
stoppedState?.let { state ->
|
||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||
if (position < Duration.ZERO) {
|
||||
Timber.w("Negative position when reporting playback stopped: %s", position)
|
||||
|
|
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
|
|||
liveStreamId = state.liveStreamId,
|
||||
),
|
||||
)
|
||||
onStopped()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +127,7 @@ class TrackActivityPlaybackListener(
|
|||
|
||||
private fun saveActivity(position: Long) {
|
||||
launch("saveActivity") {
|
||||
if (released) return@launch
|
||||
getState.invoke()?.let { state ->
|
||||
val calcPosition =
|
||||
withContext(WholphinDispatchers.Main) {
|
||||
|
|
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
|
|||
) {
|
||||
coroutineScope.launchIO {
|
||||
try {
|
||||
block.invoke(this)
|
||||
reports.withLock { block.invoke(this) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Exception during %s", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import io.mockk.mockkStatic
|
|||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
|
@ -116,9 +118,6 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
||||
// coEvery { serverRepository.restoreLastSession() } returns
|
||||
// RestoredSession.Success(currentUser)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
|
|
@ -132,7 +131,7 @@ class TestMainActivityViewModel {
|
|||
destination as SetupDestination.AppContent
|
||||
Assert.assertEquals(currentUser, destination.current)
|
||||
}
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +149,7 @@ class TestMainActivityViewModel {
|
|||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
||||
val args = mutableListOf<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
||||
|
|
@ -165,6 +164,20 @@ class TestMainActivityViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) {
|
||||
setupPreferences { signInAutomatically = true }
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser)
|
||||
val release = CompletableDeferred<Unit>()
|
||||
coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() }
|
||||
viewModel.handleAppStart()
|
||||
runCurrent()
|
||||
verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) }
|
||||
assertFalse(release.isCompleted)
|
||||
release.complete(Unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test auto sign in, hot load, with protected profile`() =
|
||||
runTest {
|
||||
|
|
@ -178,7 +191,7 @@ class TestMainActivityViewModel {
|
|||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
} returns null
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
coEvery { serverRepository.restoreSavedSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
|
@ -204,7 +217,7 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
coEvery { serverRepository.restoreSavedSession() } returns
|
||||
RestoredSession.ServerOnly(server)
|
||||
coEvery {
|
||||
serverRepository.tryChangeUser(serverId, userId)
|
||||
|
|
@ -212,7 +225,7 @@ class TestMainActivityViewModel {
|
|||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
|
|
@ -232,11 +245,11 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
||||
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
|
||||
|
||||
viewModel.handleAppStart()
|
||||
|
||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||
val slot = slot<SetupDestination>()
|
||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||
slot.captured.let { destination ->
|
||||
|
|
@ -254,7 +267,7 @@ class TestMainActivityViewModel {
|
|||
signInAutomatically = true
|
||||
}
|
||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||
coEvery { serverRepository.restoreLastSession() } returns
|
||||
coEvery { serverRepository.restoreSavedSession() } returns
|
||||
RestoredSession.Success(currentUser)
|
||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||
|
||||
|
|
@ -271,7 +284,7 @@ class TestMainActivityViewModel {
|
|||
// destination as SetupDestination.AppContent
|
||||
// Assert.assertEquals(currentUser, destination.current)
|
||||
// }
|
||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
||||
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.app.Application
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.*
|
||||
import com.github.damontecres.wholphin.data.model.*
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import io.mockk.*
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.operations.SystemApi
|
||||
import org.jellyfin.sdk.api.operations.UserApi
|
||||
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.junit.Assert.*
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Saved identities can paint offline without unlocking protected logins or accepting a late identity. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class ServerSavedSessionTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val server = JellyfinServer(UUID.randomUUID(), "Saved server", "https://jellyfin.test", "12.1.0")
|
||||
private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Saved name", accessToken = "saved-token")
|
||||
private val users = mockk<UserApi>()
|
||||
private val system = mockk<SystemApi>()
|
||||
private val api = mockk<ApiClient>(relaxed = true)
|
||||
private val recent = mockk<MostRecentServerProvider>()
|
||||
private val database by lazy {
|
||||
Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext<Application>(), 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<Context>(), 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<Unit>()
|
||||
val systemStarted = CompletableDeferred<Unit>()
|
||||
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<Unit>()
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
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<Unit>()
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import timber.log.Timber
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -91,12 +92,11 @@ object TestModule {
|
|||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient() =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.apply {
|
||||
// TODO user agent, timeouts, logging, etc
|
||||
}.build()
|
||||
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
|
||||
|
||||
@AuthOkHttpClient
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ class PlayExternalViewModelTest {
|
|||
every { api.subtitleApi } returns subtitleApi
|
||||
every { api.playStateApi } returns playStateApi
|
||||
every { serverRepository.currentUser } returns null
|
||||
every { serverRepository.currentServer } returns null
|
||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package com.github.damontecres.wholphin.util
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import androidx.media3.common.Player
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.operations.PlayStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||
import java.io.File
|
||||
|
||||
/** Return refresh waits for the stop write rather than racing its acknowledgement. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class PlaybackWatchStateTest {
|
||||
@Test fun revisionAppearsOnlyAfterTheCapturedUsersStopIsAcknowledged() = runTest {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
val previous = WholphinDispatchers.IO
|
||||
Dispatchers.setMain(dispatcher)
|
||||
WholphinDispatchers.IO = dispatcher
|
||||
val api = mockk<ApiClient>()
|
||||
val reports = mockk<PlayStateApi>()
|
||||
every { api.playStateApi } returns reports
|
||||
val player = mockk<Player> { 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<Unit>()
|
||||
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<ApiClient>()
|
||||
val reports = mockk<PlayStateApi>()
|
||||
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<Player> { 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,8 +52,9 @@ class ConductarrHomeSurfaceTest {
|
|||
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||
runBlocking { session.authenticate() }
|
||||
val home = mockk<HomeReadFacade>()
|
||||
coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf(
|
||||
coEvery { home.readWatch(user.id) } returns HomeLibrary(resume = listOf(
|
||||
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
|
||||
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class FirstSignInNavigationTest {
|
|||
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
|
||||
for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) {
|
||||
coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
|
||||
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,13 @@ class HomeRecoveryTest {
|
|||
val started = CompletableDeferred<Unit>()
|
||||
val response = CompletableDeferred<HomeLibrary>()
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertFalse(model.snapshot.rows.first().watchReady)
|
||||
response.completeExceptionally(IOException("fixture offline"))
|
||||
refresh.await()
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
|
|
@ -62,8 +64,9 @@ class HomeRecoveryTest {
|
|||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||
val startedA = CompletableDeferred<Unit>()
|
||||
val responseA = CompletableDeferred<HomeLibrary>()
|
||||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
||||
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
|
||||
startedA.await()
|
||||
|
|
@ -80,7 +83,8 @@ class HomeRecoveryTest {
|
|||
|
||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
coEvery { facade.readWatch(personA.id) } returns library
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>()
|
||||
every { cache.read(any()) } returns null
|
||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||
|
|
@ -93,7 +97,8 @@ class HomeRecoveryTest {
|
|||
|
||||
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||
val facade = mockk<HomeReadFacade>()
|
||||
coEvery { facade.read(personA.id) } returns library
|
||||
coEvery { facade.readWatch(personA.id) } returns library
|
||||
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||
val cache = mockk<HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class LibraryReadFacadeTest {
|
|||
val offsets = mutableListOf<Int>()
|
||||
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||
assertEquals(200, offsets.last())
|
||||
|
|
|
|||
|
|
@ -48,6 +48,21 @@ class LibrarySnapshotStoreTest {
|
|||
assertFalse(reads.last().unreachable)
|
||||
}
|
||||
|
||||
@Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking {
|
||||
var now = 1000L
|
||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||
val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f))
|
||||
store.read("server:justin", "movies") { flowOf(old) }.toList()
|
||||
store.read("server:mindel", "movies") { flowOf(old) }.toList()
|
||||
now = 1002L
|
||||
val updated = listOf(old.single().copy(progress = .6f))
|
||||
val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList()
|
||||
assertEquals(.1f, reads.first().items.single().progress)
|
||||
assertEquals(.6f, reads.last().items.single().progress)
|
||||
assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items)
|
||||
assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items)
|
||||
}
|
||||
|
||||
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ class PersonPickerSessionTest {
|
|||
private fun awaitLoaded(user: JellyfinUser) {
|
||||
try {
|
||||
rule.waitUntil(10_000) {
|
||||
// Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||
models.last().conductarr.identity.value?.signedIn == true &&
|
||||
|
|
|
|||
|
|
@ -214,8 +214,9 @@ class PersonSwitchThreadingTest {
|
|||
every { repository.currentUser } returns user
|
||||
every { repository.currentServer } returns server
|
||||
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||
coEvery { home.readWatch(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
|
||||
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
|
||||
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
|
||||
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
|
||||
every { cache.read(any()) } returns null
|
||||
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||
import java.util.UUID
|
||||
|
||||
/** Checkpoints use the SDK's required identity and watch fields. */
|
||||
internal fun playbackUserData(itemId: UUID, ticks: Long, played: Boolean = false, playCount: Int = 0): UserItemDataDto =
|
||||
UserItemDataDto(playbackPositionTicks = ticks, playCount = playCount, isFavorite = false,
|
||||
lastPlayedDate = null, played = played, key = "", itemId = itemId)
|
||||
|
|
@ -58,7 +58,7 @@ class PlayerHandoffTest {
|
|||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||
val title = snapshot.rows.first().cards.first()
|
||||
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
|
||||
coEvery { facade.preparePlayback(user.id, title) } throws IOException("fixture offline")
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||
|
|
@ -80,14 +80,16 @@ class PlayerHandoffTest {
|
|||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
coEvery { facade.playableId(user.id, title) } returns id
|
||||
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
|
||||
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
|
||||
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
|
||||
val view = shell.view
|
||||
view.cards["hero"] = 0
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
|
||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||
val destination = navigation.backStack.last() as Destination.Playback
|
||||
assertEquals(id, destination.itemId)
|
||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
||||
assertEquals(1_136_274L, destination.positionMs)
|
||||
assertTrue(destination.fullScreen)
|
||||
navigation.goBack()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
|
|
@ -99,11 +101,11 @@ class PlayerHandoffTest {
|
|||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val otherUser = user.copy(name = "Justin")
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, otherUser) }
|
||||
awaitModel { model.toast != null }
|
||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
coVerify(exactly = 0) { facade.playableId(any(), any()) }
|
||||
coVerify(exactly = 0) { facade.preparePlayback(any(), any()) }
|
||||
}
|
||||
private fun awaitModel(condition: () -> Boolean) {
|
||||
rule.waitUntil(5000) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.PeopleStatus
|
||||
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** The actual picker displays every saved login while household revalidation has no response. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class SavedPeoplePickerTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@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<ServerRepository>(relaxed = true)
|
||||
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||
val libraries = mockk<LibraryReadFacade>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey
|
|||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
|
|
@ -62,6 +65,27 @@ class TitleKeysTest {
|
|||
} }
|
||||
}
|
||||
|
||||
@Test fun downFocusesThePlayableLaterSeasonAndScrollsItIntoViewWithoutLosingBrowsingMemory() {
|
||||
render("Andor") { page ->
|
||||
page.copy(item = page.item.copy(seasons = 30), seasonNumbers = (1..30).toList(), selectedSeason = 29,
|
||||
playTarget = page.playTarget!!.copy(season = 29))
|
||||
}
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("seasons", state.row)
|
||||
assertEquals(28, state.column)
|
||||
rule.onNode(hasText("Season 29") and hasClickAction()).assertIsDisplayed()
|
||||
press(Key.DirectionLeft)
|
||||
assertEquals(27, state.column)
|
||||
rule.onNode(hasText("Season 28") and hasClickAction()).assertIsDisplayed()
|
||||
press(Key.DirectionUp)
|
||||
press(Key.DirectionDown)
|
||||
assertEquals(27, state.column)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(28, state.season)
|
||||
assertEquals(28, state.requestedSeason)
|
||||
assertEquals(TitleAction.Season(28), effects.last())
|
||||
}
|
||||
|
||||
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||
private fun replay(end: String): Int {
|
||||
val path = paths.single { it.getString("end") == end }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
/** Production title actions preserve parents and resolve live playback instead of cached offsets. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class TitlePlaybackNavigationTest {
|
||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001")
|
||||
private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
private val person = ShellPerson("justin", "Justin", "J", false)
|
||||
private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
private val series = TitleItem(seriesId.toString(), "Series", "tv", seasons = 5, held = true)
|
||||
private val first = TitleItem(firstId.toString(), "First episode", "episode", held = true, season = 3, episode = 1, resumeSeconds = 12)
|
||||
private val second = first.copy(id = secondId.toString(), name = "Second episode", episode = 2)
|
||||
|
||||
@Test fun repeatedEpisodeChoicesReplaceOnlyEpisodesAndBackPreservesTheirSeriesParent() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val navigation = NavigationManager()
|
||||
val facade = mockk<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(any(), any()) } returns null
|
||||
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
||||
val id = firstArg<UUID>()
|
||||
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<ServerRepository>(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<TitleReadFacade>()
|
||||
coEvery { facade.readSaved(any(), any()) } returns null
|
||||
coEvery { facade.read(any(), any(), any()) } coAnswers {
|
||||
TitlePageData(series, first, watchTarget = if (firstArg<UUID>() == 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<ServerRepository>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ import com.github.damontecres.wholphin.test.TestTracks
|
|||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
|
|
@ -128,7 +130,7 @@ class TitleReadFacadeTest {
|
|||
}
|
||||
|
||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
||||
facade.read(firstId, null, preferences)
|
||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||
val saved = facade.readSaved(firstId, preferences)!!
|
||||
|
|
@ -139,6 +141,7 @@ class TitleReadFacadeTest {
|
|||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||
assertTrue(saved.item.ratings.isEmpty())
|
||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
|
||||
}
|
||||
|
||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||
|
|
@ -218,6 +221,43 @@ class TitleReadFacadeTest {
|
|||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
||||
}
|
||||
|
||||
@Test fun leanEpisodeListsKeepSingleItemPlaybackTracksEvenWithoutNextUp() = runTest {
|
||||
val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES, people = emptyList(), providerIds = mapOf("Tmdb" to "42"))
|
||||
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 3, indexNumber = 1)
|
||||
coEvery { library.getItem(seriesId) } returns successResponse(series)
|
||||
coEvery { library.getItem(firstId) } returns successResponse(episode)
|
||||
coEvery { shows.getNextUp(any<GetNextUpRequest>()) } coAnswers {
|
||||
assertTrue(firstArg<GetNextUpRequest>().fields.orEmpty().isEmpty())
|
||||
successQueryResult(emptyList())
|
||||
}
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||
listOf(BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 3)))
|
||||
coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } coAnswers {
|
||||
val fields = firstArg<GetEpisodesRequest>().fields.orEmpty()
|
||||
assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PEOPLE, ItemFields.PARENT_ID) })
|
||||
successQueryResult(listOf(episode.copy(mediaSources = null, overview = "Episode synopsis")))
|
||||
}
|
||||
val page = facade.read(seriesId, 3, preferences)
|
||||
assertEquals(firstId.toString(), page.playTarget?.id)
|
||||
assertEquals("MKV", page.versions.single().container)
|
||||
assertEquals(listOf(3), page.versions.single().audio.map { it.index })
|
||||
val saved = requireNotNull(facade.readSaved(seriesId, preferences))
|
||||
assertEquals(page.versions, saved.versions)
|
||||
}
|
||||
|
||||
@Test fun performerCreditListsOmitStreamsButKeepCharacterRoles() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(
|
||||
BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor"))
|
||||
coEvery { items.getItems(any<GetItemsRequest>()) } coAnswers {
|
||||
val fields = firstArg<GetItemsRequest>().fields.orEmpty()
|
||||
assertFalse(fields.any { it in listOf(ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.PARENT_ID) })
|
||||
assertTrue(ItemFields.PEOPLE in fields)
|
||||
successQueryResult(listOf(file(secondId).copy(people = listOf(
|
||||
org.jellyfin.sdk.model.api.BaseItemPerson(id = firstId, name = "Actor", role = "Chef")))))
|
||||
}
|
||||
assertEquals("Chef", facade.readPerson(firstId, 0).credits.single().character)
|
||||
}
|
||||
|
||||
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
|
||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
||||
|
|
|
|||
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
135
app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import com.github.damontecres.wholphin.ui.successResponse
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.TvShowsApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import java.util.UUID
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.github.damontecres.wholphin.util.configure
|
||||
import com.github.damontecres.wholphin.util.reset
|
||||
|
||||
/** Recorded Jellyfin watch shapes exercise newer checkpoints and independent Home publication. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class WatchReadTest {
|
||||
private val api = mockk<ApiClient>()
|
||||
private val library = mockk<UserLibraryApi>()
|
||||
private val shows = mockk<TvShowsApi>()
|
||||
private val items = mockk<ItemsApi>()
|
||||
private val images = mockk<ImageUrlService>(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<ServerRepository> {
|
||||
every { currentUser } returns mockk { every { id } returns userId }
|
||||
}
|
||||
val title = TitleReadFacade(api, images, mockk(), repository, mockk(), mockk<Context>())
|
||||
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<Unit>()
|
||||
coEvery { facade.readLibrary(userId) } coAnswers { releaseLibrary.await(); throw java.io.IOException("Library failed") }
|
||||
val cache = mockk<HomeLibraryCache>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** A still-composed Home must paint the checkpoint acknowledged after its first return read. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class WatchReturnTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@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<HomeReadFacade>()
|
||||
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<HomeLibraryCache>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||
import org.justinvisser.wolfskin.conductarr.CacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
|
||||
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||
object ConductarrPreference {
|
||||
|
|
@ -54,8 +55,8 @@ object ConductarrPreference {
|
|||
object ConductarrModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
|
||||
ConductarrSession(ConductarrDiskCache(context), scope)
|
||||
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
|
||||
ConductarrSession(ConductarrDiskCache(context), scope, dns)
|
||||
}
|
||||
|
||||
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ data class ConductarrIdentity(
|
|||
class ConductarrSession(
|
||||
private val cache: CacheStore,
|
||||
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
private val dns: PersistentDns? = null,
|
||||
) : Closeable {
|
||||
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||
|
|
@ -50,7 +51,7 @@ class ConductarrSession(
|
|||
}
|
||||
try {
|
||||
val client = if (token.isNullOrBlank()) null else try {
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
|
||||
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event ->
|
||||
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||
mutableIdentity.update {
|
||||
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ import java.util.Date
|
|||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||
|
|
@ -50,9 +52,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||
catalogueJob?.cancel()
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||
withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,11 +76,12 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
toast = null
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||
}
|
||||
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
val saved = withContext(WholphinDispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
if (revision != loadRevision) return
|
||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||
var libraryAvailable = true
|
||||
var librarySavedAt: String? = null
|
||||
var watchReady = false
|
||||
val identity = conductarr.identity.value
|
||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||
fun publish() {
|
||||
|
|
@ -89,8 +92,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||
}
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||
}
|
||||
catalogueSnapshot = catalogueCopy
|
||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||
|
|
@ -107,20 +111,43 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
libraryCopy = library
|
||||
publish()
|
||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } }
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision != loadRevision) return
|
||||
libraryAvailable = false
|
||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||
publish()
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
suspend fun save() {
|
||||
val library = libraryCopy
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
||||
}
|
||||
}
|
||||
coroutineScope {
|
||||
launch {
|
||||
try {
|
||||
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
||||
if (revision != loadRevision) return@launch
|
||||
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
||||
watchReady = true
|
||||
libraryAvailable = true
|
||||
publish()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision != loadRevision) return@launch
|
||||
libraryAvailable = false
|
||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||
publish()
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
try {
|
||||
val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) }
|
||||
if (revision != loadRevision) return@launch
|
||||
libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals)
|
||||
publish()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (revision == loadRevision) save()
|
||||
}
|
||||
|
||||
suspend fun readAhead(lane: String?) {
|
||||
|
|
@ -138,6 +165,7 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
val personKey = shell.person.key
|
||||
val selectedId = shell.person.jellyfinUserId
|
||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||
|
|
@ -146,9 +174,9 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
return@launch
|
||||
}
|
||||
try {
|
||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
|
|
@ -204,7 +232,8 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
|||
@Composable
|
||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||
val identity by model.conductarrState.collectAsState()
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
|
||||
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||
import com.github.damontecres.wholphin.data.MostRecentServerProvider
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||
|
||||
/** Process warm-up never blocks application creation or serializes the two service lookups. */
|
||||
@Singleton
|
||||
class NetworkStartup @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val recent: Provider<MostRecentServerProvider>,
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import org.justinvisser.wolfskin.design.TitleReadStatus
|
|||
import org.justinvisser.wolfskin.design.TitleState
|
||||
import org.justinvisser.wolfskin.design.TitleSurface
|
||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
private val saveMutex = Mutex()
|
||||
private var performerPage = 1
|
||||
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||
|
||||
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||
fun popRoute() = navigation.goBack()
|
||||
|
|
@ -87,6 +89,16 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||
if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
||||
if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key &&
|
||||
title?.data?.status == TitleReadStatus.Ready && title?.data?.item?.held == true &&
|
||||
(title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) &&
|
||||
!destination.startsWith("person/") && fixture == null) {
|
||||
loadJob?.cancel()
|
||||
preferences = userPreferences
|
||||
title?.let { it.update(it.data.copy(watchReady = false)) }
|
||||
loadJob = viewModelScope.launch { readTitle(Uri.decode(destination.substringAfter('/'))) }
|
||||
return
|
||||
}
|
||||
route = destination
|
||||
loadJob?.cancel()
|
||||
person = null
|
||||
|
|
@ -132,7 +144,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
if (title !== current) return
|
||||
if (heldId != null) {
|
||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
||||
held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
||||
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||
current.update(held)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
|
|
@ -223,13 +235,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
when (action) {
|
||||
TitleAction.Pop -> navigation.goBack()
|
||||
is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id)))
|
||||
is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
||||
is TitleAction.OpenTitle -> {
|
||||
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
||||
if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination)
|
||||
else navigation.navigateTo(destination)
|
||||
}
|
||||
is TitleAction.Play -> {
|
||||
val opened = title?.data?.watchTarget
|
||||
play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(),
|
||||
restart = action.restart)
|
||||
}
|
||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true)
|
||||
else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer."
|
||||
is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||
is TitleAction.Season -> {
|
||||
|
|
@ -261,12 +281,15 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun play(id: UUID?, position: Long) {
|
||||
private fun play(id: UUID?, restart: Boolean) {
|
||||
val current = title
|
||||
val playbackScope = watchScope
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||
facade.preparePlayback(id)
|
||||
navigation.navigateTo(Destination.Playback(id, position))
|
||||
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||
if (title !== current || watchScope != playbackScope) return@launch
|
||||
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||
|
|
@ -308,7 +331,8 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
@Composable
|
||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
||||
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||
val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null
|
||||
LaunchedEffect(title?.data?.status) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.github.damontecres.wholphin.util.PlaybackWatchState
|
||||
|
||||
/** Re-read on entry, foreground and successful stop, even when a screen remains composed. */
|
||||
@Composable
|
||||
internal fun rememberWatchRefresh(scope: String?): Pair<Long, Int> {
|
||||
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
|
||||
}
|
||||
|
|
@ -72,7 +72,8 @@ fun WolfskinLibraryContent(
|
|||
ShellPlaceholder("See all list unavailable", modifier)
|
||||
return
|
||||
}
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
||||
val watchRefresh = rememberWatchRefresh(cacheScope)
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null, watchRefresh) {
|
||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||
val wasLoading = grid.status == LibraryStatus.Loading
|
||||
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||
|
|
@ -82,7 +83,7 @@ fun WolfskinLibraryContent(
|
|||
grid.status = LibraryStatus.Ready
|
||||
} else {
|
||||
val store = withContext(Dispatchers.IO) { snapshots.value }
|
||||
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||
val focusedId = grid.focusedTitle?.id
|
||||
grid.source = snapshot.items
|
||||
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
|||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||
val audiences get() = libraries.audiences
|
||||
suspend fun read(userId: UUID): HomeLibrary {
|
||||
/** Watch rows are publishable without waiting for any library shelf. */
|
||||
suspend fun readWatch(userId: UUID): HomeLibrary {
|
||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||
|
|
@ -35,6 +36,11 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
}
|
||||
val resume = resumeItems.map { title(it, series) }
|
||||
val next = nextItems.map { title(it, series) }
|
||||
return HomeLibrary(resume = resume, nextUp = next)
|
||||
}
|
||||
|
||||
/** Library failures do not discard an already successful watch read. */
|
||||
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||
val newest = mutableListOf<HomeTitle>()
|
||||
|
|
@ -46,23 +52,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
||||
}
|
||||
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
||||
return HomeLibrary(newest = newest.sortedByDescending { it.addedAt }.take(12), destinations = destinations, destinationTotals = totals)
|
||||
}
|
||||
|
||||
suspend fun playableId(userId: UUID, title: HomeTitle): UUID? {
|
||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
||||
if (item.type == BaseItemKind.SERIES) {
|
||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
||||
}
|
||||
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
||||
}
|
||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||
}?.id
|
||||
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
||||
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
||||
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||
api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||
}?.id
|
||||
} ?: return null
|
||||
return readPlayback(api, userId, id)
|
||||
}
|
||||
|
||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
|||
val response = api.itemsApi.getItems(
|
||||
userId = userId, parentId = library.id, recursive = true,
|
||||
includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
|
||||
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset,
|
||||
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
||||
).content
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
|
|||
|
||||
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
||||
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
||||
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||
fun read(scope: String, destination: String, force: Boolean = false, invalidatedAtMillis: Long = 0L, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
|
||||
val key = "library:$destination"
|
||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
||||
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||
if (!force && saved != null && titles != null && saved.validatedAtMillis > invalidatedAtMillis && now() - saved.validatedAtMillis < lifetime) return@flow
|
||||
var current: List<LibraryTitle> = emptyList()
|
||||
try {
|
||||
load().collect { chunk ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.util.UUID
|
||||
|
||||
/** A click resolves its target and checkpoint from Jellyfin, never from a painted projection. */
|
||||
internal suspend fun readPlayback(api: ApiClient, userId: UUID?, id: UUID): BaseItemDto? {
|
||||
val opened = api.userLibraryApi.getItem(itemId = id, userId = userId).content
|
||||
val target = if (opened.type == BaseItemKind.SERIES) {
|
||||
api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1, enableResumable = true, enableUserData = true)
|
||||
.content.items.firstOrNull()?.id
|
||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1, enableUserData = true).content.items.firstOrNull()?.id
|
||||
} else opened.id
|
||||
val item = if (target == opened.id) opened else target?.let { api.userLibraryApi.getItem(itemId = it, userId = userId).content }
|
||||
return item?.takeIf { it.type == BaseItemKind.EPISODE || it.type == BaseItemKind.MOVIE }
|
||||
}
|
||||
|
||||
/** Preserve subsecond server checkpoints, including resumable rewatches marked Played. */
|
||||
internal fun BaseItemDto.playbackPositionMs(): Long = (userData?.playbackPositionTicks ?: 0L) / 10_000L
|
||||
|
|
@ -53,7 +53,8 @@ class TitleReadFacade @Inject constructor(
|
|||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID)
|
||||
private val episodeFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW)
|
||||
private val creditFields = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.PEOPLE)
|
||||
|
||||
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
|
||||
suspend fun findHeld(tmdb: Long, media: String): UUID? {
|
||||
|
|
@ -77,7 +78,7 @@ class TitleReadFacade @Inject constructor(
|
|||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
||||
build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences)
|
||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() })
|
||||
.copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }, watchReady = false)
|
||||
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||
}
|
||||
|
||||
|
|
@ -88,12 +89,12 @@ class TitleReadFacade @Inject constructor(
|
|||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
|
||||
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
|
||||
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
|
||||
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, enableResumable = true)).content.items.firstOrNull()?.let { readItem(it.id) }
|
||||
}
|
||||
val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
|
||||
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
|
||||
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty()
|
||||
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }
|
||||
val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = episodeFields)).content.items.orEmpty() }.orEmpty()
|
||||
val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE }?.let { readItem(it.id) }
|
||||
val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
||||
val numbers = seasons.mapNotNull { it.indexNumber }
|
||||
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
||||
|
|
@ -122,8 +123,9 @@ class TitleReadFacade @Inject constructor(
|
|||
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||
}
|
||||
|
||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
||||
/** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */
|
||||
suspend fun preparePlayback(id: UUID): BaseItemDto? =
|
||||
readPlayback(api, repository.currentUser?.id, id)
|
||||
|
||||
/** Track selection uses the same repository consumed by the upstream player. */
|
||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||
|
|
@ -164,7 +166,7 @@ class TitleReadFacade @Inject constructor(
|
|||
} else emptyList()
|
||||
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
||||
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = creditFields, startIndex = start, limit = 200,
|
||||
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
|
||||
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
||||
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class ConductarrClient(
|
|||
timeouts: ClientTimeouts = ClientTimeouts(),
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
private val onEvent: (ClientEvent) -> Unit = {},
|
||||
dns: PersistentDns? = null,
|
||||
) : Closeable {
|
||||
private val base = baseUrl.toHttpUrl()
|
||||
private val scope: String
|
||||
|
|
@ -74,6 +75,7 @@ class ConductarrClient(
|
|||
private val cacheIdentityLock = Any()
|
||||
private val catalogueReads = Semaphore(2)
|
||||
private val http = OkHttpClient.Builder()
|
||||
.apply { if (dns != null) DnsConnectionRetry.configure(this, dns) }
|
||||
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
|
||||
.callTimeout(timeouts.callSeconds, TimeUnit.SECONDS)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import okhttp3.Call
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
|
||||
/** Retry once with fresh DNS only if connection establishment failed before any HTTP headers were sent. */
|
||||
class DnsConnectionRetry(private val dns: PersistentDns) : Interceptor, EventListener.Factory {
|
||||
private class Attempt {
|
||||
@Volatile var connectionFailed = false
|
||||
@Volatile var headersSent = false
|
||||
}
|
||||
private val attempts = ConcurrentHashMap<Call, Attempt>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.encodeToString
|
||||
import okhttp3.Dns
|
||||
|
||||
/** Last-known system answers stay usable for one day while a single background lookup refreshes them. */
|
||||
class PersistentDns(
|
||||
private val file: () -> File,
|
||||
private val resolver: Dns = Dns.SYSTEM,
|
||||
private val executor: Executor = Executors.newFixedThreadPool(2) { task -> Thread(task, "wolfskin-dns").apply { isDaemon = true } },
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
) : Dns {
|
||||
private class Host {
|
||||
@Volatile var answer: Answer? = null
|
||||
@Volatile var refreshAfter = 0L
|
||||
var refreshing = false
|
||||
val resolutionLock = Any()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class Answer(
|
||||
val addresses: List<String>,
|
||||
val savedAtMillis: Long,
|
||||
@Transient val resolvedAddresses: List<InetAddress> = decodeAddresses(addresses),
|
||||
)
|
||||
private val hosts = ConcurrentHashMap<String, Host>()
|
||||
private val diskLock = Any()
|
||||
@Volatile private var loaded = false
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
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<InetAddress> {
|
||||
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<InetAddress> {
|
||||
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<Map<String, Answer>>(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<String>): List<InetAddress> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.Executor
|
||||
import okhttp3.Dns
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
|
@ -14,13 +18,24 @@ fun main() = runBlocking {
|
|||
try {
|
||||
MockWebServer().use { server ->
|
||||
server.start()
|
||||
val baseUrl = server.url("/").toString()
|
||||
val baseUrl = server.url("/").newBuilder().host("localhost").build().toString()
|
||||
var resolutions = 0
|
||||
val dead = InetAddress.getByName("127.0.0.2")
|
||||
val live = InetAddress.getByName("127.0.0.1")
|
||||
val dns = PersistentDns({ File(directory, "dns.json") }, Dns {
|
||||
resolutions++
|
||||
if (resolutions == 1) listOf(dead) else listOf(live)
|
||||
}, Executor { it.run() })
|
||||
check(dns.lookup("localhost") == listOf(dead))
|
||||
server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build())
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = dns).use { client ->
|
||||
val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value
|
||||
check(live.saved.value!!.profiles!!.single().name == "Justin")
|
||||
check(resolutions == 2)
|
||||
println("DNS SMOKE: cached 127.0.0.2 refused -> fresh 127.0.0.1 -> profile Justin; requests=${server.requestCount}, resolutions=$resolutions")
|
||||
}
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client ->
|
||||
val reopened = PersistentDns({ File(directory, "dns.json") }, Dns { listOf(live) }, Executor { it.run() })
|
||||
ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory), dns = reopened).use { client ->
|
||||
val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value
|
||||
check(saved.saved.fromDisk)
|
||||
server.enqueue(MockResponse.Builder().code(304).build())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.Dns
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/** OkHttp's listener-free WebSocket client must retain a working upgrade and message path. */
|
||||
class DnsConnectionRetryTest {
|
||||
@get:Rule val directory = TemporaryFolder()
|
||||
|
||||
@Test fun derivedClientOpensWebSocketWithoutAnUncaughtDispatcherException() {
|
||||
val opened = CompletableFuture<Int>()
|
||||
val message = CompletableFuture<String>()
|
||||
val uncaught = AtomicReference<Throwable?>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.conductarr
|
||||
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import mockwebserver3.MockResponse
|
||||
import mockwebserver3.MockWebServer
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dns
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
/** Fake time and queued refreshes prove stale answers, disk hints and dead-address recovery. */
|
||||
class PersistentDnsTest {
|
||||
@get:Rule val directory = TemporaryFolder()
|
||||
private var now = 1_000_000L
|
||||
private val queued = ArrayDeque<Runnable>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
docker/README.md
112
docker/README.md
|
|
@ -27,6 +27,64 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
|||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||
task without changing upstream's test configuration.
|
||||
|
||||
### Full-suite test diagnostics
|
||||
|
||||
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
|
||||
from the forked test-worker heap. The default test worker uses 512 MiB; changing
|
||||
the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve
|
||||
the person-picker fixture's missing Compose snapshot notifications.
|
||||
|
||||
Gradle 9.8.2 runs previously failed test classes first on the next invocation.
|
||||
An immediate green `make test` rerun can therefore hide an order-sensitive
|
||||
failure rather than prove a fix. For comparisons, clear only generated binary
|
||||
test-result history (`build/test-results/<task>/binary` in each module) before
|
||||
each run and record actual class-start order; XML file listing order is not
|
||||
execution order. Keep worker heap and parallelism unchanged between samples.
|
||||
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
|
||||
or promoted to the front by previous failures.
|
||||
|
||||
## Saved-person launch and DNS
|
||||
|
||||
Launch restores an unprotected saved Jellyfin login from local storage before
|
||||
network validation. The existing Wolfskin picker paints the saved names and
|
||||
identity-derived colors, then refreshes the household in the background. PIN and
|
||||
require-login profiles still go through the existing protected sign-in flow.
|
||||
`Users/Me` and `System/Info/Public` now start concurrently; a late launch response
|
||||
cannot replace a different selected person. Name revalidation updates only the
|
||||
name column and reloads the saved user, preserving newer settings and PINs.
|
||||
|
||||
Jellyfin, Conductarr, artwork, playback and crash-report clients share one
|
||||
`PersistentDns` instance through the application graph. It keeps system-ordered
|
||||
IPv6/IPv4 answers in memory and `noBackupFilesDir/wolfskin-dns.json` (at most 32 hosts).
|
||||
Saved answers return immediately for up to 24 hours and refresh off main after
|
||||
60 seconds; failed background lookups back off for 2 seconds without extending
|
||||
the last successful timestamp. Unexpected resolver exceptions are contained at
|
||||
the background refresh and warm-up boundaries. Negative answers are not persisted or cached.
|
||||
Missing, oversized or corrupt hint files fall back to system resolution.
|
||||
Application creation warms the saved Jellyfin host and configured Conductarr
|
||||
host on parallel IO jobs; it does not change Android Private DNS.
|
||||
|
||||
A connection-establishment failure before any HTTP request headers were sent
|
||||
gets one fresh system lookup and one retry. Responses, read failures and
|
||||
uncertain mutation outcomes are not replayed. Conductarr retains its existing
|
||||
no-automatic-replay policy after a request starts. A DNS refresh changes the next
|
||||
new connection; already-working pooled connections are not forcibly closed.
|
||||
OkHttp's derived WebSocket clients disable event listeners; those calls bypass
|
||||
the HTTP retry bookkeeping while retaining the shared DNS resolver.
|
||||
|
||||
Device acceptance (not established by JVM tests): with saved people present,
|
||||
cold-launch six times more than 60 seconds apart and require the picker within
|
||||
0.5 seconds on every launch. Press Left/Right then one OK; the selected Home must
|
||||
open without a second picker. Open the avatar picker, change person with one OK,
|
||||
and repeat after an offline launch to confirm saved people still paint.
|
||||
|
||||
Compare timestamps of the launch pair of SDK log lines
|
||||
`GET https://jelly.justinvisser.org/Users/Me` and
|
||||
`GET https://jelly.justinvisser.org/System/Info/Public` (ignore later foreground
|
||||
`Users/Me` refreshes). The spike's two slow launch gaps were 1.508 and 1.509
|
||||
seconds. The requests now overlap, so either may log first; the picker must not
|
||||
wait for either response. The minified SDK log tag is not stable across builds.
|
||||
|
||||
## Optimized side-by-side comparison
|
||||
|
||||
The `optimized` build type inherits `release`: R8 optimization/minification,
|
||||
|
|
@ -315,16 +373,29 @@ an untouched fresh entry focuses hero button zero; intervening nav input prevent
|
|||
that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus`
|
||||
release/repeat guard and Back ladder.
|
||||
|
||||
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
||||
variants read the selected user's Resume, Next up, newest titles and destination
|
||||
shelves, with app-private last-known-good projections through the shared atomic
|
||||
disk cache. A missing saved library becomes a recovery panel; a saved library
|
||||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||
episode and checks Jellyfin before opening upstream's player. No season,
|
||||
recommendation stub or different person's session is promoted to playback.
|
||||
`HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up
|
||||
publish independently of slow or failed library shelves; saved metadata still
|
||||
paints immediately, but a saved hero offers Details until watch data revalidates.
|
||||
Home and title Play actions resolve the current Jellyfin item and its current
|
||||
checkpoint at the click, preserving millisecond positions. Series actions use
|
||||
live NextUp with resumable episodes enabled; an explicitly opened episode stays
|
||||
that episode, and Restart deliberately starts that displayed episode at zero.
|
||||
Played flags do not discard a positive checkpoint for an intentional rewatch.
|
||||
No season, recommendation stub or different person's session becomes playable.
|
||||
An unresolved person switch guards Home and both owned/upstream library content
|
||||
before mounting any source read with the prior person's Jellyfin session.
|
||||
|
||||
Home and title watch reads refresh on entry and foreground. A successful
|
||||
PlaybackStopped acknowledgement publishes a revision for the captured server/user,
|
||||
so a return read that beat the stop write is refreshed again after that write.
|
||||
The acknowledgement also removes that user's persisted library grid snapshots
|
||||
instead of waiting for their ten-minute TTL; an in-memory revision bypasses any
|
||||
older snapshot that raced invalidation. Stop-report failures remain logged and
|
||||
do not publish a successful acknowledgement. External players refresh after a
|
||||
supported stop result; an external app that returns no position cannot supply a
|
||||
missing checkpoint. Another client's changes after the final read are outside
|
||||
that click's snapshot.
|
||||
|
||||
Debug variants additionally package the locked fixture and local artwork under
|
||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||
The explicit fixture provider supplies approved household data and Home states
|
||||
|
|
@ -557,15 +628,29 @@ retain the approved frames except for the requested feedback changes:
|
|||
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
||||
gap. Home heading geometry is unchanged.
|
||||
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
||||
420×630. The shared request builder removes conflicting Jellyfin size caps,
|
||||
preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB
|
||||
memory cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
||||
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
||||
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
||||
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
||||
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||
authentication query parameters remain intact. TMDB uses w342 for posters and
|
||||
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
||||
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
||||
decoder support and requests JPEG if WebP is unavailable. The 64 MiB memory
|
||||
cache, 128 MiB disk cache and four image-read limit remain unchanged.
|
||||
|
||||
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
|
||||
directions, first focus and native Back from the actual title surface. Native
|
||||
proof records real Bear season-three metadata through the title read facade;
|
||||
the proof-only harness is not part of the shipped tests or APK.
|
||||
|
||||
Episode lists request only ProviderIds and Overview. Next up reads only identity,
|
||||
then hydrates the selected playback target through the existing single-item read,
|
||||
including when Next up is empty. Media sources/streams and cast remain on detail
|
||||
reads for header track/version controls. Performer credits omit media sources,
|
||||
streams and ParentId but retain People because their cards show character roles.
|
||||
Movies/Shows grids retain ProviderIds and DateCreated; server-side SortName
|
||||
ordering does not require returning SortName on every item.
|
||||
|
||||
|
||||
## Title pages and people
|
||||
|
||||
|
|
@ -586,6 +671,13 @@ Selections persist through the same playback repository as the upstream player.
|
|||
Marking a whole series asks first; an explicit episode remains the exact play and
|
||||
watched target even while another season is selected. Nested person/title visits
|
||||
retain their entry's focus and selection state.
|
||||
Choosing another episode from an episode page replaces only the top navigation
|
||||
entry, preserving its series/Home parent. Choosing the first episode from a
|
||||
series, related titles, cast and performer credits still push normal drill-ins.
|
||||
Down into season chips initially focuses the selected playable season, including
|
||||
offscreen later seasons; the row scrolls that chip into view and remembers
|
||||
subsequent deliberate browsing. Saved title watch data cannot enable Play/Resume
|
||||
or Restart before source revalidation.
|
||||
Hardware and remote Back share the sheet/picker/request-overlay precedence, then
|
||||
pop a pushed title/person from content **or** nav. Initial loading has no content
|
||||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class HomeFocus(
|
|||
private fun activate(row: HomeRow) {
|
||||
when (row.kind) {
|
||||
HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title ->
|
||||
onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title))
|
||||
onAction(if (column == 0 && row.heroKind != "pick" && row.watchReady) HomeAction.Play(title) else HomeAction.Details(title))
|
||||
}
|
||||
HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row))
|
||||
HomeRowKind.State -> onAction(HomeAction.Retry)
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
|
|||
Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
||||
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
||||
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -67,9 +67,10 @@ data class HomeRow(
|
|||
val skeleton: Boolean = false,
|
||||
val calm: Boolean = false,
|
||||
val allCards: List<HomeTitle> = cards,
|
||||
val watchReady: Boolean = true,
|
||||
) {
|
||||
val hasSeeAll: Boolean get() = total > cards.size
|
||||
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
||||
val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick" || !watchReady) 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 }
|
||||
val focusable: Boolean get() = !skeleton && count > 0
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +85,7 @@ data class HomeSnapshot(
|
|||
|
||||
/** Home emits integration intents, never owns the upstream player or later-slice pages. */
|
||||
sealed interface HomeAction {
|
||||
data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction
|
||||
data class Play(val title: HomeTitle) : HomeAction
|
||||
data class Details(val title: HomeTitle) : HomeAction
|
||||
data class SeeAll(val row: HomeRow) : HomeAction
|
||||
data object Discover : HomeAction
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ data class TitlePageData(
|
|||
val episodesStatus: TitleReadStatus = TitleReadStatus.Ready,
|
||||
val requestsAllowed: Boolean = true,
|
||||
val requestFor: String? = null,
|
||||
val watchReady: Boolean = true,
|
||||
)
|
||||
|
||||
/** Effects are handled by the app's authenticated facade and navigation layer. */
|
||||
|
|
@ -101,7 +102,7 @@ sealed interface TitleAction {
|
|||
data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction
|
||||
data class Trailer(val url: String) : TitleAction
|
||||
data class Person(val person: TitlePerson) : TitleAction
|
||||
data class OpenTitle(val item: TitleItem) : TitleAction
|
||||
data class OpenTitle(val item: TitleItem, val episodeSelection: Boolean = false) : TitleAction
|
||||
data class Season(val number: Int) : TitleAction
|
||||
data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction
|
||||
data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction
|
||||
|
|
|
|||
|
|
@ -182,7 +182,12 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
|||
@Composable
|
||||
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
||||
if (visibleChip >= 0) list.animateScrollToItem(visibleChip)
|
||||
}
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index ->
|
||||
val number = state.data.seasonNumbers[index]
|
||||
val focused = active && state.column == index
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class TitleState(
|
|||
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private var armedCard: String? = null
|
||||
private var seasonChosen = false
|
||||
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
|
||||
private var okHeld = false
|
||||
val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held }
|
||||
val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull()
|
||||
|
|
@ -56,7 +58,7 @@ class TitleState(
|
|||
val pendingAudio = audioIndex != data.selectedAudio
|
||||
val pendingSubtitle = subtitleIndex != data.selectedSubtitle
|
||||
data = value
|
||||
if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
if (wasLoading || (!seasonChosen && !columns.containsKey("seasons")) || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1
|
||||
if (!pendingWatched) watched = value.watchTarget.watched
|
||||
if (!pendingVersion) versionId = value.selectedVersion
|
||||
if (!pendingAudio) audioIndex = value.selectedAudio
|
||||
|
|
@ -152,7 +154,8 @@ class TitleState(
|
|||
val next = rows[index]
|
||||
val x = centers[row]?.get(column)
|
||||
val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null
|
||||
focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
val selected = if (next == "seasons") data.seasonNumbers.indexOf(season).takeIf { it >= 0 } else null
|
||||
focus(next, (columns[next] ?: selected ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
private fun rowSize(id: String): Int = when (id) {
|
||||
|
|
@ -168,15 +171,15 @@ class TitleState(
|
|||
when (row) {
|
||||
"cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) }
|
||||
"related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
||||
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) }
|
||||
"seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
|
||||
"episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it, episodeSelection = true)) }
|
||||
"seasons" -> { seasonChosen = true; season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) }
|
||||
"header" -> options.getOrNull(column)?.let { activateOption(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun activateOption(id: String) {
|
||||
when (id) {
|
||||
"play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
||||
|
|
@ -260,7 +263,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
|||
return@buildList
|
||||
}
|
||||
if (item.held) {
|
||||
if (target != null) {
|
||||
if (!data.watchReady) add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
if (target != null && data.watchReady) {
|
||||
val verb = if (target.resumeSeconds > 0) "Resume" else "Play"
|
||||
add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false))
|
||||
if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.net.Uri
|
||||
import coil3.ComponentRegistry
|
||||
import coil3.ImageLoader
|
||||
|
|
@ -23,6 +25,8 @@ object WolfskinImages {
|
|||
private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null
|
||||
private const val MEBIBYTE = 1024L * 1024L
|
||||
private const val IMAGE_READS = 4
|
||||
private const val CARD_QUALITY = "80"
|
||||
private const val HERO_QUALITY = "96"
|
||||
private val readPermits = Semaphore(IMAGE_READS)
|
||||
|
||||
/** The app supplies authenticated networking without exposing its server/session to design code. */
|
||||
|
|
@ -78,32 +82,34 @@ object WolfskinImages {
|
|||
|
||||
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
|
||||
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
|
||||
private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight")
|
||||
private val imageParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight", "quality", "format")
|
||||
// Lossy WebP is mandatory from API 14; minSdk is 23. Newer platforms expose a decoder capability query.
|
||||
private val supportsWebp by lazy { Build.VERSION.SDK_INT < 28 || ImageDecoder.isMimeTypeSupported("image/webp") }
|
||||
|
||||
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
|
||||
if (source !is String) return source
|
||||
val uri = Uri.parse(source)
|
||||
if (uri.scheme != "https" && uri.scheme != "http") return source
|
||||
if (uri.host == "image.tmdb.org") {
|
||||
val size = if (height > width) when {
|
||||
width <= 500 -> "w500"
|
||||
width <= 780 -> "w780"
|
||||
else -> "original"
|
||||
} else when {
|
||||
width <= 780 -> "w780"
|
||||
width <= 1280 -> "w1280"
|
||||
else -> "original"
|
||||
val size = when {
|
||||
height >= width -> "w342"
|
||||
width > WolfskinTokens.Image.wideRequestWidth -> "w1280"
|
||||
else -> "w780"
|
||||
}
|
||||
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
|
||||
}
|
||||
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
|
||||
val hero = width == WolfskinTokens.Image.heroRequestWidth && height == WolfskinTokens.Image.heroRequestHeight ||
|
||||
width == WolfskinTokens.Image.heroPosterRequestWidth && height == WolfskinTokens.Image.heroPosterRequestHeight
|
||||
val builder = uri.buildUpon().clearQuery()
|
||||
for (name in uri.queryParameterNames) {
|
||||
if (name.lowercase() !in dimensionParameters) {
|
||||
if (name.lowercase() !in imageParameters) {
|
||||
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
|
||||
}
|
||||
}
|
||||
return builder.appendQueryParameter("fillWidth", width.toString())
|
||||
.appendQueryParameter("fillHeight", height.toString()).build().toString()
|
||||
.appendQueryParameter("fillHeight", height.toString())
|
||||
.appendQueryParameter("format", if (hero || !supportsWebp) "Jpg" else "Webp")
|
||||
.appendQueryParameter("quality", if (hero) HERO_QUALITY else CARD_QUALITY).build().toString()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,10 +220,10 @@ object WolfskinTokens {
|
|||
object Image {
|
||||
const val posterRequestWidth = 256
|
||||
const val posterRequestHeight = 384
|
||||
const val tileRequestWidth = 416
|
||||
const val tileRequestHeight = 234
|
||||
const val wideRequestWidth = 560
|
||||
const val wideRequestHeight = 315
|
||||
const val tileRequestWidth = 400
|
||||
const val tileRequestHeight = 225
|
||||
const val wideRequestWidth = 544
|
||||
const val wideRequestHeight = 306
|
||||
const val heroRequestWidth = 1920
|
||||
const val heroRequestHeight = 1080
|
||||
const val heroPosterRequestWidth = 420
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ class HomeKeysTest {
|
|||
for (key in 0 until keys.length()) press(keys.getString(key))
|
||||
assertEquals(path.getString("goal"), path.getInt("presses"), keys.length())
|
||||
when (index) {
|
||||
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) }
|
||||
1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
|
||||
0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play) }
|
||||
1 -> { assertTrue(action is HomeAction.Play); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) }
|
||||
2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id)
|
||||
3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) }
|
||||
4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) }
|
||||
|
|
@ -170,7 +170,6 @@ class HomeKeysTest {
|
|||
assertEquals(0, shell.view.cards["hero"])
|
||||
press("OK")
|
||||
val play = action as HomeAction.Play
|
||||
assertFalse(play.resume)
|
||||
assertEquals("Yu Yu Hakusho", play.title.seriesTitle)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Card URLs bound server work and transfer size, while hero quality and cache identity stay intact. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class WolfskinImagesTest {
|
||||
private data class Surface(val name: String, val width: Int, val height: Int, val tier: String, val hero: Boolean = false)
|
||||
private val surfaces = listOf(
|
||||
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
|
||||
Surface("Home tile", 400, 225, "w780"),
|
||||
Surface("Home wide", 544, 306, "w780"),
|
||||
Surface("episode still", 400, 280, "w780"),
|
||||
Surface("cast portrait", 200, 200, "w342"),
|
||||
Surface("person portrait", 240, 240, "w342"),
|
||||
Surface("hero poster", 420, 630, "w342", true),
|
||||
Surface("Home/Discover/title hero", 1920, 1080, "w1280", true),
|
||||
)
|
||||
|
||||
private fun request(source: Any, surface: Surface): Any = WolfskinImages.request(
|
||||
RuntimeEnvironment.getApplication(), source, surface.width, surface.height, surface.height > surface.width).data
|
||||
|
||||
@Test fun jellyfinSurfacesReplaceStaleDimensionsAndEncodingWithoutLosingIdentity() {
|
||||
for (surface in surfaces) {
|
||||
val source = "https://jelly.example/Items/123/Images/Backdrop/0?tag=stable&api_key=kept&MaxWidth=1280&height=720&FillHeight=720&Quality=96&Format=Png"
|
||||
val rewritten = request(source, surface).toString()
|
||||
val uri = Uri.parse(rewritten)
|
||||
assertEquals(surface.name, surface.width.toString(), uri.getQueryParameter("fillWidth"))
|
||||
assertEquals(surface.name, surface.height.toString(), uri.getQueryParameter("fillHeight"))
|
||||
assertEquals(surface.name, if (surface.hero) "Jpg" else "Webp", uri.getQueryParameter("format"))
|
||||
assertEquals(surface.name, if (surface.hero) "96" else "80", uri.getQueryParameter("quality"))
|
||||
assertEquals("stable", uri.getQueryParameter("tag"))
|
||||
assertEquals("kept", uri.getQueryParameter("api_key"))
|
||||
assertEquals(setOf("tag", "api_key", "fillWidth", "fillHeight", "format", "quality"), uri.queryParameterNames)
|
||||
assertEquals(rewritten, request(rewritten, surface))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun tmdbSurfacesNeverDownloadOriginalArtwork() {
|
||||
for (surface in surfaces) {
|
||||
for (oldTier in listOf("original", "w500", "w780")) {
|
||||
assertEquals(surface.name, "https://image.tmdb.org/t/p/${surface.tier}/art.jpg?identity=kept",
|
||||
request("https://image.tmdb.org/t/p/$oldTier/art.jpg?identity=kept", surface))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun localAndUnrelatedSourcesRemainUntouched() {
|
||||
for (source in listOf("file:///android_asset/poster.webp", "https://cdn.example/poster.jpg?width=800", "https://jelly.example/Users/123/Images/Primary", Uri.parse("content://art/1"))) {
|
||||
assertEquals(source, request(source, surfaces.first()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1492,8 +1492,8 @@
|
|||
},
|
||||
"tileRequest": {
|
||||
"px": [
|
||||
416,
|
||||
234
|
||||
400,
|
||||
225
|
||||
],
|
||||
"decode": "ARGB_8888",
|
||||
"source": "index.html:549",
|
||||
|
|
@ -1501,8 +1501,8 @@
|
|||
},
|
||||
"wideRequest": {
|
||||
"px": [
|
||||
560,
|
||||
315
|
||||
544,
|
||||
306
|
||||
],
|
||||
"decode": "ARGB_8888",
|
||||
"source": "index.html:401",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
|
|||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||
bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
580ed1710468ccbd97082afaa445e96ae71510ebace711cc735ea389ecb9249d src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
|
||||
36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
|
||||
3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json
|
||||
226f4bd07c0b105f8640beff71fe2c0c95d0f8862e624cea511384a4591b44ad tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue