Compare commits
54 commits
fm/tv-slic
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fd48426fa7 | |||
| 052d0b8898 | |||
| acb72335e3 | |||
| 53a2cff2c8 | |||
| 327f631f0d | |||
| e340d470c1 | |||
| f4fb6e22c9 | |||
| 5bd2affc1a | |||
| 8e40ac0869 | |||
| 54c23e076a | |||
| 12fd12f754 | |||
| 7b88f9b54b | |||
| 5162c299db | |||
| 9d2471c5b1 | |||
| 278335f009 | |||
| deb24e3c9f | |||
| 556d633e88 | |||
| 34087d8eb5 | |||
| 33dc65bf18 | |||
| ef3ae3f21b | |||
| d0e38306b3 | |||
| 5306f8ddc4 | |||
| 59f4a06815 | |||
| 427b18cdc5 | |||
| 93a41ea2d3 | |||
| 1c21f50acb | |||
| e8d0c52088 | |||
| db2541feff | |||
| f37ad0f0fe | |||
| 92c5661be0 | |||
| b9ea593735 | |||
| 711dbebf28 | |||
| 6e0f9952cf | |||
| c17447c528 | |||
| 9e15dbee8c | |||
| 53b8358317 | |||
| 5a84666074 | |||
| 22850632ec | |||
| afff383b75 | |||
| d401ebc355 | |||
| e794dec253 | |||
| 4e6377b628 | |||
| e9a92eba49 | |||
| 81073c410d | |||
| 04b1bdd6aa | |||
| bfe3dbbb16 | |||
| ec32346c71 | |||
| 34ea983a51 | |||
| 09f1d72f30 | |||
| 78322f588a | |||
| a00db96dc8 | |||
| 71a055987c | |||
| 59c2728d9c | |||
| f53defad90 |
182 changed files with 19728 additions and 817 deletions
|
|
@ -82,6 +82,7 @@ configure<ApplicationExtension> {
|
||||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||||
|
|
||||||
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
||||||
|
buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -132,6 +133,13 @@ configure<ApplicationExtension> {
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
create("optimized") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
applicationIdSuffix = ".opt"
|
||||||
|
matchingFallbacks += "release"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
flavorDimensions += "version"
|
flavorDimensions += "version"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
|
|
@ -198,6 +206,10 @@ configure<ApplicationExtension> {
|
||||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
kotlin.directories += "src/wolfskin/java"
|
kotlin.directories += "src/wolfskin/java"
|
||||||
}
|
}
|
||||||
|
getByName("optimized") {
|
||||||
|
kotlin.directories += "src/release/java"
|
||||||
|
baselineProfiles.directories += "src/optimized/baselineProfiles"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
|
|
@ -227,6 +239,11 @@ androidComponents {
|
||||||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||||
output.outputFileName = outputFileName
|
output.outputFileName = outputFileName
|
||||||
}
|
}
|
||||||
|
if (variant.name == "defaultOptimized") {
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
if (variant.name == "defaultDebug") {
|
if (variant.name == "defaultDebug") {
|
||||||
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
|
||||||
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
|
||||||
|
|
@ -448,6 +465,7 @@ dependencies {
|
||||||
testImplementation(libs.mockk.android)
|
testImplementation(libs.mockk.android)
|
||||||
testImplementation(libs.mockk.agent)
|
testImplementation(libs.mockk.agent)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
||||||
testImplementation(libs.androidx.core.testing)
|
testImplementation(libs.androidx.core.testing)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ import org.justinvisser.wolfskin.design.HomeFixture
|
||||||
import org.justinvisser.wolfskin.design.HomeSnapshot
|
import org.justinvisser.wolfskin.design.HomeSnapshot
|
||||||
|
|
||||||
/** Only debug builds may read the locked prototype household projection. */
|
/** Only debug builds may read the locked prototype household projection. */
|
||||||
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)
|
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? =
|
||||||
|
if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,7 @@
|
||||||
<string name="display_preset_default">Wolfskin Default</string>
|
<string name="display_preset_default">Wolfskin Default</string>
|
||||||
<string name="display_preset_compact">Wolfskin Compact</string>
|
<string name="display_preset_compact">Wolfskin Compact</string>
|
||||||
<string name="updated_toast">Wolfskin updated to %s</string>
|
<string name="updated_toast">Wolfskin updated to %s</string>
|
||||||
|
<string name="wolfskin_conductarr_url">Conductarr server URL</string>
|
||||||
|
<string name="wolfskin_conductarr_url_summary">Advanced override. HTTPS required; blank restores the build default.</string>
|
||||||
|
<string name="wolfskin_fixtures">Wolfskin preview fixtures (debug only)</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
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.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
|
|
@ -576,12 +577,22 @@ class MainActivityViewModel
|
||||||
val restoredSession =
|
val restoredSession =
|
||||||
if (signInAutomatically) {
|
if (signInAutomatically) {
|
||||||
Timber.v("Sign in automatically")
|
Timber.v("Sign in automatically")
|
||||||
serverRepository.restoreLastSession()
|
serverRepository.restoreSavedSession()
|
||||||
} else {
|
} else {
|
||||||
serverRepository.getMostRecentServer()
|
serverRepository.getMostRecentServer()
|
||||||
}
|
}
|
||||||
Timber.d("Restore last session result=%s", restoredSession)
|
Timber.d("Restore last session result=%s", restoredSession)
|
||||||
setupNavigationManager.navigateTo(restoredSession.destination)
|
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.Jellyfin
|
||||||
import org.jellyfin.sdk.model.ServerVersion
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.tv.NetworkStartup
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeRuntimeApi::class)
|
@OptIn(ExperimentalComposeRuntimeApi::class)
|
||||||
|
|
@ -54,6 +55,7 @@ class WholphinApplication :
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
networkStartup.start()
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy
|
StrictMode.ThreadPolicy
|
||||||
|
|
@ -110,6 +112,9 @@ class WholphinApplication :
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var networkStartup: NetworkStartup
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() =
|
get() =
|
||||||
Configuration
|
Configuration
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ interface JellyfinServerDao {
|
||||||
return user.copy(rowId = result.toInt())
|
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")
|
@Query("SELECT * FROM users WHERE serverId = :serverId AND id = :userId")
|
||||||
fun getUser(
|
fun getUser(
|
||||||
serverId: UUID,
|
serverId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -101,15 +103,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
Timber.v("Changing user to ${user.name} on ${server.url}")
|
Timber.v("Changing user to ${user.name} on ${server.url}")
|
||||||
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
|
||||||
val userDto by apiClient.userApi.getCurrentUser()
|
val (updatedServer, userDto) = readSession(server)
|
||||||
val updatedServer =
|
|
||||||
try {
|
|
||||||
val sysInfo by apiClient.systemApi.getPublicSystemInfo()
|
|
||||||
server.copy(name = sysInfo.serverName, version = sysInfo.version)
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.w(ex, "Exception fetching public system info")
|
|
||||||
server
|
|
||||||
}
|
|
||||||
var updatedUser =
|
var updatedUser =
|
||||||
user.copy(
|
user.copy(
|
||||||
id = userDto.id,
|
id = userDto.id,
|
||||||
|
|
@ -125,6 +119,45 @@ class ServerRepository
|
||||||
return@withContext currentUser
|
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
|
* Try to restore the most recent session
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,31 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ConductarrUrl =
|
||||||
|
AppStringPreference<AppPreferences>(
|
||||||
|
title = R.string.wolfskin_conductarr_url,
|
||||||
|
defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL,
|
||||||
|
getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value)
|
||||||
|
prefs
|
||||||
|
},
|
||||||
|
summary = R.string.wolfskin_conductarr_url_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
val WolfskinFixtures =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.wolfskin_fixtures,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value)
|
||||||
|
prefs
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val UserProfileSettings =
|
val UserProfileSettings =
|
||||||
AppDestinationPreference<AppPreferences>(
|
AppDestinationPreference<AppPreferences>(
|
||||||
title = R.string.override_user_profile_settings,
|
title = R.string.override_user_profile_settings,
|
||||||
|
|
@ -1320,6 +1345,8 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.WolfskinScreens,
|
AppPreference.WolfskinScreens,
|
||||||
|
AppPreference.ConductarrUrl,
|
||||||
|
*if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(),
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
AppPreference.BackdropStylePref,
|
AppPreference.BackdropStylePref,
|
||||||
AppPreference.ShowLogos,
|
AppPreference.ShowLogos,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ class NavigationManager
|
||||||
log()
|
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
|
* 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.createJellyfin
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
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.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
@ -91,12 +94,14 @@ object AppModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient() =
|
fun okHttpClient(dns: PersistentDns) =
|
||||||
OkHttpClient
|
DnsConnectionRetry.configure(OkHttpClient.Builder(), dns).build()
|
||||||
.Builder()
|
|
||||||
.apply {
|
/** Resolve noBackupFilesDir lazily: Android may create it and lookup always runs off main. */
|
||||||
// TODO user agent, timeouts, logging, etc
|
@Provides
|
||||||
}.build()
|
@Singleton
|
||||||
|
fun dns(@ApplicationContext context: Context): PersistentDns =
|
||||||
|
PersistentDns(file = { File(context.noBackupFilesDir, "wolfskin-dns.json") })
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ class PlayExternalViewModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
val launched = savedStateHandle.getMutableStateFlow("launched", false)
|
||||||
val state = MutableStateFlow(PlayExternalState())
|
val state = MutableStateFlow(PlayExternalState())
|
||||||
|
private var watchScope: String? = null
|
||||||
|
|
||||||
fun init(destination: Destination) {
|
fun init(destination: Destination) {
|
||||||
Timber.v("init called: %s", destination)
|
Timber.v("init called: %s", destination)
|
||||||
|
watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||||
state.update { it.copy(loading = LoadingState.Loading) }
|
state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val prefs = userPreferencesService.getCurrent()
|
val prefs = userPreferencesService.getCurrent()
|
||||||
|
|
@ -358,6 +360,7 @@ class PlayExternalViewModel
|
||||||
failed = false,
|
failed = false,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
watchScope?.let { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, it) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
|
|
|
||||||
|
|
@ -830,10 +830,12 @@ class PlaybackViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
val playbackItemState = PlaybackItemState(playback)
|
val playbackItemState = PlaybackItemState(playback)
|
||||||
|
val watchScope = "${serverRepository.currentServer?.id}:${serverRepository.currentUser?.id}"
|
||||||
val activityListener =
|
val activityListener =
|
||||||
TrackActivityPlaybackListener(
|
TrackActivityPlaybackListener(
|
||||||
api = api,
|
api = api,
|
||||||
player = player,
|
player = player,
|
||||||
|
onStopped = { com.github.damontecres.wholphin.util.PlaybackWatchState.stopped(context, watchScope) },
|
||||||
getState = { playbackItemState },
|
getState = { playbackItemState },
|
||||||
)
|
)
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ import android.content.Context
|
||||||
import com.github.damontecres.wholphin.data.MostRecentServer
|
import com.github.damontecres.wholphin.data.MostRecentServer
|
||||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||||
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
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 com.google.auto.service.AutoService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -73,10 +78,7 @@ class CrashReportSender : ReportSender {
|
||||||
|
|
||||||
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||||
(this as? MostRecentServer.ServerAndUser)?.let {
|
(this as? MostRecentServer.ServerAndUser)?.let {
|
||||||
val okHttpClient =
|
val okHttpClient = EntryPointAccessors.fromApplication(context.applicationContext, CrashNetwork::class.java).client()
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
|
||||||
.build()
|
|
||||||
val okHttpFactory = OkHttpFactory(okHttpClient)
|
val okHttpFactory = OkHttpFactory(okHttpClient)
|
||||||
val api =
|
val api =
|
||||||
createJellyfin {
|
createJellyfin {
|
||||||
|
|
@ -89,3 +91,11 @@ private fun MostRecentServer.createApi(context: Context): ApiClient? =
|
||||||
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
}.createApi(baseUrl = serverUrl, accessToken = accessToken)
|
||||||
api
|
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 com.github.damontecres.wholphin.ui.playback.CurrentPlayback
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
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.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
|
@ -31,9 +33,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
class TrackActivityPlaybackListener(
|
class TrackActivityPlaybackListener(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val player: Player,
|
private val player: Player,
|
||||||
|
private val onStopped: () -> Unit = {},
|
||||||
private val getState: () -> PlaybackItemState?,
|
private val getState: () -> PlaybackItemState?,
|
||||||
) : Player.Listener {
|
) : Player.Listener {
|
||||||
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
private val coroutineScope = CoroutineScope(WholphinDispatchers.Main)
|
||||||
|
private val reports = Mutex()
|
||||||
|
@Volatile private var released = false
|
||||||
private val task: TimerTask =
|
private val task: TimerTask =
|
||||||
object : TimerTask() {
|
object : TimerTask() {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
|
|
@ -70,19 +75,23 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
val delay = 5.seconds.inWholeMilliseconds
|
val delay = 5.seconds.inWholeMilliseconds
|
||||||
// Every x seconds, check if the video is playing
|
// Every x seconds, check if the video is playing
|
||||||
TIMER.schedule(task, delay, delay)
|
if (!released) {
|
||||||
initialized = true
|
TIMER.schedule(task, delay, delay)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
// player.removeListener(this)
|
if (released) return
|
||||||
|
released = true
|
||||||
task.cancel()
|
task.cancel()
|
||||||
TIMER.purge()
|
TIMER.purge()
|
||||||
val position = player.currentPosition.milliseconds
|
val position = player.currentPosition.milliseconds
|
||||||
|
val stoppedState = getState.invoke()
|
||||||
launch("reportPlaybackStopped") {
|
launch("reportPlaybackStopped") {
|
||||||
getState.invoke()?.let { state ->
|
stoppedState?.let { state ->
|
||||||
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
Timber.v("reportPlaybackStopped for ${state.itemId} at $position")
|
||||||
if (position < Duration.ZERO) {
|
if (position < Duration.ZERO) {
|
||||||
Timber.w("Negative position when reporting playback stopped: %s", position)
|
Timber.w("Negative position when reporting playback stopped: %s", position)
|
||||||
|
|
@ -96,6 +105,7 @@ class TrackActivityPlaybackListener(
|
||||||
liveStreamId = state.liveStreamId,
|
liveStreamId = state.liveStreamId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
onStopped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +127,7 @@ class TrackActivityPlaybackListener(
|
||||||
|
|
||||||
private fun saveActivity(position: Long) {
|
private fun saveActivity(position: Long) {
|
||||||
launch("saveActivity") {
|
launch("saveActivity") {
|
||||||
|
if (released) return@launch
|
||||||
getState.invoke()?.let { state ->
|
getState.invoke()?.let { state ->
|
||||||
val calcPosition =
|
val calcPosition =
|
||||||
withContext(WholphinDispatchers.Main) {
|
withContext(WholphinDispatchers.Main) {
|
||||||
|
|
@ -152,7 +163,7 @@ class TrackActivityPlaybackListener(
|
||||||
) {
|
) {
|
||||||
coroutineScope.launchIO {
|
coroutineScope.launchIO {
|
||||||
try {
|
try {
|
||||||
block.invoke(this)
|
reports.withLock { block.invoke(this) }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.w(ex, "Exception during %s", name)
|
Timber.w(ex, "Exception during %s", name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
8
app/src/optimized/baselineProfiles/baseline-prof.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles.
|
||||||
|
Lcom/github/damontecres/wholphin/WholphinApplication;
|
||||||
|
HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V
|
||||||
|
Lcom/github/damontecres/wholphin/MainActivity;
|
||||||
|
HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V
|
||||||
|
Lorg/justinvisser/wolfskin/tv/WolfskinAppKt;
|
||||||
|
Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference;
|
||||||
|
Lorg/justinvisser/wolfskin/tv/ConductarrPreference;
|
||||||
4
app/src/optimized/res/values/strings.xml
Normal file
4
app/src/optimized/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -32,6 +32,8 @@ import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import io.mockk.unmockkStatic
|
import io.mockk.unmockkStatic
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
@ -116,9 +118,6 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
|
|
||||||
// coEvery { serverRepository.restoreLastSession() } returns
|
|
||||||
// RestoredSession.Success(currentUser)
|
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
|
|
@ -132,7 +131,7 @@ class TestMainActivityViewModel {
|
||||||
destination as SetupDestination.AppContent
|
destination as SetupDestination.AppContent
|
||||||
Assert.assertEquals(currentUser, destination.current)
|
Assert.assertEquals(currentUser, destination.current)
|
||||||
}
|
}
|
||||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +149,7 @@ class TestMainActivityViewModel {
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
|
||||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
|
||||||
val args = mutableListOf<SetupDestination>()
|
val args = mutableListOf<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(args)) }
|
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
|
@Test
|
||||||
fun `Test auto sign in, hot load, with protected profile`() =
|
fun `Test auto sign in, hot load, with protected profile`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
@ -178,7 +191,7 @@ class TestMainActivityViewModel {
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
} returns null
|
} returns null
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
@ -204,7 +217,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.ServerOnly(server)
|
RestoredSession.ServerOnly(server)
|
||||||
coEvery {
|
coEvery {
|
||||||
serverRepository.tryChangeUser(serverId, userId)
|
serverRepository.tryChangeUser(serverId, userId)
|
||||||
|
|
@ -212,7 +225,7 @@ class TestMainActivityViewModel {
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||||
val slot = slot<SetupDestination>()
|
val slot = slot<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
slot.captured.let { destination ->
|
slot.captured.let { destination ->
|
||||||
|
|
@ -232,11 +245,11 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
|
coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.None
|
||||||
|
|
||||||
viewModel.handleAppStart()
|
viewModel.handleAppStart()
|
||||||
|
|
||||||
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 1) { serverRepository.restoreSavedSession() }
|
||||||
val slot = slot<SetupDestination>()
|
val slot = slot<SetupDestination>()
|
||||||
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
verify { setupNavigationManager.navigateTo(capture(slot)) }
|
||||||
slot.captured.let { destination ->
|
slot.captured.let { destination ->
|
||||||
|
|
@ -254,7 +267,7 @@ class TestMainActivityViewModel {
|
||||||
signInAutomatically = true
|
signInAutomatically = true
|
||||||
}
|
}
|
||||||
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
every { serverRepository.current } returns MutableStateFlow(currentUser)
|
||||||
coEvery { serverRepository.restoreLastSession() } returns
|
coEvery { serverRepository.restoreSavedSession() } returns
|
||||||
RestoredSession.Success(currentUser)
|
RestoredSession.Success(currentUser)
|
||||||
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
|
||||||
|
|
||||||
|
|
@ -271,7 +284,7 @@ class TestMainActivityViewModel {
|
||||||
// destination as SetupDestination.AppContent
|
// destination as SetupDestination.AppContent
|
||||||
// Assert.assertEquals(currentUser, destination.current)
|
// Assert.assertEquals(currentUser, destination.current)
|
||||||
// }
|
// }
|
||||||
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
|
coVerify(exactly = 0) { serverRepository.restoreSavedSession() }
|
||||||
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.test
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.util.Version
|
import com.github.damontecres.wholphin.util.Version
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -18,6 +19,12 @@ class VersionCompareTests {
|
||||||
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
|
||||||
|
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
|
||||||
|
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testIsAtLeast() {
|
fun testIsAtLeast() {
|
||||||
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -91,12 +92,11 @@ object TestModule {
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun okHttpClient() =
|
fun okHttpClient(dns: PersistentDns) = AppModule.okHttpClient(dns)
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
@Provides
|
||||||
.apply {
|
@Singleton
|
||||||
// TODO user agent, timeouts, logging, etc
|
fun dns(@ApplicationContext context: Context): PersistentDns = AppModule.dns(context)
|
||||||
}.build()
|
|
||||||
|
|
||||||
@AuthOkHttpClient
|
@AuthOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ class PlayExternalViewModelTest {
|
||||||
every { api.subtitleApi } returns subtitleApi
|
every { api.subtitleApi } returns subtitleApi
|
||||||
every { api.playStateApi } returns playStateApi
|
every { api.playStateApi } returns playStateApi
|
||||||
every { serverRepository.currentUser } returns null
|
every { serverRepository.currentUser } returns null
|
||||||
|
every { serverRepository.currentServer } returns null
|
||||||
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
|
||||||
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
|
||||||
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.ReadinessStage
|
||||||
|
import org.justinvisser.wolfskin.design.ComingInStage
|
||||||
|
import org.justinvisser.wolfskin.design.ShellPerson
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.ActivityFeed
|
||||||
|
|
||||||
|
/** Recorded history separates acquisition, visibility proof, entitlement and file health. */
|
||||||
|
class ActivityTruthProjectionTest {
|
||||||
|
private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>(
|
||||||
|
javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id }
|
||||||
|
@Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() {
|
||||||
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
|
val expected = mapOf(
|
||||||
|
"request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof.
|
||||||
|
"request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof.
|
||||||
|
"dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition.
|
||||||
|
"dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere.
|
||||||
|
"dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor.
|
||||||
|
"dispatch-723" to "Removed", // A historical play is not a current holding.
|
||||||
|
"dispatch-728" to "Not out yet", // The Body: unreleased.
|
||||||
|
"dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding.
|
||||||
|
"request-8" to "Waiting for a file", // Partition: no active transfer is established.
|
||||||
|
"dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed.
|
||||||
|
)
|
||||||
|
assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer))
|
||||||
|
expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) }
|
||||||
|
listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id ->
|
||||||
|
val source = rows.getValue(id)
|
||||||
|
val projected = source.comingIn()
|
||||||
|
val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED }
|
||||||
|
assertEquals(affected.summary, projected.summary)
|
||||||
|
assertFalse(projected.readiness[1] == "Failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() {
|
||||||
|
val row = rows.getValue("dispatch-721")
|
||||||
|
val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable."
|
||||||
|
val projected = row.copy(stages = row.stages.orEmpty().map {
|
||||||
|
when (it.name) {
|
||||||
|
ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary)
|
||||||
|
ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE)
|
||||||
|
else -> it
|
||||||
|
}
|
||||||
|
}).comingIn()
|
||||||
|
assertEquals(ComingInStage.FileHealth, projected.stage)
|
||||||
|
assertEquals(summary, projected.summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() {
|
||||||
|
val row = rows.getValue("request-8")
|
||||||
|
val projected = row.copy(stages = row.stages.orEmpty().map {
|
||||||
|
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it
|
||||||
|
}).comingIn()
|
||||||
|
assertEquals(ComingInStage.Waiting, projected.stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() {
|
||||||
|
val row = rows.getValue("dispatch-725")
|
||||||
|
val absent = row.copy(stages = row.stages.orEmpty().map {
|
||||||
|
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it
|
||||||
|
})
|
||||||
|
assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage)
|
||||||
|
val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } }
|
||||||
|
assertEquals(ComingInStage.Ready, ready.comingIn().stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class AnimeAvailabilityTest {
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||||
|
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
|
||||||
|
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
|
||||||
|
conductarr.select("${server.id}:${user.id}", token, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
val identity = conductarr.identity.value!!
|
||||||
|
val audiences = identity.provider.audiences()!!.filter {
|
||||||
|
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
|
||||||
|
}
|
||||||
|
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
|
||||||
|
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
|
||||||
|
val views = mockk<UserViewsApi>()
|
||||||
|
val items = mockk<ItemsApi>()
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
every { api.userViewsApi } returns views
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
|
||||||
|
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
|
||||||
|
})
|
||||||
|
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
|
||||||
|
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
|
||||||
|
val libraries = LibraryReadFacade(api)
|
||||||
|
val session = WolfskinSession().apply {
|
||||||
|
state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply {
|
||||||
|
pickerVisible = false
|
||||||
|
focusNav(tabs.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
try {
|
||||||
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||||
|
assertEquals("The avatar must remain focused when Anime appears or disappears",
|
||||||
|
session.state!!.tabs.size, session.state!!.navIndex)
|
||||||
|
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
|
||||||
|
destinationTotals = mapOf("anime" to count))
|
||||||
|
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
|
||||||
|
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class AnimeRootRemovalTest {
|
||||||
|
private var previousFixtures = false
|
||||||
|
private var previousUrl = ""
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||||
|
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking {
|
||||||
|
val title = Destination.Wolfskin("title/tv%3A123")
|
||||||
|
val person = Destination.Wolfskin("person/456")
|
||||||
|
val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true,
|
||||||
|
listOf(title) to false, listOf(title, person) to false)
|
||||||
|
for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns null
|
||||||
|
var hasTitles = true
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
|
coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles }
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
try {
|
||||||
|
model.load(server, user)
|
||||||
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
val shell = requireNotNull(session.state)
|
||||||
|
assertTrue(shell.person.hasAnime)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusNav(shell.tabs.indexOf("anime"))
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList())
|
||||||
|
for (page in pushed) {
|
||||||
|
navigation.navigateTo(page)
|
||||||
|
shell.enterDestination(page.route)
|
||||||
|
}
|
||||||
|
if (avatarFocused) shell.focusNav(shell.tabs.size)
|
||||||
|
|
||||||
|
hasTitles = false
|
||||||
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs)
|
||||||
|
assertEquals("Home must replace the missing root, not the current pushed page",
|
||||||
|
listOf(Destination.Home()) + pushed, navigation.backStack.toList())
|
||||||
|
assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route)
|
||||||
|
assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab)
|
||||||
|
if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex)
|
||||||
|
|
||||||
|
shell.key(ShellKey.Back)
|
||||||
|
if (pushed.isEmpty()) {
|
||||||
|
assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
|
||||||
|
if (!avatarFocused) assertEquals(0, shell.navIndex)
|
||||||
|
} else {
|
||||||
|
assertEquals("Back pops only the current page and retains the replacement Home root",
|
||||||
|
listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList())
|
||||||
|
}
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class ConductarrAudienceRecoveryTest {
|
||||||
|
@Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
|
||||||
|
@After fun restoreMain() { Dispatchers.resetMain() }
|
||||||
|
|
||||||
|
@Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
for (empty in listOf(false, true)) FakeConductarr().use { fake ->
|
||||||
|
fake.audiencesStatus = if (empty) 200 else 503
|
||||||
|
fake.emptyAudiences = empty
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
assertTrue(session.identity.value!!.signedIn)
|
||||||
|
val client = session.identity.value!!.client
|
||||||
|
val libraries = LibraryReadFacade(mockk())
|
||||||
|
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||||
|
try {
|
||||||
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
assertNull(libraries.audiences)
|
||||||
|
assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES))
|
||||||
|
assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS))
|
||||||
|
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
|
||||||
|
if (!empty) {
|
||||||
|
fake.audiencesStatus = 200
|
||||||
|
requireNotNull(model.onForeground(server, user)).join()
|
||||||
|
assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries)
|
||||||
|
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
|
||||||
|
assertSame(client, session.identity.value!!.client)
|
||||||
|
}
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previousUrl = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
val libraries = LibraryReadFacade(mockk())
|
||||||
|
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||||
|
try {
|
||||||
|
val identity = session.identity.value!!
|
||||||
|
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||||
|
org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared")
|
||||||
|
fake.postStatus = 503
|
||||||
|
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
|
||||||
|
identity.provider.operations(identity.operator!!).request(intent).phase)
|
||||||
|
model.retryConductarr(server, user)
|
||||||
|
val refreshed = session.identity.value!!
|
||||||
|
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
|
||||||
|
refreshed.provider.operations(refreshed.operator!!).request(intent).phase)
|
||||||
|
assertEquals(1, fake.calls.count { it.method == "POST" })
|
||||||
|
} finally {
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
|
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ConductarrHomeSurfaceTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun currentIdentityFeedsRealHomeAndSavedRequestsWithoutInventingJellyfinPlayback() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.localArt = true
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fake", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val home = mockk<HomeReadFacade>()
|
||||||
|
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)
|
||||||
|
try {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
|
||||||
|
rule.setContent { WolfskinTheme {
|
||||||
|
LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) }
|
||||||
|
val snapshot = model.snapshot
|
||||||
|
CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) {
|
||||||
|
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
rule.waitUntil(timeoutMillis = 30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } }
|
||||||
|
}
|
||||||
|
rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" }
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodesWithText("Moon")[0].assertIsDisplayed()
|
||||||
|
assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held })
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-home-real.png").absolutePath)
|
||||||
|
val provider = session.identity.value!!.provider
|
||||||
|
runBlocking { provider.requests().last() }
|
||||||
|
fake.status = 503
|
||||||
|
val saved = runBlocking { provider.requests().last() }
|
||||||
|
assertNotNull(saved.savedAt)
|
||||||
|
assertEquals(ComingInStage.Downloading, saved.items.single().stage)
|
||||||
|
assertEquals("Shared", saved.items.single().actor(shell.person))
|
||||||
|
rule.runOnIdle { shell.requests.update(saved); shell.openRequests() }
|
||||||
|
rule.onAllNodesWithText("Moon")[0].assertIsDisplayed()
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-requests-saved.png").absolutePath)
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
/** Consumer-visible mappings exercise realistic wire responses over a local HTTP server. */
|
||||||
|
class ConductarrProjectionTest {
|
||||||
|
@Test fun householdFeedAndCatalogueBecomePersonalRowsWithoutRawLibraryNamesOrHomeComingSoon() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
|
val provider = ConductarrProvider(client)
|
||||||
|
assertEquals("Shared", provider.profiles()!!.profiles!!.first().name)
|
||||||
|
assertEquals(listOf("Shared Anime"), provider.audiences()!!.single().libraries)
|
||||||
|
val snapshot = provider.discover("shared").last()
|
||||||
|
assertEquals(DiscoverStatus.Ready, snapshot.status)
|
||||||
|
assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster)
|
||||||
|
assertEquals("Solaris", snapshot.seeds.single().title)
|
||||||
|
assertEquals(listOf(42L), snapshot.seeds.single().items.map { it.tmdb })
|
||||||
|
assertEquals(listOf("New on Netflix", "Trending"), snapshot.lanes.take(2).map { it.title })
|
||||||
|
assertTrue(snapshot.lanes.first { it.id == "genre-Comedy" }.pending)
|
||||||
|
assertEquals(99L, snapshot.hidden.single().tmdb)
|
||||||
|
assertEquals(8L, snapshot.services.single().id)
|
||||||
|
val rows = snapshot.homeRows()
|
||||||
|
assertEquals("Moon", rows.first { it.id == "pk-movie" }.cards.single().title)
|
||||||
|
assertFalse(rows.any { "Coming soon" in it.title || "Shared Anime" in it.title })
|
||||||
|
assertTrue(fake.calls.filter { it.url.encodedPath == "/catalogue/discover" }.all { it.url.queryParameter("genre_id") == null })
|
||||||
|
val expanded = provider.lanesThrough(99)
|
||||||
|
assertFalse(expanded.any { it.pending })
|
||||||
|
assertTrue(fake.calls.any { it.url.queryParameter("genre_id") == "35" })
|
||||||
|
assertEquals("Coming soon", expanded.last().title)
|
||||||
|
val service = provider.service(snapshot.services.single(), "tv")
|
||||||
|
assertEquals(listOf("Popular series", "New series", "Top rated series"), service.take(3).map { it.title })
|
||||||
|
assertTrue(fake.calls.filter { it.url.queryParameter("service_id") == "8" }.any { it.url.queryParameter("media_type") == "tv" })
|
||||||
|
val requests = provider.requests().last()
|
||||||
|
val request = requests.items.single()
|
||||||
|
assertEquals(ComingInStage.Downloading, request.stage)
|
||||||
|
assertEquals("Mindel", request.requesterName)
|
||||||
|
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun uncertainRequestIsLookedUpRatherThanSentTwiceAndSharedRemainsTheAuthenticatedActor() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
|
val operations = ConductarrDiscoverOperations(client, { DiscoverSnapshot() }, profile = "shared", allowedRequesters = setOf("shared", "mindel"))
|
||||||
|
val intent = DiscoverRequest(DiscoverTitle(42, "movie", "Moon"), "mindel", "untrusted-ui-profile")
|
||||||
|
fake.postStatus = 503
|
||||||
|
assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase)
|
||||||
|
assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase)
|
||||||
|
assertEquals(1, fake.calls.count { it.method == "POST" })
|
||||||
|
val post = fake.calls.single { it.method == "POST" }
|
||||||
|
val body = post.body!!.utf8()
|
||||||
|
assertTrue(body.contains("\"requester\":\"mindel\""))
|
||||||
|
assertTrue(body.contains("\"recommendation_profile\":\"shared\""))
|
||||||
|
assertFalse(body.contains("actor"))
|
||||||
|
assertEquals(listOf("GET", "POST", "GET"), fake.calls.map { it.method })
|
||||||
|
fake.existingRequests = true
|
||||||
|
val receipt = operations.check(intent)
|
||||||
|
assertEquals(RequestPhase.Receipt, receipt.phase)
|
||||||
|
assertEquals("Shared", receipt.actor)
|
||||||
|
assertEquals("mindel", receipt.requester)
|
||||||
|
assertEquals(RequestPhase.Refused, operations.request(intent.copy(requester = "outsider")).phase)
|
||||||
|
assertEquals(RequestPhase.Refused, operations.request(intent.copy(title = DiscoverTitle(44, "tv", "Dark"))).phase)
|
||||||
|
assertEquals(1, fake.calls.count { it.method == "POST" })
|
||||||
|
assertTrue(operations.hide("untrusted-ui-profile", intent.title))
|
||||||
|
assertTrue(operations.restore("untrusted-ui-profile", intent.title))
|
||||||
|
assertTrue(fake.calls.takeLast(2).all { "/profiles/shared/dismissals" in it.url.encodedPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun episodeVotesReplaceSeriesScoreAndConditionalTitleReadsKeepTheOriginalBody() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
|
val ratings = ConductarrRatings(client)
|
||||||
|
assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source })
|
||||||
|
val episode = ratings.read(44, "episode", 1, 1)!!
|
||||||
|
assertEquals(listOf("rtCritics", "rtAudience", "tmdb"), episode.map { it.source })
|
||||||
|
assertTrue(episode.last().episode)
|
||||||
|
assertEquals(8.1, episode.last().score, 0.0)
|
||||||
|
ratings.read(44, "episode", 1, 2)
|
||||||
|
assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") })
|
||||||
|
assertFalse(fake.calls.any { it.url.encodedPath.endsWith("/enrichment") })
|
||||||
|
val provider: ConductarrTitleProvider = ConductarrProvider(client)
|
||||||
|
val title = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved
|
||||||
|
fake.notModified = true
|
||||||
|
val revalidated = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved
|
||||||
|
assertEquals(title.value, revalidated.value)
|
||||||
|
assertEquals(title.savedAtMillis, revalidated.savedAtMillis)
|
||||||
|
assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"])
|
||||||
|
fake.notModified = false
|
||||||
|
fake.ratingsStatus = 401
|
||||||
|
assertEquals(emptyList<LibraryRating>(), ratings.read(45, "movie", null, null))
|
||||||
|
assertEquals(emptyList<LibraryRating>(), ratings.read(42, "movie", null, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
/** Real sockets and reopened disk stores prove identity and authentication boundaries. */
|
||||||
|
class ConductarrSessionTest {
|
||||||
|
@Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking {
|
||||||
|
val directory = Files.createTempDirectory("tv-session-").toFile()
|
||||||
|
try {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(FileCacheStore(directory)).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
val first = session.identity.value!!
|
||||||
|
assertEquals("shared", first.operator!!.requester)
|
||||||
|
assertEquals("Moon", first.provider.discover("shared").last().picks.single().title)
|
||||||
|
session.select("server:mindel", MINDEL_TOKEN, fake.url)
|
||||||
|
val second = session.identity.value!!
|
||||||
|
assertNotSame(first.client, second.client)
|
||||||
|
assertFalse(second.signedIn)
|
||||||
|
assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty())
|
||||||
|
session.authenticate()
|
||||||
|
assertEquals("Mindel", session.identity.value!!.operator!!.name)
|
||||||
|
val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" }
|
||||||
|
assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"])
|
||||||
|
session.select("server:other", SHARED_TOKEN, fake.url)
|
||||||
|
assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester)
|
||||||
|
session.select("server:shared", null, fake.url)
|
||||||
|
assertNull(session.identity.value)
|
||||||
|
}
|
||||||
|
fake.calls.forEach { request ->
|
||||||
|
assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\""))
|
||||||
|
listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) }
|
||||||
|
assertFalse(request.url.toString().contains(SHARED_TOKEN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) })
|
||||||
|
} finally { directory.deleteRecursively() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking {
|
||||||
|
val directory = Files.createTempDirectory("tv-offline-").toFile()
|
||||||
|
try {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(FileCacheStore(directory)).use { original ->
|
||||||
|
original.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
original.authenticate()
|
||||||
|
original.identity.value!!.provider.discover("shared").toList()
|
||||||
|
}
|
||||||
|
ConductarrSession(FileCacheStore(directory)).use { reopened ->
|
||||||
|
reopened.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
fake.status = 503
|
||||||
|
reopened.authenticate()
|
||||||
|
assertTrue(reopened.identity.value!!.signedIn)
|
||||||
|
val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList()
|
||||||
|
assertEquals("Moon", snapshots.first().picks.single().title)
|
||||||
|
assertEquals("Moon", snapshots.last().picks.single().title)
|
||||||
|
assertNotNull(snapshots.last().savedAt)
|
||||||
|
fake.status = 200
|
||||||
|
fake.ratingsStatus = 401
|
||||||
|
val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null)
|
||||||
|
assertTrue(ratings!!.isEmpty())
|
||||||
|
assertFalse(reopened.identity.value!!.signedIn)
|
||||||
|
assertNull(reopened.identity.value!!.operator)
|
||||||
|
assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error)
|
||||||
|
assertEquals(DiscoverStatus.NeedsSignIn, reopened.identity.value!!.provider.discover("shared").last().status)
|
||||||
|
fake.ratingsStatus = 200
|
||||||
|
reopened.select("server:shared", SHARED_TOKEN, fake.url, retry = true)
|
||||||
|
reopened.authenticate()
|
||||||
|
assertTrue(reopened.identity.value!!.signedIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { directory.deleteRecursively() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** The shipped host reads a local server and draws real data, dwell ratings and 401 recovery. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ConductarrSurfaceTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("discover")
|
||||||
|
}
|
||||||
|
lateinit var current: DiscoverState
|
||||||
|
val art = ImageBitmap(1, 1)
|
||||||
|
rule.setContent {
|
||||||
|
val identity by session.identity.collectAsState()
|
||||||
|
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false)
|
||||||
|
current = state
|
||||||
|
CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) {
|
||||||
|
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
current.snapshot.status == DiscoverStatus.Ready
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("Moon").assertIsDisplayed()
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
rule.runOnIdle {
|
||||||
|
val lane = current.lanes.indexOfFirst { it.titles.any { title -> title.tmdb == 43L } }
|
||||||
|
current.focus(current.firstShelf + lane, 0)
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.mainClock.advanceTimeBy(100)
|
||||||
|
org.junit.Assert.assertFalse(fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" })
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
|
||||||
|
directory.mkdirs()
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-real-provider.png").absolutePath)
|
||||||
|
fake.ratingsStatus = 401
|
||||||
|
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.NeedsSignIn }
|
||||||
|
rule.onNodeWithText("Discover needs sign-in").assertIsDisplayed()
|
||||||
|
org.junit.Assert.assertFalse(current.canRequest)
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-401.png").absolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.Rating
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
class ConductarrTitlePagesTest {
|
||||||
|
@Test fun catalogueEvidenceNeverGrantsPlaybackAndOfflinePagesKeepTheirLastKnownGoodFacts() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
val identity = session.identity.value!!
|
||||||
|
identity.provider.audiences()
|
||||||
|
val pages = ConductarrTitlePages(identity)
|
||||||
|
val live = pages.title(42, MediaType.MOVIE).last()
|
||||||
|
val state = TitleState(live, ShellPerson("shared", "Shared", "S", true)) {}
|
||||||
|
assertEquals(97 * 60L, live.item.runtimeSeconds)
|
||||||
|
assertEquals(listOf("Comedy"), live.item.genres)
|
||||||
|
assertEquals("216", live.cast.single().id)
|
||||||
|
assertEquals("Sam Bell", live.cast.single().character)
|
||||||
|
assertEquals("movie:43", live.related.single().id)
|
||||||
|
assertFalse(live.item.held)
|
||||||
|
assertNull(live.playTarget)
|
||||||
|
assertFalse(state.options.any { it.id in listOf("play", "watched", "version") })
|
||||||
|
assertTrue(state.options.any { it.id == "request" })
|
||||||
|
val actor = pages.person(216).last()
|
||||||
|
assertEquals("Actor.", actor.person!!.biography)
|
||||||
|
assertEquals("Sam Bell", actor.credits.single().character)
|
||||||
|
assertFalse(actor.credits.single().held)
|
||||||
|
assertFalse(actor.hasMore)
|
||||||
|
fake.status = 503
|
||||||
|
val saved = pages.title(42, MediaType.MOVIE).last()
|
||||||
|
assertEquals(live.item, saved.item)
|
||||||
|
assertEquals(live.cast, saved.cast)
|
||||||
|
assertEquals(TitleConnection.Offline, saved.connection)
|
||||||
|
assertNotNull(saved.savedAt)
|
||||||
|
assertFalse(TitleState(saved, state.person) {}.options.any { it.id == "request" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun onlyResolvedJellyfinEpisodesArePlayableAndTheirObservedVotesReplaceSeriesScores() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
fake.seasonTwoScore = 7.2
|
||||||
|
val episode = TitleItem("00000000-0000-0000-0000-000000000012", "Episode", "episode", held = true, season = 1, episode = 1)
|
||||||
|
val held = TitlePageData(TitleItem("00000000-0000-0000-0000-000000000011", "Dark", "tv", held = true, seasons = 2),
|
||||||
|
playTarget = episode, watchTarget = episode, episodes = listOf(episode.copy(id = "00000000-0000-0000-0000-000000000013", season = 2)), selectedSeason = 2)
|
||||||
|
val page = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last()
|
||||||
|
assertEquals(episode.id, page.playTarget!!.id)
|
||||||
|
assertEquals(episode.id, page.watchTarget.id)
|
||||||
|
assertEquals(held.item.id, page.item.id)
|
||||||
|
assertEquals(44L, page.episodes.single().tmdbId)
|
||||||
|
assertTrue(page.episodes.single().ratings.last().episode)
|
||||||
|
assertEquals(8.1, page.playTarget!!.ratings.last().score, 0.0)
|
||||||
|
assertEquals(7.2, page.episodes.single().ratings.last().score, 0.0)
|
||||||
|
assertFalse(page.item.ratings.any { it.episode })
|
||||||
|
fake.status = 401
|
||||||
|
val revoked = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last()
|
||||||
|
assertFalse(session.identity.value!!.signedIn)
|
||||||
|
assertEquals(TitleConnection.SignedOut, revoked.connection)
|
||||||
|
assertTrue(revoked.item.ratings.isEmpty())
|
||||||
|
assertTrue(revoked.playTarget!!.ratings.isEmpty())
|
||||||
|
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun ratingsUseObservedLabelsPrecedenceAndPositiveVoteCountsWithoutDiscardingRealZeroes() {
|
||||||
|
fun ratings(json: String) = Json.decodeFromString<List<Rating>>(json).screenRatings("tv")
|
||||||
|
assertEquals(listOf("rtCritics"), ratings("""[{"source":"rt_critics","state":"observed","score":0},{"source":"tmdb","state":"observed","score":9,"votes":20}]""").map { it.source })
|
||||||
|
assertNull(ratings("""[{"source":"rt_critics","state":"observed","score":99,"status":"unknown"}]""").single().label)
|
||||||
|
assertTrue(ratings("""[{"source":"tmdb","state":"observed","score":7,"votes":0},{"source":"imdb","state":"observed","score":8,"votes":10}]""").isEmpty())
|
||||||
|
assertEquals(0.0, ratings("""[{"source":"tmdb","state":"observed","score":0,"votes":1}]""").single().score, 0.0)
|
||||||
|
assertTrue(emptyList<LibraryRating>().withEpisode(0.0, 1).single().episode)
|
||||||
|
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
|
||||||
|
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ConductarrTitleSurfaceTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun realTitleOffersNoFakePlaybackAndSharedRequestForMindelHasARealReceipt() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
|
||||||
|
try {
|
||||||
|
val route = titleRoute("movie:42")
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination(route)
|
||||||
|
}
|
||||||
|
val art = mutableMapOf<String, ImageBitmap>()
|
||||||
|
fun decode(element: JsonElement) {
|
||||||
|
when (element) {
|
||||||
|
is JsonObject -> element.values.forEach(::decode)
|
||||||
|
is JsonArray -> element.forEach(::decode)
|
||||||
|
is JsonPrimitive -> element.contentOrNull?.takeIf { it.startsWith("http") || it.startsWith('/') }?.let { art[catalogueImage(it)!!] = ImageBitmap(1, 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decode(fake.wire)
|
||||||
|
rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||||
|
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false)
|
||||||
|
} } }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" }
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("Moon").assertIsDisplayed()
|
||||||
|
assertFalse(viewModel.title!!.options.any { it.id == "play" })
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
|
||||||
|
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
viewModel.title?.data?.requestActor == "Shared"
|
||||||
|
}
|
||||||
|
assertEquals("Mindel", viewModel.title!!.data.requestFor)
|
||||||
|
rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed()
|
||||||
|
val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" }
|
||||||
|
val body = Json.parseToJsonElement(post.body!!.utf8()).jsonObject
|
||||||
|
assertEquals("mindel", body.getValue("requester").jsonPrimitive.content)
|
||||||
|
assertFalse(body.containsKey("actor"))
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
fake.ratingsStatus = 401
|
||||||
|
runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable
|
||||||
|
}
|
||||||
|
assertTrue(viewModel.title!!.data.item.ratings.isEmpty())
|
||||||
|
assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") })
|
||||||
|
} finally { viewModel.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ConductarrTruthCaptureTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false))
|
||||||
|
|
||||||
|
@Before fun fullscreen() {
|
||||||
|
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun requestHistory() = requests("requests-history", "request-2")
|
||||||
|
@Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true)
|
||||||
|
@Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true)
|
||||||
|
@Test fun requestRemoved() = requests("requests-removed", "dispatch-725")
|
||||||
|
@Test fun discoverMovies() = discover("movie")
|
||||||
|
@Test fun discoverSeries() = discover("tv")
|
||||||
|
|
||||||
|
private fun requests(name: String, focused: String, expanded: Boolean = false) {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.activityBody = resource("activity-history.json")
|
||||||
|
ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
|
val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) }
|
||||||
|
val shell = ShellState(people, "justin").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("settings")
|
||||||
|
requests.update(snapshot)
|
||||||
|
openRequests()
|
||||||
|
requests.focus(focused)
|
||||||
|
if (expanded) requests.key(ShellKey.Right)
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } }
|
||||||
|
capture(name)
|
||||||
|
rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0)
|
||||||
|
rule.onNodeWithText("Requests · 56 requests").assertExists()
|
||||||
|
if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discover(type: String) {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.feedBody = resource("mindel-feed.json")
|
||||||
|
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
|
val provider = ConductarrProvider(client)
|
||||||
|
val snapshot = runBlocking { provider.discover("mindel").last() }.let { original ->
|
||||||
|
fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null)
|
||||||
|
original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) })
|
||||||
|
}
|
||||||
|
val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") }
|
||||||
|
val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value }
|
||||||
|
val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate))
|
||||||
|
state.filter = type
|
||||||
|
state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0)
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } }
|
||||||
|
capture("discover-$type")
|
||||||
|
rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String) {
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest {
|
||||||
rule.runOnIdle {
|
rule.runOnIdle {
|
||||||
assertNotNull(current.title)
|
assertNotNull(current.title)
|
||||||
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
assertEquals("The Amazing Spider-Man", current.title!!.title)
|
||||||
assertEquals("discover-title/${current.title!!.key}", shell.route)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
|
||||||
|
/** Equal captions are not equal seed identities; repeated contributions union their evidence. */
|
||||||
|
class DiscoverSeedProjectionTest {
|
||||||
|
@Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
val feed = fake.wire.getValue("feed").jsonObject
|
||||||
|
val candidate = feed.getValue("movies").jsonArray.first().jsonObject
|
||||||
|
val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[
|
||||||
|
{"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false},
|
||||||
|
{"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true},
|
||||||
|
{"media_type":"movie","tmdb_id":2,"watched":true,"added":false}
|
||||||
|
]""")))
|
||||||
|
val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[
|
||||||
|
{"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true}
|
||||||
|
]""")))
|
||||||
|
fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString()
|
||||||
|
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
|
||||||
|
val seeds = ConductarrProvider(client).discover("mindel").last().seeds
|
||||||
|
assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key })
|
||||||
|
assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb })
|
||||||
|
assertTrue(seeds.first().watched && seeds.first().added)
|
||||||
|
assertFalse(seeds.last().watched)
|
||||||
|
assertTrue(seeds.last().added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** No drawn screen may prewarm memory before the production Discover observer starts. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class DiscoverSnapshotReadTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun signedInHostObservesFreshShellThroughTheRealAsyncProvider() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val identity = requireNotNull(session.identity.value)
|
||||||
|
assertTrue(identity.signedIn)
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared")
|
||||||
|
lateinit var current: DiscoverState
|
||||||
|
rule.setContent {
|
||||||
|
current = rememberDiscoverState(shell, shell::enterDestination, {}, identity = identity, fixtures = false,
|
||||||
|
loadFixture = { error("Production observer must not load fixtures") })
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
current.snapshot.status == DiscoverStatus.Ready
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertTrue(fake.calls.any { it.url.encodedPath.endsWith("/feed") })
|
||||||
|
assertEquals(0, current.focusedRow)
|
||||||
|
assertEquals(0, current.column)
|
||||||
|
current.focus(current.firstShelf)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertNotNull(current.focusedLane)
|
||||||
|
assertEquals(current.firstShelf, current.focusedRow)
|
||||||
|
assertEquals(0, current.column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class DiscoverTitleTimingTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.localArt = true
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val identity = session.identity.value!!
|
||||||
|
val facade = mockk<TitleReadFacade>()
|
||||||
|
coEvery { facade.findHeld(any(), any()) } returns null
|
||||||
|
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
|
||||||
|
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
lateinit var discover: DiscoverState
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
try {
|
||||||
|
rule.setContent {
|
||||||
|
val route = (navigation.backStack.last() as Destination.Wolfskin).route
|
||||||
|
LaunchedEffect(route) { shell.enterDestination(route) }
|
||||||
|
discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false)
|
||||||
|
WolfskinTheme {
|
||||||
|
if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
|
||||||
|
else ShellSurface(shell) { DiscoverScreen(discover, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
|
||||||
|
rule.runOnIdle {
|
||||||
|
fake.beforeResponse = { if (it.endsWith("/cast")) release.await() }
|
||||||
|
discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning"))
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/"))
|
||||||
|
rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } }
|
||||||
|
rule.runOnIdle { release.countDown() }
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertEquals("cast", model.title!!.row)
|
||||||
|
rule.onNodeWithText("Sam Rockwell").assertIsDisplayed()
|
||||||
|
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||||
|
assertEquals("related", model.title!!.row)
|
||||||
|
rule.onNodeWithText("More like this").assertIsDisplayed()
|
||||||
|
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.Back) }
|
||||||
|
rule.waitForIdle()
|
||||||
|
assertNull(discover.title)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt
Normal file
112
app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import mockwebserver3.Dispatcher
|
||||||
|
import mockwebserver3.MockResponse
|
||||||
|
import mockwebserver3.MockWebServer
|
||||||
|
import mockwebserver3.RecordedRequest
|
||||||
|
|
||||||
|
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
|
||||||
|
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
|
||||||
|
internal const val JUSTIN_TOKEN = "11111111111111111111111111111111"
|
||||||
|
|
||||||
|
/** A loopback server supplies wire JSON, never app projections or live service state. */
|
||||||
|
internal class FakeConductarr : Closeable {
|
||||||
|
val server = MockWebServer()
|
||||||
|
val calls = CopyOnWriteArrayList<RecordedRequest>()
|
||||||
|
val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject
|
||||||
|
@Volatile var status = 200
|
||||||
|
@Volatile var postStatus = 200
|
||||||
|
@Volatile var existingRequests = false
|
||||||
|
@Volatile var notModified = false
|
||||||
|
@Volatile var ratingsStatus = 200
|
||||||
|
@Volatile var audiencesStatus = 200
|
||||||
|
@Volatile var emptyAudiences = false
|
||||||
|
@Volatile var seasonTwoScore = 8.1
|
||||||
|
@Volatile var localArt = false
|
||||||
|
@Volatile var beforeResponse: ((String) -> Unit)? = null
|
||||||
|
@Volatile var audiencesBody: String? = null
|
||||||
|
@Volatile var feedBody: String? = null
|
||||||
|
@Volatile var activityBody: String? = null
|
||||||
|
private val imageBytes = java.util.Base64.getDecoder().decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
|
||||||
|
|
||||||
|
init {
|
||||||
|
server.dispatcher = object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
calls.add(request)
|
||||||
|
if (status != 200) return response("", status)
|
||||||
|
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
|
||||||
|
.body(okio.Buffer().write(imageBytes)).build()
|
||||||
|
val path = request.url.encodedPath
|
||||||
|
beforeResponse?.invoke(path)
|
||||||
|
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
|
||||||
|
if (request.method == "POST") return response(wire.getValue("hide").toString())
|
||||||
|
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
|
||||||
|
val key = when {
|
||||||
|
path == "/api/v1/operator" -> "operator"
|
||||||
|
path == "/api/v1/recommendations/profiles" -> "profiles"
|
||||||
|
path == "/curation/library-audiences" -> "audiences"
|
||||||
|
path.endsWith("/feed") -> "feed"
|
||||||
|
path.endsWith("/dismissals") -> "dismissals"
|
||||||
|
path == "/catalogue/options" -> "options"
|
||||||
|
path == "/catalogue/discover" -> "discover"
|
||||||
|
path.endsWith("/ratings") -> "ratings"
|
||||||
|
path.endsWith("/cast") -> "cast"
|
||||||
|
path.endsWith("/related") -> "discover"
|
||||||
|
path.endsWith("/enrichment") -> "enrichment"
|
||||||
|
path.endsWith("/display") -> "display"
|
||||||
|
path.contains("/seasons/") -> "season"
|
||||||
|
path.endsWith("/filmography") -> "filmography"
|
||||||
|
path.startsWith("/catalogue/performers/") -> "performer"
|
||||||
|
path.startsWith("/catalogue/titles/") -> "detail"
|
||||||
|
path.startsWith("/api/v1/activity/titles/") -> "readiness"
|
||||||
|
path.startsWith("/api/v1/requests/titles/") -> "requests"
|
||||||
|
path == "/api/v1/activity" -> "activity"
|
||||||
|
else -> return response("", 404)
|
||||||
|
}
|
||||||
|
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
|
||||||
|
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
|
||||||
|
if (key == "audiences") audiencesBody?.let { return response(it) }
|
||||||
|
if (key == "feed") feedBody?.let { return response(it) }
|
||||||
|
if (key == "activity") activityBody?.let { return response(it) }
|
||||||
|
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
|
||||||
|
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
|
||||||
|
var body = wire.getValue(key).jsonObject
|
||||||
|
if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList())))
|
||||||
|
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
|
||||||
|
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
|
||||||
|
}
|
||||||
|
if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) {
|
||||||
|
body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin")))
|
||||||
|
}
|
||||||
|
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
|
||||||
|
body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
|
||||||
|
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))
|
||||||
|
}
|
||||||
|
if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2)))
|
||||||
|
if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map {
|
||||||
|
JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore)))
|
||||||
|
})))
|
||||||
|
val responseBody = if (localArt) localizeImages(body).toString() else body.toString()
|
||||||
|
return response(responseBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
val url: String get() = server.url("/").toString()
|
||||||
|
private fun localizeImages(element: JsonElement): JsonElement = when (element) {
|
||||||
|
is JsonObject -> JsonObject(element.mapValues { (key, value) ->
|
||||||
|
if (key in setOf("poster_url", "poster_path", "backdrop_url", "backdrop_path", "profile_url") && value is JsonPrimitive && value.contentOrNull != null)
|
||||||
|
JsonPrimitive(server.url("/image").toString()) else localizeImages(value)
|
||||||
|
})
|
||||||
|
is JsonArray -> JsonArray(element.map(::localizeImages))
|
||||||
|
else -> element
|
||||||
|
}
|
||||||
|
private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build()
|
||||||
|
override fun close() = server.close()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.*
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.*
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class FirstSignInNavigationTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var previousUrl: String
|
||||||
|
private var previousFixtures = false
|
||||||
|
private var previousScreens = true
|
||||||
|
|
||||||
|
@Before fun captureGlobalPreferences() {
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun restoreGlobalPreferences() {
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
rule.mainClock.autoAdvance = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() {
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}"""
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
val views = mockk<UserViewsApi>()
|
||||||
|
val items = mockk<ItemsApi>()
|
||||||
|
every { api.userViewsApi } returns views
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
|
||||||
|
val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
|
||||||
|
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
|
||||||
|
for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) {
|
||||||
|
coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true,
|
||||||
|
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||||
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
|
||||||
|
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
|
||||||
|
}
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||||
|
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
|
||||||
|
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
|
||||||
|
try {
|
||||||
|
rule.setContent {
|
||||||
|
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model,
|
||||||
|
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
|
||||||
|
}
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
conductarr.identity.value?.signedIn == true && model.libraries.audiences != null
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("Mindel").assertIsDisplayed()
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||||
|
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
|
||||||
|
assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible)
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
|
||||||
|
pressKey(Key.DirectionUp)
|
||||||
|
pressKey(Key.DirectionRight)
|
||||||
|
pressKey(Key.DirectionRight)
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
|
rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Desperate Housewives").assertDoesNotExist()
|
||||||
|
assertEquals(6, session.state!!.view.library!!.source.size)
|
||||||
|
assertFalse(session.state!!.person.hasAnime)
|
||||||
|
rule.onNodeWithText("Shows").performClick()
|
||||||
|
rule.waitForIdle()
|
||||||
|
try {
|
||||||
|
rule.waitUntil(timeoutMillis = 15_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
rule.onAllNodes(hasTestTag("library-grid") and
|
||||||
|
hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
} catch (timeout: ComposeTimeoutException) {
|
||||||
|
val shell = session.state!!
|
||||||
|
throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " +
|
||||||
|
"movies=${shell.rememberedView("movies").library?.source?.size}, " +
|
||||||
|
"shows=${shell.rememberedView("shows").library?.source?.size}/" +
|
||||||
|
"${shell.rememberedView("shows").library?.status}", timeout)
|
||||||
|
}
|
||||||
|
rule.onNodeWithTag("library-grid").assertIsDisplayed()
|
||||||
|
assertEquals(29, session.state!!.view.library!!.source.size)
|
||||||
|
rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed()
|
||||||
|
rule.onNodeWithText("Home").performClick()
|
||||||
|
rule.waitForIdle()
|
||||||
|
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
|
||||||
|
assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible)
|
||||||
|
} finally { model.viewModelScope.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ class HomeRecoveryTest {
|
||||||
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||||
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||||
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
|
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
|
||||||
|
private val viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString())
|
||||||
|
private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString())
|
||||||
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
|
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
|
||||||
private val library = HomeLibrary(resume = listOf(film))
|
private val library = HomeLibrary(resume = listOf(film))
|
||||||
|
|
||||||
|
|
@ -41,11 +43,13 @@ class HomeRecoveryTest {
|
||||||
val started = CompletableDeferred<Unit>()
|
val started = CompletableDeferred<Unit>()
|
||||||
val response = CompletableDeferred<HomeLibrary>()
|
val response = CompletableDeferred<HomeLibrary>()
|
||||||
val facade = mockk<HomeReadFacade>()
|
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() }
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists())
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
|
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()
|
started.await()
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
|
assertFalse(model.snapshot.rows.first().watchReady)
|
||||||
response.completeExceptionally(IOException("fixture offline"))
|
response.completeExceptionally(IOException("fixture offline"))
|
||||||
refresh.await()
|
refresh.await()
|
||||||
assertFalse(model.snapshot.jellyfinAvailable)
|
assertFalse(model.snapshot.jellyfinAvailable)
|
||||||
|
|
@ -60,13 +64,14 @@ class HomeRecoveryTest {
|
||||||
cache.write("${server.id}:${personA.id}", library, 1000L)
|
cache.write("${server.id}:${personA.id}", library, 1000L)
|
||||||
val startedA = CompletableDeferred<Unit>()
|
val startedA = CompletableDeferred<Unit>()
|
||||||
val responseA = CompletableDeferred<HomeLibrary>()
|
val responseA = CompletableDeferred<HomeLibrary>()
|
||||||
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
|
||||||
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
|
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists())
|
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
|
||||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
|
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()
|
startedA.await()
|
||||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||||
model.loadLibrary(server, personB, "mindel")
|
model.loadLibrary(server, personB, viewerB)
|
||||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||||
assertNull(model.snapshot.savedAt)
|
assertNull(model.snapshot.savedAt)
|
||||||
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
|
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
|
||||||
|
|
@ -78,14 +83,30 @@ class HomeRecoveryTest {
|
||||||
|
|
||||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||||
val facade = mockk<HomeReadFacade>()
|
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>()
|
val cache = mockk<HomeLibraryCache>()
|
||||||
every { cache.read(any()) } returns null
|
every { cache.read(any()) } returns null
|
||||||
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
|
||||||
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists())
|
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
model.loadLibrary(server, personA, "justin")
|
model.loadLibrary(server, personA, viewerA)
|
||||||
assertTrue(model.snapshot.jellyfinAvailable)
|
assertTrue(model.snapshot.jellyfinAvailable)
|
||||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
assertNull(model.toast)
|
assertNull(model.toast)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
|
||||||
|
val facade = mockk<HomeReadFacade>()
|
||||||
|
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()))
|
||||||
|
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
|
||||||
|
assertTrue(model.snapshot.jellyfinAvailable)
|
||||||
|
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||||
|
model.loadLibrary(server, personA, viewerB.copy(key = "justin"))
|
||||||
|
assertFalse(model.snapshot.jellyfinAvailable)
|
||||||
|
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
|
||||||
}
|
}
|
||||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||||
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||||
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
|
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val facade = mockk<LibraryReadFacade>()
|
val facade = mockk<LibraryReadFacade>()
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
val destination = navigation.backStack.last()
|
val destination = navigation.backStack.last()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** The real host exposes usable remote focus before the remaining library chunks arrive. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class LibraryProgressiveSurfaceTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() {
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("movies")
|
||||||
|
focusNav(currentTab)
|
||||||
|
}
|
||||||
|
val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null)
|
||||||
|
val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") }
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val facade = mockk<LibraryReadFacade>()
|
||||||
|
every { facade.audiences } returns null
|
||||||
|
every { facade.readLibrary(user.id, "movies") } returns flow {
|
||||||
|
emit(first)
|
||||||
|
release.await()
|
||||||
|
emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie"))
|
||||||
|
}
|
||||||
|
val scope = "progressive:${UUID.randomUUID()}"
|
||||||
|
val navigation = NavigationManager()
|
||||||
|
val seeAll = WolfskinSeeAllLists()
|
||||||
|
try {
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||||
|
WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier)
|
||||||
|
} } }
|
||||||
|
rule.waitUntil(timeoutMillis = 10_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
shell.view.library?.source?.size == 200
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(500)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||||
|
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
|
||||||
|
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
|
||||||
|
rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath)
|
||||||
|
release.complete(Unit)
|
||||||
|
rule.waitUntil(timeoutMillis = 10_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
shell.view.library?.source?.size == 201
|
||||||
|
}
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
}
|
||||||
|
} finally { release.complete(Unit) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibrarySnapshot
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class LibraryReadFacadeTest {
|
||||||
|
@Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking {
|
||||||
|
val api = mockk<ApiClient>()
|
||||||
|
val views = mockk<UserViewsApi>()
|
||||||
|
val items = mockk<ItemsApi>()
|
||||||
|
every { api.userViewsApi } returns views
|
||||||
|
every { api.itemsApi } returns items
|
||||||
|
val user = UUID(0, 1)
|
||||||
|
val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
|
||||||
|
coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library))
|
||||||
|
val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) }
|
||||||
|
val nextRead = CompletableDeferred<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val offsets = mutableListOf<Int>()
|
||||||
|
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
|
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.DATE_CREATED),
|
||||||
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
|
||||||
|
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
|
||||||
|
assertEquals(200, offsets.last())
|
||||||
|
nextRead.complete(Unit)
|
||||||
|
release.await()
|
||||||
|
successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val facade = LibraryReadFacade(api)
|
||||||
|
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||||
|
val published = CompletableDeferred<LibrarySnapshot>()
|
||||||
|
val snapshots = mutableListOf<LibrarySnapshot>()
|
||||||
|
val job = launch {
|
||||||
|
store.read("server:justin", "movies") {
|
||||||
|
facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) }
|
||||||
|
}.collect {
|
||||||
|
snapshots += it
|
||||||
|
if (!published.isCompleted) published.complete(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val initial = published.await()
|
||||||
|
assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id })
|
||||||
|
nextRead.await()
|
||||||
|
assertTrue(job.isActive)
|
||||||
|
release.complete(Unit)
|
||||||
|
job.join()
|
||||||
|
val final = snapshots.last()
|
||||||
|
assertEquals(201, final.items.size)
|
||||||
|
assertEquals("Updated film", final.items.first().title)
|
||||||
|
assertEquals("Last film", final.items.last().title)
|
||||||
|
assertFalse(final.unreachable)
|
||||||
|
val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single()
|
||||||
|
assertEquals(final.items, saved.items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -20,7 +21,7 @@ class LibrarySnapshotStoreTest {
|
||||||
var now = 1000L
|
var now = 1000L
|
||||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||||
val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true))
|
val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true))
|
||||||
store.read("server:justin", "movies") { items }.toList()
|
store.read("server:justin", "movies") { flowOf(items) }.toList()
|
||||||
val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList()
|
val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList()
|
||||||
assertEquals(items, fresh.single().items)
|
assertEquals(items, fresh.single().items)
|
||||||
now += 11 * 60_000
|
now += 11 * 60_000
|
||||||
|
|
@ -39,11 +40,46 @@ class LibrarySnapshotStoreTest {
|
||||||
|
|
||||||
@Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking {
|
@Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking {
|
||||||
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||||
store.read("server:justin", "movies") { emptyList() }.toList()
|
store.read("server:justin", "movies") { flowOf(emptyList()) }.toList()
|
||||||
val current = listOf(LibraryTitle("b", "New Film", "movie"))
|
val current = listOf(LibraryTitle("b", "New Film", "movie"))
|
||||||
val reads = store.read("server:justin", "movies", force = true) { current }.toList()
|
val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList()
|
||||||
assertTrue(reads.first().items.isEmpty())
|
assertTrue(reads.first().items.isEmpty())
|
||||||
assertEquals(current, reads.last().items)
|
assertEquals(current, reads.last().items)
|
||||||
assertFalse(reads.last().unreachable)
|
assertFalse(reads.last().unreachable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun acknowledgedPlaybackInvalidatesOnlyOlderWatchSnapshotsBeforeTheTtl() = runBlocking {
|
||||||
|
var now = 1000L
|
||||||
|
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
|
||||||
|
val old = listOf(LibraryTitle("film", "Film", "movie", progress = .1f))
|
||||||
|
store.read("server:justin", "movies") { flowOf(old) }.toList()
|
||||||
|
store.read("server:mindel", "movies") { flowOf(old) }.toList()
|
||||||
|
now = 1002L
|
||||||
|
val updated = listOf(old.single().copy(progress = .6f))
|
||||||
|
val reads = store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { flowOf(updated) }.toList()
|
||||||
|
assertEquals(.1f, reads.first().items.single().progress)
|
||||||
|
assertEquals(.6f, reads.last().items.single().progress)
|
||||||
|
assertEquals(updated, store.read("server:justin", "movies", invalidatedAtMillis = 1001L) { error("Already revalidated") }.toList().single().items)
|
||||||
|
assertEquals(old, store.read("server:mindel", "movies") { error("Other viewer stays cached") }.toList().single().items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
|
||||||
|
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
|
||||||
|
val complete = partial + LibraryTitle("last", "Last film", "movie")
|
||||||
|
for (hasSaved in listOf(false, true)) {
|
||||||
|
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
|
||||||
|
if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList()
|
||||||
|
val reads = store.read("server:justin", "movies", force = true) {
|
||||||
|
kotlinx.coroutines.flow.flow {
|
||||||
|
emit(partial)
|
||||||
|
throw java.io.IOException("Later chunk failed")
|
||||||
|
}
|
||||||
|
}.toList()
|
||||||
|
assertEquals(if (hasSaved) complete else partial, reads.last().items)
|
||||||
|
assertTrue(reads.last().unreachable)
|
||||||
|
assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis)
|
||||||
|
val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList()
|
||||||
|
assertEquals(if (hasSaved) complete else emptyList<LibraryTitle>(), reopened.last().items)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Scrolled cards must be occluded across the entire persistent navigation band. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class NavBandOcclusionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun scrolledRowsNeverShowThroughTheNavBand() {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
|
||||||
|
LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) {
|
||||||
|
items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) }
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } }
|
||||||
|
rule.waitForIdle()
|
||||||
|
val pixels = rule.onRoot().captureToImage().toPixelMap()
|
||||||
|
val background = WolfskinTokens.Color.bg
|
||||||
|
for (y in listOf(5, 50, 135)) {
|
||||||
|
val actual = pixels[20, y]
|
||||||
|
assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f)
|
||||||
|
assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f)
|
||||||
|
assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.*
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.*
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import io.mockk.*
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** A selected identity gets a new app ViewModel without opening a second launch picker. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class PersonPickerSessionTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private lateinit var previousUrl: String
|
||||||
|
private var previousFixtures = false
|
||||||
|
private var previousScreens = true
|
||||||
|
private val models = mutableListOf<WolfskinShellViewModel>()
|
||||||
|
private lateinit var session: WolfskinSession
|
||||||
|
|
||||||
|
@Before fun capturePreferences() {
|
||||||
|
ConductarrPreference.initialize(rule.activity)
|
||||||
|
WolfskinScreenPreference.initialize(rule.activity)
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
previousScreens = WolfskinScreenPreference.enabled
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
WolfskinScreenPreference.enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun restorePreferences() {
|
||||||
|
models.forEach { it.viewModelScope.cancel() }
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
WolfskinScreenPreference.enabled = previousScreens
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) ->
|
||||||
|
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
|
||||||
|
}
|
||||||
|
var current by mutableStateOf(CurrentUser(server, users[initial]))
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||||
|
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||||
|
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
coEvery { libraries.hasAnimeTitles(any()) } returns false
|
||||||
|
session = WolfskinSession()
|
||||||
|
try {
|
||||||
|
rule.setContent {
|
||||||
|
key(current.user.id) {
|
||||||
|
val navigation = remember { NavigationManager() }
|
||||||
|
val model = remember {
|
||||||
|
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
|
||||||
|
}
|
||||||
|
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||||
|
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||||
|
viewModel = model, homeContent = { _, _, user, modifier ->
|
||||||
|
androidx.tv.material3.Text("Home for ${user.name}", modifier)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
awaitLoaded(users[initial])
|
||||||
|
if (fromAvatar) {
|
||||||
|
press(listOf(Key.DirectionCenter))
|
||||||
|
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
|
||||||
|
}
|
||||||
|
val actions = mutableListOf<ShellAction.Person>()
|
||||||
|
rule.runOnIdle {
|
||||||
|
val dispatch = session.onAction
|
||||||
|
session.onAction = { action ->
|
||||||
|
if (action is ShellAction.Person) actions.add(action)
|
||||||
|
dispatch(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fast) press(moves + Key.DirectionCenter) else {
|
||||||
|
press(moves)
|
||||||
|
press(listOf(Key.DirectionCenter))
|
||||||
|
}
|
||||||
|
awaitLoaded(users[selected])
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible)
|
||||||
|
assertEquals("home", session.state!!.route)
|
||||||
|
assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId)
|
||||||
|
assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId })
|
||||||
|
assertEquals(if (initial == selected) 1 else 2, models.size)
|
||||||
|
}
|
||||||
|
rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed()
|
||||||
|
} finally { models.forEach { it.viewModelScope.cancel() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun awaitLoaded(user: JellyfinUser) {
|
||||||
|
try {
|
||||||
|
rule.waitUntil(10_000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||||
|
models.last().conductarr.identity.value?.signedIn == true &&
|
||||||
|
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
|
||||||
|
session.state?.people?.size == 3 &&
|
||||||
|
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
|
||||||
|
}
|
||||||
|
} catch (timeout: ComposeTimeoutException) {
|
||||||
|
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
|
||||||
|
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
|
||||||
|
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(keys: List<Key>) {
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight))
|
||||||
|
@Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft))
|
||||||
|
@Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight))
|
||||||
|
@Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft))
|
||||||
|
@Test fun initialPersonNeedsOnlyOneOk() = select(0, 0)
|
||||||
|
@Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||||
|
@Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.*
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import io.mockk.*
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class PersonSwitchThreadingTest {
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
private lateinit var previousUrl: String
|
||||||
|
private var previousFixtures = false
|
||||||
|
|
||||||
|
@Before fun setUp() {
|
||||||
|
Dispatchers.setMain(Dispatchers.Unconfined)
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
previousUrl = ConductarrPreference.baseUrl
|
||||||
|
previousFixtures = ConductarrPreference.fixtures
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun tearDown() {
|
||||||
|
ConductarrPreference.updateBaseUrl(previousUrl)
|
||||||
|
ConductarrPreference.updateFixtures(previousFixtures)
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
unmockkConstructor(ConductarrClient::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking {
|
||||||
|
assertSame(Looper.getMainLooper(), Looper.myLooper())
|
||||||
|
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||||
|
val releaseCloses = CountDownLatch(1)
|
||||||
|
val closed = CountDownLatch(4)
|
||||||
|
val disposalErrors = CopyOnWriteArrayList<Throwable>()
|
||||||
|
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) })
|
||||||
|
val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
mockkConstructor(ConductarrClient::class)
|
||||||
|
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||||
|
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
closeThreads.add(onMain)
|
||||||
|
check(!onMain) { "StrictMode: Conductarr socket close on main" }
|
||||||
|
check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" }
|
||||||
|
callOriginal()
|
||||||
|
closed.countDown()
|
||||||
|
if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure")
|
||||||
|
}
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||||
|
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||||
|
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||||
|
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
|
||||||
|
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val clients = mutableListOf<ConductarrClient>()
|
||||||
|
try {
|
||||||
|
model.load(server, justin)
|
||||||
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
clients.add(conductarr.identity.value!!.client)
|
||||||
|
val shell = session.state!!
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (index in listOf(1, 2, 0)) {
|
||||||
|
val switched = CompletableDeferred<CurrentUser>()
|
||||||
|
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(index)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals(users[index].id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
assertFalse(shell.pickerVisible)
|
||||||
|
assertNull("Old identity must not serve the newly selected person", conductarr.identity.value)
|
||||||
|
val current = withTimeout(5_000) { switched.await() }
|
||||||
|
assertEquals(users[index].id, current.user.id)
|
||||||
|
model.load(current.server, current.user)
|
||||||
|
requireNotNull(model.onForeground(server, current.user)).join()
|
||||||
|
assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertEquals(users[index].name!!.lowercase(), model.signedInPerson)
|
||||||
|
clients.add(conductarr.identity.value!!.client)
|
||||||
|
}
|
||||||
|
conductarr.close()
|
||||||
|
conductarr.close()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
releaseCloses.countDown()
|
||||||
|
assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) })
|
||||||
|
assertEquals(listOf(false, false, false, false), closeThreads.toList())
|
||||||
|
for (client in clients) {
|
||||||
|
val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last()
|
||||||
|
assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error)
|
||||||
|
}
|
||||||
|
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||||
|
assertTrue("A failing socket close must be contained", disposalErrors.isEmpty())
|
||||||
|
} finally {
|
||||||
|
releaseCloses.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
withContext(Dispatchers.IO) { conductarr.close() }
|
||||||
|
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||||
|
disposalScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||||
|
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
|
||||||
|
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
|
||||||
|
val pending = CompletableDeferred<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
coEvery { repository.changeUser(server, any()) } coAnswers {
|
||||||
|
val user = secondArg<JellyfinUser>()
|
||||||
|
if (user.id == users[1].id) {
|
||||||
|
pending.complete(Unit)
|
||||||
|
withContext(NonCancellable) { release.await() }
|
||||||
|
}
|
||||||
|
CurrentUser(server, user)
|
||||||
|
}
|
||||||
|
val switched = CompletableDeferred<CurrentUser>()
|
||||||
|
val setup = mockk<SetupNavigationManager>(relaxed = true)
|
||||||
|
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
try {
|
||||||
|
model.load(server, justin)
|
||||||
|
requireNotNull(model.onForeground(server, justin)).join()
|
||||||
|
val shell = session.state!!
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (index in listOf(1, 2, 0)) {
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(index)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
if (index == 1) withTimeout(5_000) { pending.await() }
|
||||||
|
}
|
||||||
|
release.complete(Unit)
|
||||||
|
val current = withTimeout(5_000) { switched.await() }
|
||||||
|
assertEquals(justin.id, current.user.id)
|
||||||
|
verify(exactly = 1) { setup.navigateTo(any()) }
|
||||||
|
model.load(server, current.user)
|
||||||
|
requireNotNull(model.onForeground(server, current.user)).join()
|
||||||
|
assertEquals(justin.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("justin", model.signedInPerson)
|
||||||
|
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
} finally {
|
||||||
|
release.complete(Unit)
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking {
|
||||||
|
val closeThreads = CopyOnWriteArrayList<Boolean>()
|
||||||
|
val closed = CountDownLatch(3)
|
||||||
|
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
mockkConstructor(ConductarrClient::class)
|
||||||
|
every { anyConstructed<ConductarrClient>().close() } answers {
|
||||||
|
val onMain = Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
closeThreads.add(onMain)
|
||||||
|
check(!onMain) { "StrictMode: retry socket close on main" }
|
||||||
|
callOriginal()
|
||||||
|
closed.countDown()
|
||||||
|
}
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>()
|
||||||
|
every { repository.currentUser } returns user
|
||||||
|
every { repository.currentServer } returns server
|
||||||
|
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
|
||||||
|
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)
|
||||||
|
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository)
|
||||||
|
try {
|
||||||
|
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
fake.status = 401
|
||||||
|
conductarr.authenticate()
|
||||||
|
assertFalse(conductarr.identity.value!!.signedIn)
|
||||||
|
fake.status = 200
|
||||||
|
homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true)
|
||||||
|
withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } }
|
||||||
|
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } })
|
||||||
|
fake.status = 401
|
||||||
|
conductarr.authenticate()
|
||||||
|
assertFalse(conductarr.identity.value!!.signedIn)
|
||||||
|
fake.status = 200
|
||||||
|
titleModel.retrySignIn()
|
||||||
|
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertTrue(conductarr.identity.value!!.signedIn)
|
||||||
|
} finally {
|
||||||
|
homeModel.viewModelScope.cancel()
|
||||||
|
titleModel.viewModelScope.cancel()
|
||||||
|
conductarr.close()
|
||||||
|
disposalScope.coroutineContext.job.children.toList().joinAll()
|
||||||
|
disposalScope.cancel()
|
||||||
|
}
|
||||||
|
assertEquals(0L, closed.count)
|
||||||
|
assertEquals(listOf(false, false, false), closeThreads.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -52,13 +52,13 @@ class PlayerHandoffTest {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
|
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
|
||||||
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists())
|
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||||
val title = snapshot.rows.first().cards.first()
|
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 {
|
rule.setContent { WolfskinTheme {
|
||||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||||
|
|
@ -80,14 +80,16 @@ class PlayerHandoffTest {
|
||||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
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
|
val view = shell.view
|
||||||
view.cards["hero"] = 0
|
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 }
|
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||||
val destination = navigation.backStack.last() as Destination.Playback
|
val destination = navigation.backStack.last() as Destination.Playback
|
||||||
assertEquals(id, destination.itemId)
|
assertEquals(id, destination.itemId)
|
||||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
assertEquals(1_136_274L, destination.positionMs)
|
||||||
assertTrue(destination.fullScreen)
|
assertTrue(destination.fullScreen)
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||||
|
|
@ -99,11 +101,11 @@ class PlayerHandoffTest {
|
||||||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||||
val otherUser = user.copy(name = "Justin")
|
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 }
|
awaitModel { model.toast != null }
|
||||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
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) {
|
private fun awaitModel(condition: () -> Boolean) {
|
||||||
rule.waitUntil(5000) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
class ShellStartupTimingTest {
|
||||||
|
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
|
||||||
|
@After fun resetMain() { Dispatchers.resetMain() }
|
||||||
|
|
||||||
|
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previous = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
|
||||||
|
conductarr.authenticate()
|
||||||
|
val activityEntered = CompletableDeferred<Unit>()
|
||||||
|
val releaseActivity = java.util.concurrent.CountDownLatch(1)
|
||||||
|
fake.beforeResponse = {
|
||||||
|
if (it == "/api/v1/activity") {
|
||||||
|
activityEntered.complete(Unit)
|
||||||
|
releaseActivity.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
|
||||||
|
withTimeout(5_000) { activityEntered.await() }
|
||||||
|
val savedPeople = CompletableDeferred<Unit>()
|
||||||
|
val savedPeopleStarted = CompletableDeferred<Unit>()
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null }
|
||||||
|
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val load = launch { model.load(server, user) }
|
||||||
|
try {
|
||||||
|
withTimeout(5_000) { savedPeopleStarted.await() }
|
||||||
|
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
|
||||||
|
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
|
||||||
|
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
for (route in listOf("movies", "shows", "discover")) {
|
||||||
|
shell.focusNav(shell.tabs.indexOf(route))
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
verify { navigation.replace(Destination.Wolfskin(route)) }
|
||||||
|
assertEquals(route, shell.route)
|
||||||
|
}
|
||||||
|
savedPeople.complete(Unit)
|
||||||
|
load.join()
|
||||||
|
assertSame(shell, session.state)
|
||||||
|
assertEquals("discover", shell.route)
|
||||||
|
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
|
||||||
|
} finally {
|
||||||
|
releaseActivity.countDown()
|
||||||
|
activity.cancelAndJoin()
|
||||||
|
savedPeople.complete(Unit)
|
||||||
|
load.cancelAndJoin()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConductarrPreference.updateBaseUrl(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||||
|
ConductarrPreference.initialize(context)
|
||||||
|
ConductarrPreference.updateFixtures(false)
|
||||||
|
val previous = ConductarrPreference.baseUrl
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrPreference.updateBaseUrl(fake.url)
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { conductarr ->
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
|
||||||
|
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
|
||||||
|
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
|
||||||
|
val repository = mockk<ServerRepository>(relaxed = true)
|
||||||
|
coEvery { repository.serverDao.getServer(server.id) } returns
|
||||||
|
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
|
||||||
|
val switched = CompletableDeferred<Unit>()
|
||||||
|
coEvery { repository.changeUser(server, mindel) } coAnswers {
|
||||||
|
switched.complete(Unit)
|
||||||
|
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
|
||||||
|
}
|
||||||
|
val navigation = mockk<NavigationManager>(relaxed = true)
|
||||||
|
val libraries = mockk<LibraryReadFacade>(relaxed = true)
|
||||||
|
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||||
|
val session = WolfskinSession()
|
||||||
|
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
|
||||||
|
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||||
|
val pending = CompletableDeferred<Unit>()
|
||||||
|
val release = java.util.concurrent.CountDownLatch(1)
|
||||||
|
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||||
|
fake.beforeResponse = {
|
||||||
|
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
|
||||||
|
pending.complete(Unit)
|
||||||
|
release.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
model.load(server, shared)
|
||||||
|
withTimeout(5_000) { pending.await() }
|
||||||
|
val shell = requireNotNull(session.state)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.enterDestination("discover")
|
||||||
|
shell.focusNav(shell.tabs.size)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
shell.focusPerson(1)
|
||||||
|
shell.key(ShellKey.Ok)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
verify { navigation.replace(Destination.Home()) }
|
||||||
|
withTimeout(5_000) { switched.await() }
|
||||||
|
model.load(server, mindel)
|
||||||
|
requireNotNull(model.onForeground(server, mindel)).join()
|
||||||
|
release.countDown()
|
||||||
|
assertSame(shell, session.state)
|
||||||
|
assertEquals("mindel", model.signedInPerson)
|
||||||
|
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
|
||||||
|
assertEquals("Mindel", shell.person.name)
|
||||||
|
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
|
||||||
|
assertEquals("home", shell.route)
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
model.viewModelScope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConductarrPreference.updateBaseUrl(previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Looper
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
|
@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -55,7 +57,7 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readSaved(id, any()) } returns null
|
coEvery { facade.readSaved(id, any()) } returns null
|
||||||
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() }
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
||||||
val person = ShellPerson("justin", "Justin", "J", false)
|
val person = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
val shell = ShellState(listOf(person), person.key).apply {
|
val shell = ShellState(listOf(person), person.key).apply {
|
||||||
|
|
@ -94,7 +96,7 @@ class TitleAsyncFocusTest {
|
||||||
val facade = mockk<TitleReadFacade>()
|
val facade = mockk<TitleReadFacade>()
|
||||||
coEvery { facade.readPersonSaved(id) } returns null
|
coEvery { facade.readPersonSaved(id) } returns null
|
||||||
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = personRoute(id.toString())
|
val route = personRoute(id.toString())
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
|
|
@ -137,7 +139,7 @@ class TitleAsyncFocusTest {
|
||||||
reads.incrementAndGet()
|
reads.incrementAndGet()
|
||||||
throw java.io.IOException("offline")
|
throw java.io.IOException("offline")
|
||||||
}
|
}
|
||||||
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
|
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true))
|
||||||
val viewer = ShellPerson("justin", "Justin", "J", false)
|
val viewer = ShellPerson("justin", "Justin", "J", false)
|
||||||
val route = titleRoute(id.toString())
|
val route = titleRoute(id.toString())
|
||||||
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
val shell = ShellState(listOf(viewer), viewer.key).apply {
|
||||||
|
|
@ -152,7 +154,10 @@ class TitleAsyncFocusTest {
|
||||||
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
|
||||||
rule.runOnIdle { viewModel.title!!.toast = null }
|
rule.runOnIdle { viewModel.title!!.toast = null }
|
||||||
press(Key.DirectionCenter)
|
press(Key.DirectionCenter)
|
||||||
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null }
|
rule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
reads.get() == 2 && viewModel.title?.toast != null
|
||||||
|
}
|
||||||
rule.waitForIdle()
|
rule.waitForIdle()
|
||||||
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
assertEquals("Saved title", viewModel.title!!.data.item.name)
|
||||||
assertEquals("20:42", shell.navSavedAt)
|
assertEquals("20:42", shell.navSavedAt)
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
||||||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||||
data.episodes.forEach { item ->
|
data.episodes.forEach { item ->
|
||||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) }
|
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||||
|
|
@ -215,4 +215,5 @@ class TitleCaptureTest {
|
||||||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||||
|
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
import androidx.compose.ui.test.performMouseInput
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
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 press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||||
private fun replay(end: String): Int {
|
private fun replay(end: String): Int {
|
||||||
val path = paths.single { it.getString("end") == end }
|
val path = paths.single { it.getString("end") == end }
|
||||||
|
|
@ -312,11 +336,6 @@ class TitleKeysTest {
|
||||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
|
|
||||||
render("Andor")
|
|
||||||
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||||
render(transform = { data ->
|
render(transform = { data ->
|
||||||
|
|
|
||||||
|
|
@ -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.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
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.Assert.*
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
@ -81,6 +83,7 @@ class TitleReadFacadeTest {
|
||||||
every { repository.currentServer } returns null
|
every { repository.currentServer } returns null
|
||||||
every { repository.currentUser } returns null
|
every { repository.currentUser } returns null
|
||||||
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList())
|
||||||
val context = mockk<Context> { every { filesDir } returns directory.root }
|
val context = mockk<Context> { every { filesDir } returns directory.root }
|
||||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +106,31 @@ class TitleReadFacadeTest {
|
||||||
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
@Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
|
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
|
||||||
|
listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42"))))
|
||||||
|
val page = facade.read(firstId, null, preferences)
|
||||||
|
assertEquals(42L, page.item.tmdbId)
|
||||||
|
assertEquals(firstId.toString(), page.watchTarget.id)
|
||||||
|
assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id)
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
session.authenticate()
|
||||||
|
val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}
|
||||||
|
ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId),
|
||||||
|
org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) }
|
||||||
|
assertEquals(listOf("header", "cast", "related"), screen.rows)
|
||||||
|
assertEquals("Sam Rockwell", screen.data.cast.single().name)
|
||||||
|
assertEquals(firstId.toString(), screen.target?.id)
|
||||||
|
assertTrue(screen.data.item.held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
|
||||||
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId).copy(userData = playbackUserData(firstId, 200_000_000L)))
|
||||||
facade.read(firstId, null, preferences)
|
facade.read(firstId, null, preferences)
|
||||||
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
coEvery { library.getItem(any()) } throws java.io.IOException("offline")
|
||||||
val saved = facade.readSaved(firstId, preferences)!!
|
val saved = facade.readSaved(firstId, preferences)!!
|
||||||
|
|
@ -115,6 +141,7 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(TitleConnection.SignedOut, saved.connection)
|
assertEquals(TitleConnection.SignedOut, saved.connection)
|
||||||
assertTrue(saved.item.ratings.isEmpty())
|
assertTrue(saved.item.ratings.isEmpty())
|
||||||
assertEquals(firstId.toString(), saved.watchTarget.id)
|
assertEquals(firstId.toString(), saved.watchTarget.id)
|
||||||
|
assertFalse(screen.options.any { it.id == "play" || it.id == "restart" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
@Test fun diskCopiesNeverCrossViewerIdentity() = runTest {
|
||||||
|
|
@ -164,6 +191,18 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(secondId.toString(), saved.credits.single().id)
|
assertEquals(secondId.toString(), saved.credits.single().id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun cataloguePlaybackLookupRejectsAnotherMediaTypeOrTmdbIdentity() = runTest {
|
||||||
|
var payload = """{"Items":[{"Id":"$firstId","Type":"Series","ProviderIds":{"Tmdb":"42"}}]}"""
|
||||||
|
coEvery { api.request(any(), any(), any(), any(), any()) } coAnswers {
|
||||||
|
org.jellyfin.sdk.api.client.RawResponse(org.json.JSONObject(payload).put("TotalRecordCount", 1).put("StartIndex", 0).toString().toByteArray(), 200, emptyMap())
|
||||||
|
}
|
||||||
|
assertNull(facade.findHeld(42, "movie"))
|
||||||
|
payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"43"}}]}"""
|
||||||
|
assertNull(facade.findHeld(42, "movie"))
|
||||||
|
payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"42"}}]}"""
|
||||||
|
assertEquals(firstId, facade.findHeld(42, "movie"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
|
@Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
|
||||||
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
|
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9)
|
||||||
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
|
val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)
|
||||||
|
|
@ -182,6 +221,43 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
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 {
|
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
|
||||||
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
|
||||||
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
coEvery { playback.savePlayVersion(any(), any()) } returns null
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ class UpstreamContentFocusTest {
|
||||||
val state = shellState()
|
val state = shellState()
|
||||||
val model = mockk<WolfskinShellViewModel>(relaxed = true)
|
val model = mockk<WolfskinShellViewModel>(relaxed = true)
|
||||||
every { model.session } returns WolfskinSession().also { it.state = state }
|
every { model.session } returns WolfskinSession().also { it.state = state }
|
||||||
|
every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
|
||||||
every { model.libraryDestinations } returns emptyMap()
|
every { model.libraryDestinations } returns emptyMap()
|
||||||
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
|
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
|
||||||
every { model.signedInPerson } returns "unavailable"
|
every { model.signedInPerson } returns "unavailable"
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.imageLoader
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.request.CachePolicy
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import mockwebserver3.MockResponse
|
||||||
|
import mockwebserver3.MockWebServer
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinImages
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class WolfskinArtworkTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@OptIn(coil3.annotation.DelicateCoilApi::class)
|
||||||
|
@Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() {
|
||||||
|
MockWebServer().use { jellyfin ->
|
||||||
|
MockWebServer().use { artwork ->
|
||||||
|
jellyfin.start(); artwork.start()
|
||||||
|
val context = rule.activity
|
||||||
|
val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\""
|
||||||
|
val authenticated = OkHttpClient.Builder().addInterceptor { chain ->
|
||||||
|
chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build())
|
||||||
|
}.build()
|
||||||
|
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null)
|
||||||
|
val repository = mockk<ServerRepository> { every { currentServer } returns server }
|
||||||
|
val network = WolfskinArtwork(authenticated, repository)
|
||||||
|
val bytes = ByteArrayOutputStream().also {
|
||||||
|
Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}.toByteArray()
|
||||||
|
jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
|
||||||
|
artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
|
||||||
|
artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build())
|
||||||
|
val urls = listOf(jellyfin.url("/jellyfin.png"),
|
||||||
|
artwork.url("/public.png").newBuilder().host("127.0.0.1").build(),
|
||||||
|
artwork.url("/another-port.png"))
|
||||||
|
val results = CopyOnWriteArrayList<coil3.request.ImageResult>()
|
||||||
|
val previousParent = context.applicationContext.imageLoader
|
||||||
|
try {
|
||||||
|
// Other screen tests may already have created Coil's process singleton.
|
||||||
|
SingletonImageLoader.reset()
|
||||||
|
rule.setContent {
|
||||||
|
CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false)
|
||||||
|
LaunchedEffect(network) {
|
||||||
|
// Force the upstream authenticated parent to exist before configuring Wolfskin.
|
||||||
|
context.applicationContext.imageLoader
|
||||||
|
network.configure(context)
|
||||||
|
val loader = WolfskinImages.loader(context)
|
||||||
|
for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString())
|
||||||
|
.memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitUntil(30_000) {
|
||||||
|
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||||
|
results.size == urls.size
|
||||||
|
}
|
||||||
|
results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) }
|
||||||
|
assertEquals(credential, jellyfin.takeRequest().headers["Authorization"])
|
||||||
|
assertNull(artwork.takeRequest().headers["Authorization"])
|
||||||
|
assertNull(artwork.takeRequest().headers["Authorization"])
|
||||||
|
} finally {
|
||||||
|
WolfskinImages.loader(context).shutdown()
|
||||||
|
val testParent = context.applicationContext.imageLoader
|
||||||
|
SingletonImageLoader.setUnsafe(previousParent)
|
||||||
|
testParent.shutdown()
|
||||||
|
authenticated.dispatcher.executorService.shutdown()
|
||||||
|
authenticated.connectionPool.evictAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
File diff suppressed because it is too large
Load diff
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
File diff suppressed because it is too large
Load diff
621
app/src/test/resources/conductarr/wire.json
Normal file
621
app/src/test/resources/conductarr/wire.json
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
{
|
||||||
|
"operator": {
|
||||||
|
"capabilities": {
|
||||||
|
"administer": false,
|
||||||
|
"manage_library": false,
|
||||||
|
"manage_memberships": false,
|
||||||
|
"recommendation_profiles": [
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"request_media": true,
|
||||||
|
"requesters": [
|
||||||
|
"shared",
|
||||||
|
"mindel"
|
||||||
|
],
|
||||||
|
"watch_users": [
|
||||||
|
"shared"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "Shared",
|
||||||
|
"recommendation_profile": "shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household",
|
||||||
|
"subject": "jellyfin:shared",
|
||||||
|
"watch_user": "shared"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"key": "shared",
|
||||||
|
"name": "Shared"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mindel",
|
||||||
|
"name": "Mindel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_profile": "shared"
|
||||||
|
},
|
||||||
|
"audiences": {
|
||||||
|
"audiences": [
|
||||||
|
{
|
||||||
|
"is_anime": true,
|
||||||
|
"libraries": [
|
||||||
|
"Shared Anime"
|
||||||
|
],
|
||||||
|
"media_type": "tv",
|
||||||
|
"owner": "shared",
|
||||||
|
"users": [
|
||||||
|
"shared",
|
||||||
|
"mindel"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feed": {
|
||||||
|
"profile": {
|
||||||
|
"key": "shared",
|
||||||
|
"name": "Shared"
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"coverage": {
|
||||||
|
"complete": true,
|
||||||
|
"excluded": 0,
|
||||||
|
"matched": 1,
|
||||||
|
"total": 1,
|
||||||
|
"unresolved": 0
|
||||||
|
},
|
||||||
|
"facts": [],
|
||||||
|
"genre_id": 0,
|
||||||
|
"media_type": "all",
|
||||||
|
"service_id": 0,
|
||||||
|
"unresolved": []
|
||||||
|
},
|
||||||
|
"state": "current",
|
||||||
|
"outcome": "ranked",
|
||||||
|
"materialization": "current",
|
||||||
|
"movies": [
|
||||||
|
{
|
||||||
|
"backdrop_path": "/moon-wide.jpg",
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"added": false,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Solaris",
|
||||||
|
"tmdb_id": 33,
|
||||||
|
"watched": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_path": "/moon.jpg",
|
||||||
|
"score": 4.2,
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"tvdb_id": 0,
|
||||||
|
"year": 2009
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shows": [],
|
||||||
|
"expires_at": "2099-11-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Comedy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"name": "Animation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"observations": [],
|
||||||
|
"region": "NL",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "Netflix"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "observed"
|
||||||
|
},
|
||||||
|
"discover": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"tmdb_id": 43,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Arrival",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"observations": [],
|
||||||
|
"page": 1,
|
||||||
|
"query": "",
|
||||||
|
"source": "general",
|
||||||
|
"state": "observed",
|
||||||
|
"total_pages": 2,
|
||||||
|
"total_results": 24
|
||||||
|
},
|
||||||
|
"dismissals": {
|
||||||
|
"profile": "shared",
|
||||||
|
"dismissals": [
|
||||||
|
{
|
||||||
|
"hidden_at": "2026-11-05T12:00:00Z",
|
||||||
|
"hidden_by": "shared",
|
||||||
|
"media_type": "movie",
|
||||||
|
"poster_path": "/hidden.jpg",
|
||||||
|
"title": "Hidden film",
|
||||||
|
"tmdb_id": 99,
|
||||||
|
"year": 2017
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ratings": {
|
||||||
|
"ratings": [
|
||||||
|
{
|
||||||
|
"source": "rt_critics",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 90,
|
||||||
|
"status": "Certified Fresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rt_audience",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 89,
|
||||||
|
"status": "Upright"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "imdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.8,
|
||||||
|
"votes": 300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.6,
|
||||||
|
"votes": 4000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"genres": [
|
||||||
|
"Comedy"
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"genre_state": "observed",
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Comedy"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"provider_state": "observed",
|
||||||
|
"providers": [],
|
||||||
|
"region": "NL",
|
||||||
|
"tmdb_id": 42
|
||||||
|
},
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"observations": [],
|
||||||
|
"ratings": [
|
||||||
|
{
|
||||||
|
"source": "rt_critics",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 90,
|
||||||
|
"status": "Certified Fresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rt_audience",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 89,
|
||||||
|
"status": "Upright"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "imdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.8,
|
||||||
|
"votes": 300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.6,
|
||||||
|
"votes": 4000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runtime": 97,
|
||||||
|
"seasons": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"name": "Season 1",
|
||||||
|
"overview": "A missing child.",
|
||||||
|
"episode_count": 10,
|
||||||
|
"recorded_episodes": 0,
|
||||||
|
"coverage": "none",
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "observed",
|
||||||
|
"title": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Moon",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enrichment": {
|
||||||
|
"genres": [
|
||||||
|
"Comedy"
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"genre_state": "observed",
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Comedy"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"provider_state": "observed",
|
||||||
|
"providers": [],
|
||||||
|
"region": "NL",
|
||||||
|
"tmdb_id": 42
|
||||||
|
},
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"observations": [],
|
||||||
|
"ratings": [
|
||||||
|
{
|
||||||
|
"source": "rt_critics",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 90,
|
||||||
|
"status": "Certified Fresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rt_audience",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 89,
|
||||||
|
"status": "Upright"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "imdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.8,
|
||||||
|
"votes": 300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.6,
|
||||||
|
"votes": 4000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runtime": 97,
|
||||||
|
"seasons": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"name": "Season 1",
|
||||||
|
"overview": "A missing child.",
|
||||||
|
"episode_count": 10,
|
||||||
|
"recorded_episodes": 0,
|
||||||
|
"coverage": "none",
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "observed",
|
||||||
|
"title": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Moon",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"media_type": "movie",
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42
|
||||||
|
},
|
||||||
|
"cast": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"source": "seerr",
|
||||||
|
"paging": "unpaged",
|
||||||
|
"credit_context": "movie-character",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"performer_id": 216,
|
||||||
|
"name": "Sam Rockwell",
|
||||||
|
"character": "Sam Bell",
|
||||||
|
"credit_id": "cast-216",
|
||||||
|
"order": 0,
|
||||||
|
"profile_url": "https://images.example.invalid/sam.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"season": {
|
||||||
|
"tmdb_id": 44,
|
||||||
|
"media_type": "tv",
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"season": {
|
||||||
|
"number": 1,
|
||||||
|
"name": "Season 1",
|
||||||
|
"overview": "A missing child.",
|
||||||
|
"episode_count": 10,
|
||||||
|
"recorded_episodes": 0,
|
||||||
|
"coverage": "none",
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked"
|
||||||
|
},
|
||||||
|
"episodes": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"name": "Secrets",
|
||||||
|
"overview": "A child disappears.",
|
||||||
|
"recorded": "missing",
|
||||||
|
"vote_average": 8.1,
|
||||||
|
"vote_count": 28
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"observations": []
|
||||||
|
},
|
||||||
|
"performer": {
|
||||||
|
"performer_id": 216,
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"source": "seerr",
|
||||||
|
"name": "Sam Rockwell",
|
||||||
|
"biography": "Actor.",
|
||||||
|
"also_known_as": []
|
||||||
|
},
|
||||||
|
"filmography": {
|
||||||
|
"performer_id": 216,
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"source": "seerr",
|
||||||
|
"source_paging": "unpaged",
|
||||||
|
"completeness": "returned-non-adult-cast",
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Moon",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"credits": [
|
||||||
|
{
|
||||||
|
"credit_id": "cast-216",
|
||||||
|
"character": "Sam Bell"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visibility_state": "unknown",
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"observations": []
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"actor": {
|
||||||
|
"name": "Shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household"
|
||||||
|
},
|
||||||
|
"filed_by_you": true,
|
||||||
|
"id": "request:7",
|
||||||
|
"media_type": "movie",
|
||||||
|
"on_behalf": true,
|
||||||
|
"requester": {
|
||||||
|
"key": "mindel",
|
||||||
|
"name": "Mindel"
|
||||||
|
},
|
||||||
|
"seasons": [],
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"name": "request",
|
||||||
|
"source": "conductarr",
|
||||||
|
"state": "done",
|
||||||
|
"summary": "Request accepted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acquisition",
|
||||||
|
"source": "sonarr",
|
||||||
|
"state": "in_progress",
|
||||||
|
"summary": "Downloading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "visibility",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not yet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "playback",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not checked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"blocking_stage": "acquisition"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requester": "all",
|
||||||
|
"sources": [],
|
||||||
|
"truncated": false
|
||||||
|
},
|
||||||
|
"readiness": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"viewer": {
|
||||||
|
"key": "mindel",
|
||||||
|
"name": "Mindel"
|
||||||
|
},
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"name": "request",
|
||||||
|
"source": "conductarr",
|
||||||
|
"state": "done",
|
||||||
|
"summary": "Request accepted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acquisition",
|
||||||
|
"source": "sonarr",
|
||||||
|
"state": "in_progress",
|
||||||
|
"summary": "Downloading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "visibility",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not yet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "playback",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not checked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"seerr": {
|
||||||
|
"state": "observed",
|
||||||
|
"requests": []
|
||||||
|
},
|
||||||
|
"coverage": [],
|
||||||
|
"open_link": {
|
||||||
|
"state": "missing"
|
||||||
|
},
|
||||||
|
"sources": []
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"actor": {
|
||||||
|
"name": "Shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household"
|
||||||
|
},
|
||||||
|
"attempts": 1,
|
||||||
|
"coverage": [],
|
||||||
|
"created_at": "2026-11-05T12:00:00Z",
|
||||||
|
"id": 7,
|
||||||
|
"media_type": "movie",
|
||||||
|
"membership": {
|
||||||
|
"status": "added"
|
||||||
|
},
|
||||||
|
"on_behalf": true,
|
||||||
|
"requester": "mindel",
|
||||||
|
"requester_username": "Mindel",
|
||||||
|
"seasons": null,
|
||||||
|
"seerr": {
|
||||||
|
"outcome": "requested",
|
||||||
|
"request_id": 9
|
||||||
|
},
|
||||||
|
"source": "recommendation",
|
||||||
|
"state": "settled",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"updated_at": "2026-11-05T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"receipt": {
|
||||||
|
"actor": {
|
||||||
|
"name": "Shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household"
|
||||||
|
},
|
||||||
|
"attempts": 1,
|
||||||
|
"coverage": [],
|
||||||
|
"created_at": "2026-11-05T12:00:00Z",
|
||||||
|
"id": 7,
|
||||||
|
"media_type": "movie",
|
||||||
|
"membership": {
|
||||||
|
"status": "added"
|
||||||
|
},
|
||||||
|
"on_behalf": true,
|
||||||
|
"requester": "mindel",
|
||||||
|
"requester_username": "Mindel",
|
||||||
|
"seasons": null,
|
||||||
|
"seerr": {
|
||||||
|
"outcome": "requested",
|
||||||
|
"request_id": 9
|
||||||
|
},
|
||||||
|
"source": "recommendation",
|
||||||
|
"state": "settled",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"updated_at": "2026-11-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"hide": {
|
||||||
|
"created": true,
|
||||||
|
"dismissal": {
|
||||||
|
"hidden_at": "2026-11-05T12:00:00Z",
|
||||||
|
"hidden_by": "shared",
|
||||||
|
"media_type": "movie",
|
||||||
|
"poster_path": "/hidden.jpg",
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"year": 2017
|
||||||
|
},
|
||||||
|
"materialization": "current",
|
||||||
|
"profile": "shared"
|
||||||
|
},
|
||||||
|
"restore": {
|
||||||
|
"media_type": "movie",
|
||||||
|
"profile": "shared",
|
||||||
|
"restored": true,
|
||||||
|
"tmdb_id": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.LibraryAudience
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.Options
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.Page
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
/** One lane can interleave movie and TV reads without exposing transport pages on screen. */
|
||||||
|
internal data class CatalogueLaneQuery(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val media: List<MediaType>,
|
||||||
|
val sort: DiscoverSort = DiscoverSort.POPULAR,
|
||||||
|
val service: Long? = null,
|
||||||
|
val genres: Map<MediaType, Long> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */
|
||||||
|
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>, private val audiences: () -> List<LibraryAudience>) {
|
||||||
|
private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean)
|
||||||
|
private val sources = mutableMapOf<String, SourcePage>()
|
||||||
|
private val visited = mutableSetOf<String>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun saved(): List<DiscoverLane> = mutex.withLock {
|
||||||
|
queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) }
|
||||||
|
lanes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun through(index: Int, force: Boolean = false): List<DiscoverLane> = mutex.withLock {
|
||||||
|
queries.take((index + 1).coerceIn(0, queries.size)).forEach { query ->
|
||||||
|
if (force || query.id !in visited) {
|
||||||
|
load(query, if (force) ReadTrigger.FORCE else ReadTrigger.IF_STALE, false)
|
||||||
|
visited.add(query.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lanes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun more(id: String): List<DiscoverLane> = mutex.withLock {
|
||||||
|
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) }
|
||||||
|
lanes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load(query: CatalogueLaneQuery, trigger: ReadTrigger, next: Boolean) = coroutineScope {
|
||||||
|
val updates = query.media.map { media -> async {
|
||||||
|
val key = "${query.id}:${media.name}"
|
||||||
|
val previous = sources[key]
|
||||||
|
if (next && previous?.last == true && !previous.failed) return@async key to previous
|
||||||
|
val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1
|
||||||
|
val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort)
|
||||||
|
val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull()
|
||||||
|
val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
|
||||||
|
if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null
|
||||||
|
val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) }
|
||||||
|
val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items
|
||||||
|
key to SourcePage(if (saved != null) page else previous?.number ?: 1,
|
||||||
|
saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(),
|
||||||
|
result is ReadResult.Failure || saved?.state == Page.State.PARTIAL,
|
||||||
|
saved?.totalResults?.toInt() ?: previous?.total ?: 0,
|
||||||
|
next && saved != null && titles.size == previous?.titles?.size && saved.page < saved.totalPages)
|
||||||
|
} }.map { it.await() }
|
||||||
|
updates.forEach { (key, value) -> if (value != null) sources[key] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lanes(): List<DiscoverLane> = queries.map { query ->
|
||||||
|
val pages = query.media.mapNotNull { sources["${query.id}:${it.name}"] }
|
||||||
|
val items = pages.flatMap { it.titles }.distinctBy { it.key }
|
||||||
|
val pending = pages.isEmpty() && query.id !in visited
|
||||||
|
DiscoverLane(query.id, query.name, items, total = pages.sumOf { it.total }, tail = when {
|
||||||
|
pages.any { it.failed } -> DiscoverTail.Retry
|
||||||
|
pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore
|
||||||
|
pages.any { !it.last } -> DiscoverTail.SeeAll
|
||||||
|
else -> DiscoverTail.None
|
||||||
|
}, pending = pending, hasMore = pages.any { !it.last })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option ids, not guessed service ids, determine every catalogue parameter. */
|
||||||
|
internal fun catalogueQueries(options: List<Options>, service: DiscoverService? = null, type: String? = null): List<CatalogueLaneQuery> {
|
||||||
|
val media = if (type == null) MediaType.entries else listOf(if (type == "movie") MediaType.MOVIE else MediaType.TV)
|
||||||
|
val noun = if (type == "movie") "movies" else "series"
|
||||||
|
val genres = options.flatMap { option -> option.genres.orEmpty().map { it.name to (MediaType.valueOf(option.mediaType.name) to it.id) } }
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
if (service != null) return listOf(
|
||||||
|
CatalogueLaneQuery("sv-pop", "Popular $noun", media, service = service.id),
|
||||||
|
CatalogueLaneQuery("sv-new", "New $noun", media, DiscoverSort.NEW, service.id),
|
||||||
|
CatalogueLaneQuery("sv-top", "Top rated $noun", media, DiscoverSort.TOP, service.id),
|
||||||
|
) + genres.entries.filter { entry -> entry.value.any { it.first in media } }.take(3).map { (name, ids) ->
|
||||||
|
CatalogueLaneQuery("sv-g-$name", "$name $noun", media.filter { item -> ids.any { it.first == item } }, service = service.id, genres = ids.toMap())
|
||||||
|
}
|
||||||
|
val services = options.flatMap { it.services.orEmpty() }.distinctBy { it.id }
|
||||||
|
return services.map { CatalogueLaneQuery("new-${it.id}", "New on ${it.name}", media, DiscoverSort.NEW, it.id) } +
|
||||||
|
CatalogueLaneQuery("trending", "Trending", media) +
|
||||||
|
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").mapNotNull { name ->
|
||||||
|
genres[name]?.let { ids -> CatalogueLaneQuery("genre-$name", name,
|
||||||
|
ids.map { it.first }.distinct(), genres = ids.toMap()) }
|
||||||
|
} + CatalogueLaneQuery("soon", "Coming soon", media, DiscoverSort.UPCOMING)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.last
|
import kotlinx.coroutines.flow.last
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.justinvisser.wolfskin.conductarr.ClientError
|
import org.justinvisser.wolfskin.conductarr.ClientError
|
||||||
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
import org.justinvisser.wolfskin.conductarr.ConductarrClient
|
||||||
import org.justinvisser.wolfskin.conductarr.MediaType
|
import org.justinvisser.wolfskin.conductarr.MediaType
|
||||||
|
|
@ -22,44 +23,64 @@ import org.justinvisser.wolfskin.design.RequestPhase
|
||||||
class ConductarrDiscoverOperations(
|
class ConductarrDiscoverOperations(
|
||||||
private val client: ConductarrClient,
|
private val client: ConductarrClient,
|
||||||
private val refreshSnapshot: suspend () -> DiscoverSnapshot,
|
private val refreshSnapshot: suspend () -> DiscoverSnapshot,
|
||||||
|
private val profile: String? = null,
|
||||||
|
private val allowedRequesters: Set<String>? = null,
|
||||||
|
private val canRequest: Boolean = true,
|
||||||
) : DiscoverOperations {
|
) : DiscoverOperations {
|
||||||
|
private val submissions = mutableSetOf<Pair<String, String>>()
|
||||||
|
private val requestMutex = kotlinx.coroutines.sync.Mutex()
|
||||||
|
private val dismissalMutex = kotlinx.coroutines.sync.Mutex()
|
||||||
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult {
|
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult {
|
||||||
val title = intent.title
|
requestMutex.lock()
|
||||||
require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" }
|
try {
|
||||||
val body = RequestIntentBody(
|
val title = intent.title
|
||||||
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
|
if (!canRequest || allowedRequesters?.contains(intent.requester) == false ||
|
||||||
recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester,
|
(title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused)
|
||||||
seasons = title.seasons?.map(Int::toLong),
|
val key = title.key to intent.requester
|
||||||
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
|
val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()
|
||||||
tmdbId = title.tmdb,
|
if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
)
|
val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull {
|
||||||
return when (val result = client.mutate(TvRoutes.request(body))) {
|
it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong)))
|
||||||
is MutationResult.Success -> result.value.outcome()
|
}
|
||||||
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed)
|
if (existing != null) return existing.outcome()
|
||||||
}
|
if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
|
val body = RequestIntentBody(
|
||||||
|
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
|
||||||
|
recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester,
|
||||||
|
seasons = title.seasons?.map(Int::toLong),
|
||||||
|
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
|
||||||
|
tmdbId = title.tmdb,
|
||||||
|
)
|
||||||
|
return when (val result = client.mutate(TvRoutes.request(body))) {
|
||||||
|
is MutationResult.Success -> result.value.outcome()
|
||||||
|
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed)
|
||||||
|
}
|
||||||
|
} finally { requestMutex.unlock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
|
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
|
||||||
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
||||||
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
|
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull {
|
||||||
|
it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong)))
|
||||||
|
}?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
|
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean {
|
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock {
|
||||||
val result = client.mutate(TvRoutes.hide(profile,
|
val result = client.mutate(TvRoutes.hide(this.profile ?: profile,
|
||||||
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
|
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
|
||||||
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong())))
|
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong())))
|
||||||
return result is MutationResult.Success || observedHidden(profile, title) == true
|
result is MutationResult.Success || observedHidden(profile, title) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean {
|
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock {
|
||||||
val result = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType()))
|
val result = client.mutate(TvRoutes.restore(this.profile ?: profile, title.tmdb, title.mediaType()))
|
||||||
return result is MutationResult.Success || observedHidden(profile, title) == false
|
result is MutationResult.Success || observedHidden(profile, title) == false
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? {
|
private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? {
|
||||||
return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
||||||
is ReadResult.Value -> result.saved.value?.dismissals?.any {
|
is ReadResult.Value -> result.saved.value?.dismissals?.any {
|
||||||
it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name
|
it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import org.justinvisser.wolfskin.conductarr.CacheEntry
|
||||||
|
import org.justinvisser.wolfskin.conductarr.CacheStore
|
||||||
|
import org.justinvisser.wolfskin.conductarr.FileCacheStore
|
||||||
|
import org.justinvisser.wolfskin.conductarr.PersistentDns
|
||||||
|
|
||||||
|
/** Advanced-only endpoint and fixture controls contain no credentials. */
|
||||||
|
object ConductarrPreference {
|
||||||
|
private var preferences: SharedPreferences? = null
|
||||||
|
var baseUrl by mutableStateOf(BuildConfig.CONDUCTARR_BASE_URL)
|
||||||
|
private set
|
||||||
|
var fixtures by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||||
|
baseUrl = preferences?.getString("wolfskin.conductarr.url", null)?.takeIf { it.isNotBlank() } ?: BuildConfig.CONDUCTARR_BASE_URL
|
||||||
|
fixtures = BuildConfig.DEBUG && preferences?.getBoolean("wolfskin.fixtures", false) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBaseUrl(value: String) {
|
||||||
|
val normalized = value.trim().ifBlank { BuildConfig.CONDUCTARR_BASE_URL }
|
||||||
|
baseUrl = normalized
|
||||||
|
preferences?.edit { putString("wolfskin.conductarr.url", normalized) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFixtures(value: Boolean) {
|
||||||
|
fixtures = BuildConfig.DEBUG && value
|
||||||
|
preferences?.edit { putBoolean("wolfskin.fixtures", fixtures) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Household snapshots must not enter Android backup or shared storage. */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object ConductarrModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope, dns: PersistentDns): ConductarrSession =
|
||||||
|
ConductarrSession(ConductarrDiskCache(context), scope, dns)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
|
||||||
|
private class ConductarrDiskCache(context: Context) : CacheStore {
|
||||||
|
private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) }
|
||||||
|
override fun read(scope: String, key: String) = store.read(scope, key)
|
||||||
|
override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity)
|
||||||
|
override fun remove(scope: String, key: String) = store.remove(scope, key)
|
||||||
|
override fun clear(scope: String) = store.clear(scope)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.*
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
/** Relative TMDB art paths are not app or Conductarr-relative URLs. */
|
||||||
|
internal fun catalogueImage(path: String?): String? = path?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
if (it.startsWith("/")) "https://image.tmdb.org/t/p/w780$it" else it
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Candidate.discoverTitle(genres: Map<Long, String>): DiscoverTitle = DiscoverTitle(
|
||||||
|
tmdbId, mediaType.name.lowercase(), title, year.toInt(), genreIds.orEmpty().mapNotNull(genres::get), overview,
|
||||||
|
catalogueImage(posterPath), catalogueImage(backdropPath), score = score,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun Title.discoverTitle(audiences: List<LibraryAudience>): DiscoverTitle = DiscoverTitle(
|
||||||
|
tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview,
|
||||||
|
poster = posterUrl, backdrop = backdropUrl,
|
||||||
|
heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() },
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
|
||||||
|
if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||||
|
if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true))
|
||||||
|
if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable))
|
||||||
|
val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) ->
|
||||||
|
picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items ->
|
||||||
|
HomeRow("pk-$type", HomeRowKind.Shelf, label, items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane ->
|
||||||
|
if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true)
|
||||||
|
else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title,
|
||||||
|
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) }
|
||||||
|
} + HomeRow("explore", HomeRowKind.Explore)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle(
|
||||||
|
id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds, seasons = seasonCount,
|
||||||
|
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */
|
||||||
|
internal fun RequestProgress.comingIn(): ComingInRequest {
|
||||||
|
val stagesByName = stages.orEmpty().associateBy { it.name }
|
||||||
|
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
|
||||||
|
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
|
||||||
|
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
|
||||||
|
val stage = when {
|
||||||
|
acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
|
||||||
|
acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet
|
||||||
|
playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth
|
||||||
|
visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere
|
||||||
|
visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary
|
||||||
|
visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded
|
||||||
|
stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed
|
||||||
|
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
|
||||||
|
awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
|
||||||
|
acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
|
||||||
|
visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
|
||||||
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
|
||||||
|
acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
|
||||||
|
acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting
|
||||||
|
else -> ComingInStage.Checking
|
||||||
|
}
|
||||||
|
val detail = when (stage) {
|
||||||
|
ComingInStage.FileHealth -> playback
|
||||||
|
ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility
|
||||||
|
ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED }
|
||||||
|
else -> acquisition
|
||||||
|
}
|
||||||
|
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
|
||||||
|
requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
|
||||||
|
readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expanded readiness describes each observed stage, not a synthetic download failure. */
|
||||||
|
private fun ReadinessStage?.readinessWord(): String = when {
|
||||||
|
this == null -> "Unknown"
|
||||||
|
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention"
|
||||||
|
name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed"
|
||||||
|
name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed"
|
||||||
|
name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet"
|
||||||
|
name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present"
|
||||||
|
name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file"
|
||||||
|
name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library"
|
||||||
|
name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library"
|
||||||
|
name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading"
|
||||||
|
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play"
|
||||||
|
name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet"
|
||||||
|
else -> when (state) {
|
||||||
|
ReadinessStage.State.DONE -> "Done"
|
||||||
|
ReadinessStage.State.FAILED -> "Failed"
|
||||||
|
ReadinessStage.State.IN_PROGRESS -> "In progress"
|
||||||
|
ReadinessStage.State.PARTIAL -> "Partial"
|
||||||
|
ReadinessStage.State.NONE -> "Not yet"
|
||||||
|
ReadinessStage.State.UNKNOWN -> "Unknown"
|
||||||
|
ReadinessStage.State.STALE -> "Last observed"
|
||||||
|
ReadinessStage.State.CONFLICTING -> "Check needed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.*
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MediaType
|
||||||
|
|
||||||
|
/** Screen projections share the identity-scoped transport, never a second HTTP or credential path. */
|
||||||
|
class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider by ClientTitleProvider(client) {
|
||||||
|
private var catalogue: ConductarrCatalogue? = null
|
||||||
|
private var options = emptyList<Options>()
|
||||||
|
private val services = mutableMapOf<String, ConductarrCatalogue>()
|
||||||
|
@Volatile private var libraryAudiences = emptyList<LibraryAudience>()
|
||||||
|
private var requestOperations: ConductarrDiscoverOperations? = null
|
||||||
|
private val profilesMutex = Mutex()
|
||||||
|
private var observedProfiles: RecommendationProfiles? = null
|
||||||
|
private val profileNames = MutableStateFlow<List<ReadProfile>>(emptyList())
|
||||||
|
val requesterProfiles = profileNames.asStateFlow()
|
||||||
|
|
||||||
|
/** One uncertain-submission ledger survives screen/navigation changes for this identity. */
|
||||||
|
fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) {
|
||||||
|
requestOperations ?: ConductarrDiscoverOperations(client, {
|
||||||
|
profiles()
|
||||||
|
discover(operator.recommendationProfile, force = true).lastOrNull()
|
||||||
|
?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||||
|
}, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia)
|
||||||
|
.also { requestOperations = it }
|
||||||
|
}
|
||||||
|
fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow {
|
||||||
|
val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY)
|
||||||
|
if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved)
|
||||||
|
emit(discoverSnapshot(profile, if (force) ReadTrigger.FORCE else ReadTrigger.OPEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun discoverSnapshot(profile: String, trigger: ReadTrigger): DiscoverSnapshot = coroutineScope {
|
||||||
|
val reads = SnapshotReads(client, trigger)
|
||||||
|
val feedJob = async { reads.read(TvRoutes.feed(profile)) }
|
||||||
|
val hiddenJob = async { reads.read(TvRoutes.dismissals(profile)) }
|
||||||
|
val optionsJobs = MediaType.entries.map { media -> async { reads.read(TvRoutes.options(media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.FOREGROUND) } }
|
||||||
|
val feed = feedJob.await()
|
||||||
|
val options = optionsJobs.mapNotNull { it.await() }
|
||||||
|
this@ConductarrProvider.options = options
|
||||||
|
val queries = catalogueQueries(options)
|
||||||
|
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
||||||
|
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
||||||
|
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||||
|
val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
|
||||||
|
val picks = candidates.map { it.discoverTitle(genres) }
|
||||||
|
val seeds = candidates.flatMapIndexed { index, candidate ->
|
||||||
|
candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
|
||||||
|
}.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) ->
|
||||||
|
DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second },
|
||||||
|
watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added })
|
||||||
|
}
|
||||||
|
val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
|
||||||
|
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
|
||||||
|
}
|
||||||
|
val status = when {
|
||||||
|
reads.needsSignIn -> DiscoverStatus.NeedsSignIn
|
||||||
|
feed == null && lanes.all { it.titles.isEmpty() } -> DiscoverStatus.Unavailable
|
||||||
|
feed?.outcome == RecommendationFeed.Outcome.NO_SEEDS -> DiscoverStatus.NoSeeds
|
||||||
|
feed == null || feed.state == RecommendationFeed.State.UNAVAILABLE -> DiscoverStatus.PicksUnavailable
|
||||||
|
else -> DiscoverStatus.Ready
|
||||||
|
}
|
||||||
|
DiscoverSnapshot(picks, seeds, options.flatMap { it.services.orEmpty() }.distinctBy { it.id }.map { DiscoverService(it.id, it.name, discoverServiceHue(it.name)) },
|
||||||
|
lanes, hidden, status, reads.savedTime(), reachable = !reads.failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read two shelves ahead; pagination is keyed by the exact service, genre and media parameters. */
|
||||||
|
suspend fun lanesThrough(index: Int): List<DiscoverLane> = catalogue?.through(index).orEmpty()
|
||||||
|
suspend fun more(lane: String): List<DiscoverLane> = catalogue?.more(lane).orEmpty()
|
||||||
|
suspend fun readAhead(lane: String?): List<DiscoverLane> {
|
||||||
|
val loader = catalogue ?: return emptyList()
|
||||||
|
val index = loader.queries.indexOfFirst { it.id == lane }
|
||||||
|
return loader.through(index + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> {
|
||||||
|
val key = "${service.id}:$type"
|
||||||
|
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } }
|
||||||
|
return if (more != null) loader.more(more) else loader.through(through)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requests(): Flow<ComingInSnapshot> = flow {
|
||||||
|
for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.FOREGROUND)) {
|
||||||
|
val reads = SnapshotReads(client, trigger)
|
||||||
|
val feed = reads.read(TvRoutes.activity())
|
||||||
|
if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue
|
||||||
|
val artReads = SnapshotReads(client, trigger)
|
||||||
|
val items = coroutineScope {
|
||||||
|
feed?.items.orEmpty().map { item -> async {
|
||||||
|
val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV
|
||||||
|
val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE)
|
||||||
|
item.comingIn().copy(poster = art?.posterUrl)
|
||||||
|
} }.map { it.await() }
|
||||||
|
}
|
||||||
|
emit(ComingInSnapshot(items,
|
||||||
|
if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready,
|
||||||
|
partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null,
|
||||||
|
savedAt = reads.savedTime()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock {
|
||||||
|
observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also {
|
||||||
|
observedProfiles = it
|
||||||
|
profileNames.value = it.profiles.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */
|
||||||
|
suspend fun audiences(): List<LibraryAudience>? {
|
||||||
|
val reads = SnapshotReads(client, ReadTrigger.IF_STALE)
|
||||||
|
val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() }
|
||||||
|
if (reads.failed || observed == null) return null
|
||||||
|
libraryAudiences = observed
|
||||||
|
return observed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */
|
||||||
|
internal fun ConductarrIdentity.requesters(profiles: List<ReadProfile>, saved: List<ShellPerson>): List<ShellPerson> {
|
||||||
|
val allowed = operator?.capabilities?.requesters.orEmpty()
|
||||||
|
return buildMap {
|
||||||
|
saved.filter { it.key in allowed }.forEach { put(it.key, it) }
|
||||||
|
profiles.filter { it.key in allowed }.forEach { profile ->
|
||||||
|
put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false))
|
||||||
|
}
|
||||||
|
}.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A batch tracks source failure without replacing saved bodies with empty success. */
|
||||||
|
internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) {
|
||||||
|
@Volatile var failed = false
|
||||||
|
private set
|
||||||
|
@Volatile var needsSignIn = false
|
||||||
|
private set
|
||||||
|
private var oldestSaved: Long? = null
|
||||||
|
|
||||||
|
suspend fun <T> read(call: ReadCall<T>, event: ReadTrigger = trigger): T? {
|
||||||
|
return when (val result = client.read(call, event).lastOrNull()) {
|
||||||
|
is ReadResult.Value -> result.saved.value
|
||||||
|
is ReadResult.Failure -> {
|
||||||
|
failed = true
|
||||||
|
if (result.error == ClientError.NeedsSignIn) needsSignIn = true
|
||||||
|
result.saved?.let { saved -> synchronized(this) { oldestSaved = minOf(oldestSaved ?: Long.MAX_VALUE, saved.savedAtMillis) } }
|
||||||
|
result.saved?.value
|
||||||
|
}
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savedTime(): String? = oldestSaved?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.RtMark
|
||||||
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.Rating
|
||||||
|
import org.justinvisser.wolfskin.design.LibraryRating
|
||||||
|
import org.justinvisser.wolfskin.design.ScreenRatings
|
||||||
|
|
||||||
|
/** Failed reads are not cached as absence; saved observations survive offline. */
|
||||||
|
class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings {
|
||||||
|
override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List<LibraryRating>? {
|
||||||
|
val media = if (type == "movie") MediaType.MOVIE else MediaType.TV
|
||||||
|
val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE
|
||||||
|
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) {
|
||||||
|
is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type)
|
||||||
|
is ReadResult.Failure -> if (result.error == ClientError.NeedsSignIn) return emptyList() else result.saved?.value?.ratings?.screenRatings(type) ?: return null
|
||||||
|
null -> return null
|
||||||
|
}
|
||||||
|
if (type != "episode" || season == null || episode == null) return ratings
|
||||||
|
val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull()
|
||||||
|
if (result is ReadResult.Failure && result.error == ClientError.NeedsSignIn) return emptyList()
|
||||||
|
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
|
||||||
|
val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() }
|
||||||
|
return ratings.withEpisode(vote?.voteAverage, vote?.voteCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<LibraryRating>.withEpisode(score: Double?, votes: Long?): List<LibraryRating> {
|
||||||
|
val observed = SourceRatings.episodeScore(score, votes) ?: return this
|
||||||
|
return filterNot { it.source == "tmdb" } + LibraryRating("tmdb", observed, episode = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Use the app's existing source precedence and label rules, not a second interpretation. */
|
||||||
|
internal fun List<Rating>.screenRatings(type: String): List<LibraryRating> {
|
||||||
|
val observed = filter { it.state == Rating.State.OBSERVED }
|
||||||
|
fun source(source: Rating.Source) = observed.firstOrNull { it.source == source }
|
||||||
|
val critics = source(Rating.Source.RT_CRITICS)
|
||||||
|
val audience = source(Rating.Source.RT_AUDIENCE)
|
||||||
|
val imdb = source(Rating.Source.IMDB).takeIf { type == "movie" }
|
||||||
|
val tmdb = source(Rating.Source.TMDB).takeIf { (it?.votes ?: 0) > 0 }
|
||||||
|
val ratings = SourceRatings.of(critics?.score, critics?.status, audience?.score, audience?.status,
|
||||||
|
imdb?.score, imdb?.votes, tmdb?.score, tmdb?.votes)
|
||||||
|
fun RtMark.label(): String? = when (this) {
|
||||||
|
RtMark.FRESH -> "Fresh"; RtMark.ROTTEN -> "Rotten"; RtMark.UPRIGHT -> "Upright"; RtMark.SPILLED -> "Spilled"; RtMark.UNKNOWN -> null
|
||||||
|
}
|
||||||
|
return buildList {
|
||||||
|
ratings.rtCritics?.let { add(LibraryRating("rtCritics", it.percent.toDouble(), it.mark.label())) }
|
||||||
|
ratings.rtAudience?.let { add(LibraryRating("rtAudience", it.percent.toDouble(), it.mark.label())) }
|
||||||
|
ratings.imdb?.let { add(LibraryRating("imdb", it)) }
|
||||||
|
ratings.tmdb?.let { add(LibraryRating("tmdb", it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.Operator
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/** A selected Jellyfin login owns every provider and all in-memory observations. */
|
||||||
|
data class ConductarrIdentity(
|
||||||
|
val person: String,
|
||||||
|
val client: ConductarrClient,
|
||||||
|
val provider: ConductarrProvider,
|
||||||
|
val operator: Operator? = null,
|
||||||
|
val error: ClientError? = null,
|
||||||
|
val signedIn: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
|
||||||
|
class ConductarrSession(
|
||||||
|
private val cache: CacheStore,
|
||||||
|
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val dns: PersistentDns? = null,
|
||||||
|
) : Closeable {
|
||||||
|
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
|
||||||
|
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
|
||||||
|
private var selection: ByteArray? = null
|
||||||
|
@Volatile private var generation = 0L
|
||||||
|
|
||||||
|
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
|
||||||
|
val fingerprint = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
|
||||||
|
val previous: ConductarrClient?
|
||||||
|
val currentGeneration: Long
|
||||||
|
synchronized(this) {
|
||||||
|
if (!retry && selection?.contentEquals(fingerprint) == true) return
|
||||||
|
generation++
|
||||||
|
currentGeneration = generation
|
||||||
|
previous = mutableIdentity.value?.client
|
||||||
|
mutableIdentity.value = null
|
||||||
|
selection = fingerprint
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val client = if (token.isNullOrBlank()) null else try {
|
||||||
|
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), dns = dns, onEvent = { event ->
|
||||||
|
if (event == ClientEvent.NEEDS_SIGN_IN) {
|
||||||
|
mutableIdentity.update {
|
||||||
|
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (_: IllegalArgumentException) { null }
|
||||||
|
synchronized(this) {
|
||||||
|
if (generation == currentGeneration) {
|
||||||
|
mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) }
|
||||||
|
} else dispose(client)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
dispose(previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
|
||||||
|
suspend fun authenticate() {
|
||||||
|
val selected = mutableIdentity.value ?: return
|
||||||
|
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
|
||||||
|
when (result) {
|
||||||
|
is ReadResult.Value -> mutableIdentity.update {
|
||||||
|
if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it
|
||||||
|
}
|
||||||
|
is ReadResult.Failure -> mutableIdentity.update {
|
||||||
|
if (it?.client !== selected.client) it
|
||||||
|
else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error)
|
||||||
|
else it.copy(error = result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove read authority synchronously; socket disposal finishes independently on IO. */
|
||||||
|
@Synchronized
|
||||||
|
override fun close() {
|
||||||
|
generation++
|
||||||
|
val previous = mutableIdentity.value?.client
|
||||||
|
mutableIdentity.value = null
|
||||||
|
selection = null
|
||||||
|
dispose(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispose(client: ConductarrClient?) {
|
||||||
|
if (client == null) return
|
||||||
|
// The application scope outlives the screen replaced by a person switch.
|
||||||
|
disposalScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
client.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Timber.w("Conductarr client disposal failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Person ids partition disk entries even if a caller accidentally supplies the same credential twice. */
|
||||||
|
private class PersonCacheStore(private val store: CacheStore, private val person: String) : CacheStore {
|
||||||
|
override fun read(scope: String, key: String) = store.read("$person:$scope", key)
|
||||||
|
override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write("$person:$scope", key, entry, capacity)
|
||||||
|
override fun remove(scope: String, key: String) = store.remove("$person:$scope", key)
|
||||||
|
override fun clear(scope: String) = store.clear("$person:$scope")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.*
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
/** Conductarr never grants playback: only a resolved Jellyfin page can supply held/play identities. */
|
||||||
|
internal class ConductarrTitlePages(private val identity: ConductarrIdentity) {
|
||||||
|
fun title(tmdb: Long, media: MediaType, held: TitlePageData? = null): Flow<TitlePageData> = flow {
|
||||||
|
for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) {
|
||||||
|
val page = titleSnapshot(tmdb, media, held, trigger)
|
||||||
|
if (trigger != ReadTrigger.CACHE_ONLY || page.status == TitleReadStatus.Ready) emit(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun titleSnapshot(tmdb: Long, media: MediaType, held: TitlePageData?, trigger: ReadTrigger): TitlePageData = coroutineScope {
|
||||||
|
val client = identity.client
|
||||||
|
val reads = SnapshotReads(client, trigger)
|
||||||
|
val profile = identity.operator!!.recommendationProfile
|
||||||
|
val detailJob = async { reads.read(TvRoutes.title(tmdb, media)) }
|
||||||
|
val castJob = async { reads.read(TvRoutes.cast(tmdb, media)) }
|
||||||
|
val relatedJob = async { reads.read(TvRoutes.related(tmdb, media, 1)) }
|
||||||
|
val enrichmentJob = async { reads.read(TvRoutes.enrichment(tmdb, media)) }
|
||||||
|
val displayJob = async { reads.read(TvRoutes.display(tmdb, media)) }
|
||||||
|
val ratingsJob = async { reads.read(TvRoutes.ratings(tmdb, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) }
|
||||||
|
val readinessJob = async { reads.read(TvRoutes.readiness(tmdb, media, identity.operator.watchUser)) }
|
||||||
|
val receiptJob = async { reads.read(TvRoutes.requests(tmdb, media)) }
|
||||||
|
val feedJob = async { reads.read(TvRoutes.feed(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) }
|
||||||
|
val hiddenJob = async { reads.read(TvRoutes.dismissals(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) }
|
||||||
|
val seasonJobs = if (media == MediaType.TV && held != null) {
|
||||||
|
(listOfNotNull(held.selectedSeason) + listOfNotNull(held.playTarget, held.watchTarget).filter { it.kind == "episode" }.map { it.season })
|
||||||
|
.distinct().associateWith { number -> async { reads.read(TvRoutes.season(tmdb, number)) } }
|
||||||
|
} else emptyMap()
|
||||||
|
val detail = detailJob.await()
|
||||||
|
val enrichment = enrichmentJob.await()
|
||||||
|
val display = displayJob.await()
|
||||||
|
val raw = detail?.title ?: enrichment?.title
|
||||||
|
val mediaName = media.name.lowercase()
|
||||||
|
val sourceItem = raw?.titleItem(identity.provider) ?: TitleItem("$mediaName:$tmdb", display?.title.orEmpty(), mediaName, poster = display?.posterUrl, tmdbId = tmdb)
|
||||||
|
val genres = detail?.genres ?: enrichment?.genres
|
||||||
|
val seasons = detail?.seasons ?: enrichment?.seasons
|
||||||
|
val cast = castJob.await()
|
||||||
|
val related = relatedJob.await()?.items.orEmpty().map { it.titleItem(identity.provider) }
|
||||||
|
val readiness = readinessJob.await()
|
||||||
|
val receipt = receiptJob.await()?.requests.orEmpty().filter { it.requester == identity.operator.requester || it.actor.requester == identity.operator.requester }.maxByOrNull { it.id }
|
||||||
|
val stages = readiness?.stages.orEmpty().takeIf { receipt?.requester == identity.operator.requester }.orEmpty()
|
||||||
|
val stage = when {
|
||||||
|
receipt == null -> null
|
||||||
|
stages.any { it.state == ReadinessStage.State.FAILED } -> "Failed"
|
||||||
|
stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state == ReadinessStage.State.DONE } -> "Ready"
|
||||||
|
stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state in listOf(ReadinessStage.State.PARTIAL, ReadinessStage.State.IN_PROGRESS) } -> "Almost ready"
|
||||||
|
stages.any { it.name == ReadinessStage.Name.ACQUISITION && it.state == ReadinessStage.State.IN_PROGRESS } -> "Downloading"
|
||||||
|
receipt?.state == RequestReceipt.State.SETTLED -> "Requested"
|
||||||
|
receipt != null -> "Checking"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val feed = feedJob.await()
|
||||||
|
val hidden = hiddenJob.await()?.dismissals.orEmpty().any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName }
|
||||||
|
val votes = seasonJobs.mapValues { (_, job) -> job.await()?.episodes.orEmpty().associateBy { it.number } }
|
||||||
|
val observedRatings = ratingsJob.await()?.ratings.orEmpty()
|
||||||
|
val ratings = if (reads.needsSignIn) emptyList() else observedRatings.screenRatings(mediaName)
|
||||||
|
val item = (held?.item ?: sourceItem).copy(
|
||||||
|
runtimeSeconds = held?.item?.runtimeSeconds?.takeIf { it > 0 } ?: ((detail?.runtime ?: enrichment?.runtime ?: 0) * 60),
|
||||||
|
genres = genres ?: held?.item?.genres.orEmpty(), seasons = held?.item?.seasons ?: seasons.orEmpty().size,
|
||||||
|
ratings = ratings, tmdbId = tmdb,
|
||||||
|
)
|
||||||
|
fun rated(target: TitleItem): TitleItem {
|
||||||
|
val episode = votes[target.season]?.get(target.episode.toLong())
|
||||||
|
return target.copy(tmdbId = tmdb, ratings = if (reads.needsSignIn) emptyList() else if (target.kind == "episode") ratings.withEpisode(episode?.voteAverage, episode?.voteCount) else ratings)
|
||||||
|
}
|
||||||
|
val base = held ?: TitlePageData(item)
|
||||||
|
base.copy(item = item, watchTarget = rated(base.watchTarget), playTarget = base.playTarget?.let(::rated),
|
||||||
|
cast = cast?.items?.sortedBy { it.order }?.map { TitlePerson(it.performerId.toString(), it.name, it.character, catalogueImage(it.profileUrl)) } ?: base.cast,
|
||||||
|
castStatus = if (cast == null && base.cast.isEmpty()) TitleReadStatus.Unavailable else TitleReadStatus.Ready,
|
||||||
|
related = related, episodes = base.episodes.map(::rated),
|
||||||
|
status = if (raw != null || held != null || display?.title != null) TitleReadStatus.Ready else TitleReadStatus.Unavailable,
|
||||||
|
connection = when { reads.needsSignIn -> TitleConnection.SignedOut; reads.failed || identity.error != null -> TitleConnection.Offline; else -> TitleConnection.SignedIn },
|
||||||
|
requestedStage = stage, requestActor = receipt?.actor?.name,
|
||||||
|
requestFor = receipt?.takeIf { it.requester != identity.operator.requester }?.requesterUsername,
|
||||||
|
hidden = hidden, pick = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName },
|
||||||
|
savedAt = reads.savedTime() ?: held?.savedAt,
|
||||||
|
seasonNumbers = held?.seasonNumbers ?: seasons.orEmpty().map { it.number.toInt() }.filter { it > 0 },
|
||||||
|
requestsAllowed = identity.operator.capabilities.requestMedia && !reads.failed && !reads.needsSignIn,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun person(id: Long, page: Int = 1): Flow<ConductarrPersonPage> = flow {
|
||||||
|
for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) {
|
||||||
|
val reads = SnapshotReads(identity.client, trigger)
|
||||||
|
val result = coroutineScope {
|
||||||
|
val detail = async { reads.read(TvRoutes.performer(id)) }
|
||||||
|
val credits = async { reads.read(TvRoutes.filmography(id, page)) }
|
||||||
|
detail.await() to credits.await()
|
||||||
|
}
|
||||||
|
val detail = result.first
|
||||||
|
val credits = result.second
|
||||||
|
if (trigger == ReadTrigger.CACHE_ONLY && detail?.name == null) continue
|
||||||
|
emit(ConductarrPersonPage(detail?.name?.let { TitlePerson(id.toString(), it, portrait = catalogueImage(detail.profileUrl), biography = detail.biography.orEmpty()) },
|
||||||
|
credits?.items.orEmpty().map { it.title.titleItem(identity.provider).copy(character = it.credits.orEmpty().map { credit -> credit.character }.filter { it.isNotBlank() }.distinct().joinToString(" / ")) },
|
||||||
|
credits != null && credits.page < (credits.totalPages ?: 1), reads.savedTime(), reads.needsSignIn, reads.failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ConductarrPersonPage(val person: TitlePerson?, val credits: List<TitleItem>, val hasMore: Boolean, val savedAt: String?, val needsSignIn: Boolean, val failed: Boolean)
|
||||||
|
|
||||||
|
private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem(
|
||||||
|
"${mediaType.name.lowercase()}:$tmdbId", title, mediaType.name.lowercase(), releaseDate?.take(4)?.toIntOrNull(),
|
||||||
|
overview = overview, poster = catalogueImage(posterUrl), backdrop = catalogueImage(backdropUrl),
|
||||||
|
otherOwners = provider.holders(this).toList(), tmdbId = tmdbId,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle(
|
||||||
|
item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year,
|
||||||
|
genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop,
|
||||||
|
runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 },
|
||||||
|
seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) },
|
||||||
|
heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.*
|
||||||
|
|
||||||
|
/** Titles can adopt these reads without importing transport or owning another login. */
|
||||||
|
interface ConductarrTitleProvider {
|
||||||
|
fun detail(tmdb: Long, media: MediaType): Flow<ReadResult<Detail>>
|
||||||
|
fun cast(tmdb: Long, media: MediaType): Flow<ReadResult<TitleCast>>
|
||||||
|
fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow<ReadResult<Page>>
|
||||||
|
fun season(tmdb: Long, number: Int): Flow<ReadResult<SeasonDetail>>
|
||||||
|
fun performer(id: Long): Flow<ReadResult<PerformerDetail>>
|
||||||
|
fun filmography(id: Long, page: Int = 1): Flow<ReadResult<FilmographyPage>>
|
||||||
|
fun activity(tmdb: Long, media: MediaType, viewer: String): Flow<ReadResult<ViewerReadiness>>
|
||||||
|
fun requests(tmdb: Long, media: MediaType): Flow<ReadResult<TitleRequestsBody>>
|
||||||
|
fun enrichment(tmdb: Long, media: MediaType): Flow<ReadResult<Enrichment>>
|
||||||
|
fun display(tmdb: Long, media: MediaType): Flow<ReadResult<Display>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */
|
||||||
|
internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider {
|
||||||
|
override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN)
|
||||||
|
override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN)
|
||||||
|
override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN)
|
||||||
|
override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN)
|
||||||
|
override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN)
|
||||||
|
override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE)
|
||||||
|
override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
}
|
||||||
|
|
@ -2,54 +2,106 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.justinvisser.wolfskin.design.DiscoverFixture
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
import org.justinvisser.wolfskin.design.DiscoverOperations
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.design.DiscoverRequest
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverRequestResult
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverSnapshot
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverState
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverTitle
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverStatus
|
|
||||||
import org.justinvisser.wolfskin.design.RequestPhase
|
|
||||||
import org.justinvisser.wolfskin.design.ShellState
|
|
||||||
|
|
||||||
internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Discover requires the Wolfskin shell") }
|
internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Discover requires the Wolfskin shell") }
|
||||||
|
|
||||||
/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */
|
/** Production uses the selected login; explicit fixtures remain deterministic and network-free. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberDiscoverState(
|
internal fun rememberDiscoverState(
|
||||||
shell: ShellState,
|
shell: ShellState,
|
||||||
push: (String) -> Unit,
|
push: (String) -> Unit,
|
||||||
pop: () -> Unit,
|
pop: () -> Unit,
|
||||||
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
|
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
|
||||||
|
identity: ConductarrIdentity? = null,
|
||||||
|
fixtures: Boolean = BuildConfig.DEBUG,
|
||||||
|
retrySignIn: suspend () -> Unit = {},
|
||||||
): DiscoverState {
|
): DiscoverState {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) }
|
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
|
||||||
LaunchedEffect(shell, shell.person.key) {
|
val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty()
|
||||||
if (BuildConfig.DEBUG) {
|
val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) {
|
||||||
|
if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty()
|
||||||
|
}
|
||||||
|
LaunchedEffect(identity?.client, identity?.signedIn, fixtures) {
|
||||||
|
if (!fixtures && identity?.signedIn == true) identity.provider.profiles()
|
||||||
|
}
|
||||||
|
val refresh: suspend () -> DiscoverSnapshot = {
|
||||||
|
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||||
|
else { retrySignIn(); DiscoverSnapshot() }
|
||||||
|
}
|
||||||
|
val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) {
|
||||||
|
val operations = identity?.takeIf { it.signedIn }?.let { it.provider.operations(it.operator!!) }
|
||||||
|
?: SignedOutDiscoverOperations(refresh)
|
||||||
|
lateinit var loaded: DiscoverState
|
||||||
|
loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop,
|
||||||
|
{ service, type -> servicePages["${service.id}:$type"].orEmpty() },
|
||||||
|
requesters = requesters,
|
||||||
|
onMore = if (fixtures || identity?.signedIn != true) null else { lane ->
|
||||||
|
val service = loaded.service
|
||||||
|
if (service == null) identity.provider.more(lane)
|
||||||
|
else {
|
||||||
|
servicePages["${service.id}:${loaded.filter}"] = identity.provider.service(service, loaded.filter, more = lane)
|
||||||
|
loaded.snapshot.lanes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
loaded
|
||||||
|
}
|
||||||
|
SideEffect { if (!fixtures) state.updateRequesters(requesters) }
|
||||||
|
var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf<DiscoverState?>(null) }
|
||||||
|
LaunchedEffect(state, fixtures) {
|
||||||
|
if (fixtures) {
|
||||||
val fixture = loadFixture(context)
|
val fixture = loadFixture(context)
|
||||||
val snapshot = fixture.snapshot(shell.person.key)
|
val snapshot = fixture.snapshot(shell.person.key)
|
||||||
val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)
|
|
||||||
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
|
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
|
||||||
state = loaded
|
fixtureState = loaded
|
||||||
if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
||||||
|
} else if (identity?.signedIn == true) {
|
||||||
|
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) }
|
LaunchedEffect(state.service?.id, state.filter, state) {
|
||||||
return state
|
val service = state.service ?: return@LaunchedEffect
|
||||||
|
if (!fixtures && identity?.signedIn == true) {
|
||||||
|
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter)
|
||||||
|
state.invalidateLanes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) {
|
||||||
|
if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) ->
|
||||||
|
val service = state.service
|
||||||
|
if (service == null) {
|
||||||
|
state.updateLanes(identity.provider.readAhead(lane))
|
||||||
|
val focused = state.focusedLane
|
||||||
|
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||||
|
state.updateLanes(identity.provider.more(focused.id))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val index = state.lanes.indexOfFirst { it.id == lane }
|
||||||
|
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2)
|
||||||
|
val focused = state.focusedLane
|
||||||
|
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||||
|
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id)
|
||||||
|
}
|
||||||
|
state.invalidateLanes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = if (fixtures) fixtureState ?: state else state
|
||||||
|
LaunchedEffect(shell.route, result) {
|
||||||
|
result.enterRoute(shell.route)
|
||||||
|
if (shell.route == "discover" && !fixtures && identity?.signedIn == true && result.snapshot.status != DiscoverStatus.Loading) result.refresh()
|
||||||
|
}
|
||||||
|
LaunchedEffect(result.snapshot.savedAt, shell.route) { if (shell.route.startsWith("discover")) shell.view.savedAt = result.snapshot.savedAt }
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readDiscoverFixture(context: Context): DiscoverFixture {
|
private fun readDiscoverFixture(context: Context): DiscoverFixture {
|
||||||
|
|
@ -57,7 +109,6 @@ private fun readDiscoverFixture(context: Context): DiscoverFixture {
|
||||||
return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }
|
return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixture writes are in-memory only and cannot reach a Conductarr client. */
|
|
||||||
private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
|
private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
|
||||||
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor)
|
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor)
|
||||||
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
|
|
@ -66,10 +117,10 @@ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, pr
|
||||||
override suspend fun refresh() = snapshot
|
override suspend fun refresh() = snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
private object SignedOutDiscoverOperations : DiscoverOperations {
|
private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations {
|
||||||
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused)
|
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused)
|
||||||
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
override suspend fun hide(profile: String, title: DiscoverTitle) = false
|
override suspend fun hide(profile: String, title: DiscoverTitle) = false
|
||||||
override suspend fun restore(profile: String, title: DiscoverTitle) = false
|
override suspend fun restore(profile: String, title: DiscoverTitle) = false
|
||||||
override suspend fun refresh() = DiscoverSnapshot()
|
override suspend fun refresh() = retry()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,12 @@ import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import org.justinvisser.wolfskin.design.*
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
||||||
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
|
||||||
|
|
@ -34,25 +36,38 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
private val cache: HomeLibraryCache,
|
private val cache: HomeLibraryCache,
|
||||||
private val navigation: NavigationManager,
|
private val navigation: NavigationManager,
|
||||||
private val seeAll: WolfskinSeeAllLists,
|
private val seeAll: WolfskinSeeAllLists,
|
||||||
|
private val conductarr: ConductarrSession,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val conductarrState get() = conductarr.identity
|
||||||
|
val audiences get() = home.audiences
|
||||||
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
||||||
private set
|
private set
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
private set
|
private set
|
||||||
|
private var catalogueJob: kotlinx.coroutines.Job? = null
|
||||||
private var sourceScope: String? = null
|
private var sourceScope: String? = null
|
||||||
private var loadRevision = 0L
|
private var loadRevision = 0L
|
||||||
|
private var catalogueSnapshot = DiscoverSnapshot()
|
||||||
|
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
catalogueJob?.cancel()
|
||||||
|
withContext(WholphinDispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
|
||||||
|
if (retry && conductarr.identity.value?.signedIn != true) {
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
loadLibrary(server, user, person, retry)
|
loadLibrary(server, user, person, retry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The authenticated read path is shared by release and offline fixture-driven tests. */
|
/** The authenticated read path is shared by release and offline fixture-driven tests. */
|
||||||
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
|
||||||
val revision = ++loadRevision
|
val revision = ++loadRevision
|
||||||
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?:
|
||||||
if (person != user.name.orEmpty().lowercase()) {
|
(person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester))
|
||||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
if (!matchesUser) {
|
||||||
|
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val scope = "${server.id}:${user.id}"
|
val scope = "${server.id}:${user.id}"
|
||||||
|
|
@ -61,39 +76,107 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
toast = null
|
toast = null
|
||||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
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
|
if (revision != loadRevision) return
|
||||||
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||||
try {
|
var libraryAvailable = true
|
||||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
var librarySavedAt: String? = null
|
||||||
val savedAt = System.currentTimeMillis()
|
var watchReady = false
|
||||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Local storage failure must not turn a reachable library into an outage. */ } }
|
val identity = conductarr.identity.value
|
||||||
if (revision != loadRevision) return
|
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
fun publish() {
|
||||||
} catch (exception: Exception) {
|
if (revision != loadRevision || sourceScope != scope) return
|
||||||
if (exception is CancellationException) throw exception
|
val status = when (catalogueCopy.status) {
|
||||||
if (revision != loadRevision) return
|
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
|
||||||
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
|
DiscoverStatus.Loading -> HomeAvailability.Loading
|
||||||
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||||
|
}
|
||||||
|
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||||
|
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||||
|
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||||
}
|
}
|
||||||
|
catalogueSnapshot = catalogueCopy
|
||||||
|
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||||
|
if (saved != null) publish()
|
||||||
|
if (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch {
|
||||||
|
if (retry && !identity.signedIn) conductarr.authenticate()
|
||||||
|
val source = conductarr.identity.value
|
||||||
|
if (source == null || source.client !== identity.client) return@launch
|
||||||
|
if (!source.signedIn) {
|
||||||
|
publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
source.provider.discover(source.operator!!.recommendationProfile, retry).collect { catalogue ->
|
||||||
|
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suspend fun save() {
|
||||||
|
val library = libraryCopy
|
||||||
|
withContext(WholphinDispatchers.IO) {
|
||||||
|
try { cache.write(scope, library, System.currentTimeMillis()) } catch (_: java.io.IOException) { /* Disk failure cannot revoke fresh watch data. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coroutineScope {
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
||||||
|
if (revision != loadRevision) return@launch
|
||||||
|
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
||||||
|
watchReady = true
|
||||||
|
libraryAvailable = true
|
||||||
|
publish()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
if (revision != loadRevision) return@launch
|
||||||
|
libraryAvailable = false
|
||||||
|
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||||
|
publish()
|
||||||
|
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
val shelves = withContext(WholphinDispatchers.IO) { home.readLibrary(user.id) }
|
||||||
|
if (revision != loadRevision) return@launch
|
||||||
|
libraryCopy = libraryCopy.copy(newest = shelves.newest, destinations = shelves.destinations, destinationTotals = shelves.destinationTotals)
|
||||||
|
publish()
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is CancellationException) throw exception
|
||||||
|
if (revision == loadRevision && retry) showToast(RecoveryMessage.StillUnreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (revision == loadRevision) save()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun readAhead(lane: String?) {
|
||||||
|
val identity = conductarr.identity.value ?: return
|
||||||
|
if (!identity.signedIn || ConductarrPreference.fixtures) return
|
||||||
|
val revision = loadRevision
|
||||||
|
val lanes = identity.provider.readAhead(lane)
|
||||||
|
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||||
when (action) {
|
when (action) {
|
||||||
HomeAction.Discover -> openRoot(shell, "discover")
|
HomeAction.Discover -> openRoot(shell, "discover")
|
||||||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
|
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
|
||||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation)
|
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.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
|
||||||
is HomeAction.Play -> viewModelScope.launch {
|
is HomeAction.Play -> viewModelScope.launch {
|
||||||
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
|
val personKey = shell.person.key
|
||||||
|
val selectedId = shell.person.jellyfinUserId
|
||||||
|
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||||
|
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||||
|
if (!snapshot.jellyfinAvailable || !matchesUser) {
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
|
||||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||||
|
|
@ -102,6 +185,18 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun openCatalogueGrid(row: HomeRow, shell: ShellState) {
|
||||||
|
val identity = conductarr.identity.value
|
||||||
|
val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id }
|
||||||
|
val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ ->
|
||||||
|
if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed")
|
||||||
|
val next = identity.provider.more(lane.id).first { it.id == lane.id }
|
||||||
|
if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable")
|
||||||
|
LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore)
|
||||||
|
} else null
|
||||||
|
seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore)
|
||||||
|
}
|
||||||
|
|
||||||
private fun openRoot(shell: ShellState, route: String) {
|
private fun openRoot(shell: ShellState, route: String) {
|
||||||
shell.enterDestination(route)
|
shell.enterDestination(route)
|
||||||
shell.focusNav(shell.tabs.indexOf(route))
|
shell.focusNav(shell.tabs.indexOf(route))
|
||||||
|
|
@ -136,9 +231,17 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
||||||
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
|
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||||
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
|
val identity by model.conductarrState.collectAsState()
|
||||||
|
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
|
||||||
|
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
|
||||||
|
val view = shell.rememberedView("home")
|
||||||
|
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||||
|
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||||
|
model.readAhead(lane)
|
||||||
|
}
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot
|
||||||
|
HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||||
model.toast?.let { RecoveryToast(it) }
|
model.toast?.let { RecoveryToast(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: ()
|
||||||
refresh()
|
refresh()
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
|
||||||
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
|
if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import org.justinvisser.wolfskin.design.ShellPerson
|
||||||
|
|
||||||
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
|
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
|
||||||
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
|
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
|
||||||
if (BuildConfig.DEBUG) {
|
if (ConductarrPreference.fixtures) {
|
||||||
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
|
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
|
||||||
val profiles = json.getJSONArray("profiles")
|
val profiles = json.getJSONArray("profiles")
|
||||||
return List(profiles.length()) { index ->
|
return List(profiles.length()) { index ->
|
||||||
|
|
@ -18,6 +18,6 @@ fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPers
|
||||||
}
|
}
|
||||||
return saved.map { user ->
|
return saved.map { user ->
|
||||||
val name = user.name.orEmpty()
|
val name = user.name.orEmpty()
|
||||||
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false)
|
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.justinvisser.wolfskin.design.DiscoverOverlay
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
import org.justinvisser.wolfskin.design.readShellKey
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
|
@ -25,6 +33,8 @@ import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import org.justinvisser.wolfskin.conductarr.MediaType
|
||||||
import org.justinvisser.wolfskin.design.PersonState
|
import org.justinvisser.wolfskin.design.PersonState
|
||||||
import org.justinvisser.wolfskin.design.PersonSurface
|
import org.justinvisser.wolfskin.design.PersonSurface
|
||||||
import org.justinvisser.wolfskin.design.RecoveryMessage
|
import org.justinvisser.wolfskin.design.RecoveryMessage
|
||||||
|
|
@ -38,6 +48,7 @@ import org.justinvisser.wolfskin.design.TitleReadStatus
|
||||||
import org.justinvisser.wolfskin.design.TitleState
|
import org.justinvisser.wolfskin.design.TitleState
|
||||||
import org.justinvisser.wolfskin.design.TitleSurface
|
import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
|
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -52,6 +63,8 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private val facade: TitleReadFacade,
|
private val facade: TitleReadFacade,
|
||||||
private val fixtureProvider: TitleFixtureProvider,
|
private val fixtureProvider: TitleFixtureProvider,
|
||||||
private val navigation: NavigationManager,
|
private val navigation: NavigationManager,
|
||||||
|
val conductarr: ConductarrSession,
|
||||||
|
private val repository: ServerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
var title by mutableStateOf<TitleState?>(null)
|
var title by mutableStateOf<TitleState?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
@ -65,23 +78,40 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
private var personReadFinished = false
|
private var personReadFinished = false
|
||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
private val saveMutex = Mutex()
|
private val saveMutex = Mutex()
|
||||||
|
private var performerPage = 1
|
||||||
|
var conductarrAction: ((TitleAction) -> Unit)? = null
|
||||||
|
val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}"
|
||||||
|
|
||||||
|
fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route))
|
||||||
|
fun popRoute() = navigation.goBack()
|
||||||
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
|
private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) }
|
||||||
|
|
||||||
/** Fixture reads are exclusively debug assets; real ids always use the authenticated Jellyfin facade. */
|
/** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */
|
||||||
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences) {
|
fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) {
|
||||||
if (route == destination && ::viewer.isInitialized && viewer.key == selected.key) return
|
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
|
route = destination
|
||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
person = null
|
person = null
|
||||||
fixture = null
|
fixture = null
|
||||||
personReadPending = false
|
personReadPending = false
|
||||||
personReadFinished = false
|
personReadFinished = false
|
||||||
|
performerPage = 1
|
||||||
viewer = selected
|
viewer = selected
|
||||||
preferences = userPreferences
|
preferences = userPreferences
|
||||||
val id = Uri.decode(destination.substringAfter('/'))
|
val id = Uri.decode(destination.substringAfter('/'))
|
||||||
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
|
title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle)
|
||||||
loadJob = viewModelScope.launch {
|
loadJob = viewModelScope.launch {
|
||||||
if (BuildConfig.DEBUG && id.toUuidOrNull() == null) {
|
if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) {
|
||||||
val source = fixtureProvider.read()
|
val source = fixtureProvider.read()
|
||||||
fixture = source
|
fixture = source
|
||||||
if (destination.startsWith("person/")) {
|
if (destination.startsWith("person/")) {
|
||||||
|
|
@ -104,23 +134,40 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
||||||
val uuid = id.toUuidOrNull() ?: return
|
|
||||||
val current = title ?: return
|
val current = title ?: return
|
||||||
|
val uuid = id.toUuidOrNull()
|
||||||
|
var held: TitlePageData? = null
|
||||||
|
val catalogue = if (uuid == null) id.split(':').takeIf { it.size == 2 && it[0] in listOf("movie", "tv") && it[1].toLongOrNull() != null } else null
|
||||||
|
if (uuid == null && catalogue == null) { current.update(current.data.copy(status = TitleReadStatus.Unavailable)); return }
|
||||||
try {
|
try {
|
||||||
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(uuid, preferences)?.let { current.update(it) }
|
val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) }
|
||||||
val data = facade.read(uuid, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences)
|
if (title !== current) return
|
||||||
current.update(data)
|
if (heldId != null) {
|
||||||
|
if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) }
|
||||||
|
held = facade.read(heldId, current.requestedSeason, preferences)
|
||||||
|
current.update(held)
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
if (current.data.status == TitleReadStatus.Ready) {
|
if (current.data.status == TitleReadStatus.Ready) {
|
||||||
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
|
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
|
||||||
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
|
held = current.data
|
||||||
if (current.data.status == TitleReadStatus.Ready || retry) current.toast = RecoveryMessage.StillUnreachable
|
} else if (uuid != null) current.update(current.data.copy(status = TitleReadStatus.Unavailable))
|
||||||
|
if (uuid != null && (current.data.status == TitleReadStatus.Ready || retry)) current.toast = RecoveryMessage.StillUnreachable
|
||||||
}
|
}
|
||||||
|
val identity = conductarr.identity.value?.takeIf { it.signedIn }
|
||||||
|
val tmdb = catalogue?.get(1)?.toLong() ?: held?.item?.tmdbId
|
||||||
|
if (identity != null && tmdb != null) {
|
||||||
|
val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV
|
||||||
|
ConductarrTitlePages(identity).title(tmdb, media, held).collect { data ->
|
||||||
|
if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data)
|
||||||
|
}
|
||||||
|
} else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readPerson(id: String, retry: Boolean = false) {
|
private suspend fun readPerson(id: String, retry: Boolean = false) {
|
||||||
if (personReadPending || personReadFinished) return
|
if (personReadPending || personReadFinished) return
|
||||||
|
if (id.toLongOrNull() != null) { readPerformer(id.toLong(), retry); return }
|
||||||
val uuid = id.toUuidOrNull() ?: return
|
val uuid = id.toUuidOrNull() ?: return
|
||||||
val owner = viewer.key
|
val owner = viewer.key
|
||||||
personReadPending = true
|
personReadPending = true
|
||||||
|
|
@ -147,6 +194,40 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
} finally { if (owner == viewer.key) personReadPending = false }
|
} finally { if (owner == viewer.key) personReadPending = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun readPerformer(id: Long, retry: Boolean) {
|
||||||
|
val identity = conductarr.identity.value?.takeIf { it.signedIn } ?: run {
|
||||||
|
title?.let { it.update(it.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentTitle = title
|
||||||
|
personReadPending = true
|
||||||
|
var failed = false
|
||||||
|
try {
|
||||||
|
ConductarrTitlePages(identity).person(id, performerPage).collect { page ->
|
||||||
|
if (title !== currentTitle || conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return@collect
|
||||||
|
if (page.person != null) {
|
||||||
|
val current = person
|
||||||
|
val credits = if (performerPage == 1) page.credits else (current?.credits.orEmpty() + page.credits).distinctBy { it.id }
|
||||||
|
if (current == null) person = PersonState(page.person, credits, ::handle) { viewModelScope.launch { readPerson(id.toString()) } }
|
||||||
|
person?.update(page.person, credits, page.savedAt)
|
||||||
|
if (page.failed && retry) person?.toast = RecoveryMessage.StillUnreachable
|
||||||
|
} else title?.update(requireNotNull(title).data.copy(status = TitleReadStatus.Unavailable))
|
||||||
|
personReadFinished = !page.hasMore && !page.failed
|
||||||
|
failed = page.failed
|
||||||
|
}
|
||||||
|
if (!personReadFinished && !failed) performerPage++
|
||||||
|
} finally { personReadPending = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun retrySignIn() {
|
||||||
|
val user = repository.currentUser ?: return
|
||||||
|
val server = repository.currentServer ?: return
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||||
|
}
|
||||||
|
conductarr.authenticate()
|
||||||
|
}
|
||||||
|
|
||||||
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) =
|
||||||
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
|
PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } }
|
||||||
|
|
||||||
|
|
@ -154,13 +235,21 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
when (action) {
|
when (action) {
|
||||||
TitleAction.Pop -> navigation.goBack()
|
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.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) {
|
is TitleAction.OpenTitle -> {
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
||||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
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."
|
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.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action)
|
||||||
is TitleAction.Season -> {
|
is TitleAction.Season -> {
|
||||||
|
|
@ -178,22 +267,29 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TitleAction.Retry -> viewModelScope.launch {
|
TitleAction.Retry -> viewModelScope.launch {
|
||||||
|
if (conductarr.identity.value?.signedIn != true) retrySignIn()
|
||||||
|
personReadFinished = false
|
||||||
|
performerPage = 1
|
||||||
if (fixture == null) {
|
if (fixture == null) {
|
||||||
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true)
|
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true)
|
||||||
}
|
}
|
||||||
else title?.toast = RecoveryMessage.StillUnreachable
|
else title?.toast = RecoveryMessage.StillUnreachable
|
||||||
}
|
}
|
||||||
is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr."
|
is TitleAction.Request, is TitleAction.Requests -> requireNotNull(conductarrAction).invoke(action)
|
||||||
is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply()
|
is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply()
|
||||||
|
else requireNotNull(conductarrAction).invoke(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun play(id: UUID?, position: Long) {
|
private fun play(id: UUID?, restart: Boolean) {
|
||||||
|
val current = title
|
||||||
|
val playbackScope = watchScope
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
if (id == null) throw IllegalArgumentException("No Jellyfin playback identity")
|
||||||
facade.preparePlayback(id)
|
val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item")
|
||||||
navigation.navigateTo(Destination.Playback(id, position))
|
if (title !== current || watchScope != playbackScope) return@launch
|
||||||
|
navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs()))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||||
|
|
@ -233,18 +329,29 @@ class WolfskinTitleViewModel @Inject constructor(
|
||||||
|
|
||||||
/** The screen's own Back handler closes its sheet before delegating a navigation pop. */
|
/** The screen's own Back handler closes its sheet before delegating a navigation pop. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel()) {
|
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) {
|
||||||
LaunchedEffect(route, shell.person.key) { viewModel.load(route, shell.person, preferences) }
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
|
val watchRefresh = rememberWatchRefresh(viewModel.watchScope)
|
||||||
|
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
|
||||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
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) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||||
}
|
}
|
||||||
val person = viewModel.person.takeIf { title != null }
|
val person = viewModel.person.takeIf { title != null }
|
||||||
BackHandler(enabled = title != null) {
|
BackHandler(enabled = title != null) {
|
||||||
title?.back(shell)
|
if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell)
|
||||||
|
}
|
||||||
|
Box(modifier.onPreviewKeyEvent { event ->
|
||||||
|
if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let {
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it)
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
}) {
|
||||||
|
if (person != null && route.startsWith("person/")) PersonSurface(person, shell)
|
||||||
|
else if (title != null) TitleSurface(title, shell)
|
||||||
|
if (requests != null) DiscoverOverlay(requests)
|
||||||
}
|
}
|
||||||
if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier)
|
|
||||||
else if (title != null) TitleSurface(title, shell, modifier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null }
|
private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import org.justinvisser.wolfskin.design.*
|
||||||
|
|
||||||
|
/** Title reads do not depend on a successful recommendation feed to authorize a request. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String,
|
||||||
|
identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty()
|
||||||
|
val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() }
|
||||||
|
LaunchedEffect(identity?.client, identity?.signedIn, fixtures) {
|
||||||
|
if (!fixtures && identity?.signedIn == true) identity.provider.profiles()
|
||||||
|
}
|
||||||
|
val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute,
|
||||||
|
identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn)
|
||||||
|
else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) {
|
||||||
|
identity?.takeIf { it.signedIn }?.let {
|
||||||
|
DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!),
|
||||||
|
scope, viewModel::openRoute, viewModel::popRoute,
|
||||||
|
requesters = requesters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SideEffect { if (!fixtures) requests?.updateRequesters(requesters) }
|
||||||
|
LaunchedEffect(requests, title?.data) {
|
||||||
|
val data = title?.data ?: return@LaunchedEffect
|
||||||
|
if (!fixtures && requests != null && data.item.tmdbId != null) {
|
||||||
|
val item = data.requestTitle(shell.person)
|
||||||
|
requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(),
|
||||||
|
hidden = if (data.hidden) listOf(item) else emptyList(),
|
||||||
|
status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready,
|
||||||
|
savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) }
|
||||||
|
LaunchedEffect(requests, hidden, requests?.toast) {
|
||||||
|
val data = title?.data ?: return@LaunchedEffect
|
||||||
|
if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden))
|
||||||
|
}
|
||||||
|
val receipt = (requests?.overlay as? DiscoverOverlayState.Request)?.state
|
||||||
|
LaunchedEffect(receipt?.phase, receipt?.result) {
|
||||||
|
val data = title?.data ?: return@LaunchedEffect
|
||||||
|
if (receipt?.phase == RequestPhase.Receipt) title.update(data.copy(requestedStage = receipt.result?.stage ?: "Requested",
|
||||||
|
requestActor = receipt.result?.actor, requestFor = receipt.selectedPerson.name.takeIf { receipt.selectedPerson.key != shell.person.key }))
|
||||||
|
}
|
||||||
|
SideEffect {
|
||||||
|
viewModel.conductarrAction = { action ->
|
||||||
|
val current = viewModel.title
|
||||||
|
when (action) {
|
||||||
|
is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person))
|
||||||
|
is TitleAction.Requests -> shell.openRequests()
|
||||||
|
is TitleAction.Hide -> if (current != null && requests != null) {
|
||||||
|
val item = current.data.requestTitle(shell.person)
|
||||||
|
if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false)
|
||||||
|
requests.overlay = null // TitleState already owns the native hide receipt and Undo.
|
||||||
|
}
|
||||||
|
else -> error("Not a Conductarr title action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -60,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
|
||||||
class WolfskinSession @Inject constructor() {
|
class WolfskinSession @Inject constructor() {
|
||||||
var state by mutableStateOf<ShellState?>(null)
|
var state by mutableStateOf<ShellState?>(null)
|
||||||
var serverId: String? = null
|
var serverId: String? = null
|
||||||
|
internal var fixtureMode: Boolean? = null
|
||||||
var onAction: (ShellAction) -> Unit = {}
|
var onAction: (ShellAction) -> Unit = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,24 +78,43 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
private val backdrop: BackdropService,
|
private val backdrop: BackdropService,
|
||||||
val libraries: LibraryReadFacade,
|
val libraries: LibraryReadFacade,
|
||||||
val seeAll: WolfskinSeeAllLists,
|
val seeAll: WolfskinSeeAllLists,
|
||||||
|
val conductarr: ConductarrSession,
|
||||||
|
artwork: WolfskinArtwork,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
init { artwork.configure(context) }
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
private val requestsSnapshot by lazy { readShellRequests(context) }
|
private val requestsSnapshot by lazy { readShellRequests(context) }
|
||||||
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
|
||||||
private set
|
private set
|
||||||
var signedInPerson by mutableStateOf("")
|
var signedInPerson by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
|
private var identityJob: kotlinx.coroutines.Job? = null
|
||||||
|
private var personSwitchJob: kotlinx.coroutines.Job? = null
|
||||||
|
private val personSwitchMutex = Mutex()
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||||
WolfskinScreenPreference.initialize(context)
|
withContext(Dispatchers.IO) {
|
||||||
signedInPerson = user.name.orEmpty().lowercase()
|
WolfskinScreenPreference.initialize(context)
|
||||||
if (session.state == null || session.serverId != server.id.toString()) {
|
ConductarrPreference.initialize(context)
|
||||||
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
|
||||||
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
|
||||||
session.serverId = server.id.toString()
|
|
||||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
|
||||||
}
|
}
|
||||||
|
identityJob?.cancel()
|
||||||
|
libraries.audiences = null
|
||||||
|
if (ConductarrPreference.fixtures) conductarr.close()
|
||||||
|
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
|
||||||
|
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
|
||||||
session.onAction = { action -> handleAction(action, server, user) }
|
session.onAction = { action -> handleAction(action, server, user) }
|
||||||
|
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
|
||||||
|
session.serverId = server.id.toString()
|
||||||
|
session.fixtureMode = ConductarrPreference.fixtures
|
||||||
|
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
|
||||||
|
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||||
|
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||||
|
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
|
||||||
|
session.state?.updatePeople(savedPeople)
|
||||||
|
}
|
||||||
|
identityJob = viewModelScope.launch {
|
||||||
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
libraryDestinations = libraries.readDestinations(user.id)
|
libraryDestinations = libraries.readDestinations(user.id)
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -100,18 +123,68 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadHousehold(user: JellyfinUser) {
|
||||||
|
conductarr.authenticate()
|
||||||
|
val identity = conductarr.identity.value ?: return
|
||||||
|
if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return
|
||||||
|
val profiles = identity.provider.profiles()?.profiles.orEmpty()
|
||||||
|
val audiences = identity.provider.audiences()
|
||||||
|
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
||||||
|
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
|
||||||
|
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
|
||||||
|
val hasAnime = try {
|
||||||
|
libraries.hasAnimeTitles(user.id)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
|
||||||
|
}
|
||||||
|
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
|
||||||
|
val updated = session.state?.people.orEmpty().map { person ->
|
||||||
|
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
|
||||||
|
val current = person.jellyfinUserId == user.id.toString()
|
||||||
|
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
|
||||||
|
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
|
||||||
|
hasAnime = if (current) hasAnime else person.hasAnime)
|
||||||
|
}
|
||||||
|
session.state?.updatePeople(updated)
|
||||||
|
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Foreground recovery retains the current identity and any usable audience mapping. */
|
||||||
|
fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? {
|
||||||
|
if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null
|
||||||
|
identityJob?.cancel()
|
||||||
|
return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
|
||||||
|
retry = conductarr.identity.value?.signedIn != true)
|
||||||
|
}
|
||||||
|
loadHousehold(user)
|
||||||
|
}
|
||||||
|
|
||||||
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
||||||
|
|
||||||
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
|
/** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */
|
||||||
fun refreshRequests() = refreshRequests(retry = false)
|
fun refreshRequests() = refreshRequests(retry = false)
|
||||||
|
|
||||||
private fun refreshRequests(retry: Boolean) {
|
private fun refreshRequests(retry: Boolean) {
|
||||||
val shell = session.state ?: return
|
val shell = session.state ?: return
|
||||||
val target = shell.requests
|
val target = shell.requests
|
||||||
|
val identity = conductarr.identity.value
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
|
if (ConductarrPreference.fixtures) {
|
||||||
target.update(snapshot)
|
target.update(withContext(Dispatchers.IO) { requestsSnapshot })
|
||||||
if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
} else if (identity?.signedIn != true) {
|
||||||
|
target.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||||
|
} else identity.provider.requests().collect { snapshot ->
|
||||||
|
if (conductarr.identity.value?.client === identity.client) {
|
||||||
|
target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
||||||
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,34 +192,49 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
|
is ShellAction.Destination -> {
|
||||||
|
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
|
||||||
|
when {
|
||||||
|
action.route == "settings" -> navigation.navigateTo(target)
|
||||||
|
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
|
||||||
|
else -> navigation.replace(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
ShellAction.Pop -> navigation.goBack()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
is ShellAction.Person -> selectPerson(action.person, server)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
ShellAction.RetryRequests -> refreshRequests(retry = true)
|
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||||
|
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||||
|
refreshRequests(retry = true)
|
||||||
|
}
|
||||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}")))
|
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
|
||||||
navigation.replace(Destination.Home())
|
navigation.replace(Destination.Home())
|
||||||
if (person.key == user.name.orEmpty().lowercase()) return
|
if (person.key == signedInPerson) return
|
||||||
viewModelScope.launch {
|
identityJob?.cancel()
|
||||||
val saved = withContext(Dispatchers.IO) {
|
personSwitchJob?.cancel()
|
||||||
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) }
|
signedInPerson = ""
|
||||||
}
|
conductarr.close()
|
||||||
if (saved == null || saved.isProtected) {
|
libraries.audiences = null
|
||||||
signedInPerson = ""
|
personSwitchJob = viewModelScope.launch {
|
||||||
return@launch
|
personSwitchMutex.withLock {
|
||||||
}
|
val saved = withContext(Dispatchers.IO) {
|
||||||
try {
|
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||||
val current = repository.changeUser(server, saved)
|
}
|
||||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
if (saved == null || saved.isProtected) return@launch
|
||||||
} catch (exception: Exception) {
|
try {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
val current = repository.changeUser(server, saved)
|
||||||
signedInPerson = ""
|
coroutineContext.ensureActive()
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,8 +258,18 @@ fun WolfskinApp(
|
||||||
preferences: UserPreferences,
|
preferences: UserPreferences,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
||||||
|
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
|
||||||
|
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(server.id, user.id) { viewModel.load(server, user) }
|
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
|
||||||
|
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
|
||||||
|
LaunchedEffect(identity?.signedIn, identity?.person) {
|
||||||
|
if (!ConductarrPreference.fixtures) {
|
||||||
|
if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||||
|
else viewModel.refreshRequests()
|
||||||
|
}
|
||||||
|
}
|
||||||
val loading = remember(user.id) {
|
val loading = remember(user.id) {
|
||||||
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
|
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
|
||||||
}
|
}
|
||||||
|
|
@ -180,10 +278,16 @@ fun WolfskinApp(
|
||||||
state,
|
state,
|
||||||
{ route -> navigationManager.navigateTo(Destination.Wolfskin(route)) },
|
{ route -> navigationManager.navigateTo(Destination.Wolfskin(route)) },
|
||||||
navigationManager::goBack,
|
navigationManager::goBack,
|
||||||
|
identity = identity,
|
||||||
|
fixtures = ConductarrPreference.fixtures,
|
||||||
|
retrySignIn = {
|
||||||
|
viewModel.retryConductarr(server, user)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
RequestsRefresh(state, viewModel::refreshRequests)
|
RequestsRefresh(state, viewModel::refreshRequests)
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) {
|
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
||||||
|
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
val current = navigationManager.backStack.last()
|
val current = navigationManager.backStack.last()
|
||||||
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
||||||
|
|
@ -200,17 +304,23 @@ fun WolfskinApp(
|
||||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
|
||||||
entryProvider = { destination ->
|
entryProvider = { destination ->
|
||||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||||
|
// Cached entries must not retain the launch shell after sign-in completes.
|
||||||
|
val state = LocalShellState.current
|
||||||
|
val discover = LocalDiscoverState.current
|
||||||
val route = when (destination) {
|
val route = when (destination) {
|
||||||
is Destination.Home -> "home"
|
is Destination.Home -> "home"
|
||||||
is Destination.Wolfskin -> destination.route
|
is Destination.Wolfskin -> destination.route
|
||||||
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
|
||||||
else -> "pushed"
|
else -> "pushed"
|
||||||
}
|
}
|
||||||
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
|
val isCurrent = navigationManager.backStack.lastOrNull() == destination
|
||||||
|
LaunchedEffect(destination, state, isCurrent) {
|
||||||
|
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||||
|
}
|
||||||
if (destination.fullScreen) {
|
if (destination.fullScreen) {
|
||||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||||
} else {
|
} else {
|
||||||
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||||
|
|
@ -223,7 +333,7 @@ fun WolfskinApp(
|
||||||
when {
|
when {
|
||||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
|
||||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||||
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
|
||||||
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import coil3.ComponentRegistry
|
||||||
|
import coil3.network.ConnectivityChecker
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinImages
|
||||||
|
|
||||||
|
/** Strip credentials after upstream auth injection, including each redirect's network request. */
|
||||||
|
@OptIn(coil3.annotation.ExperimentalCoilApi::class)
|
||||||
|
@Singleton
|
||||||
|
class WolfskinArtwork @Inject constructor(
|
||||||
|
@param:AuthOkHttpClient client: OkHttpClient,
|
||||||
|
repository: ServerRepository,
|
||||||
|
) {
|
||||||
|
private val artworkClient = client.newBuilder().addNetworkInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull()
|
||||||
|
val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme &&
|
||||||
|
request.url.host == jellyfin.host && request.url.port == jellyfin.port
|
||||||
|
chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build())
|
||||||
|
}.build()
|
||||||
|
private val configure: ComponentRegistry.Builder.() -> Unit = {
|
||||||
|
add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install host-scoped networking before any Wolfskin artwork is rendered. */
|
||||||
|
fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure)
|
||||||
|
}
|
||||||
|
|
@ -53,14 +53,15 @@ fun WolfskinLibraryContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val view = shell.rememberedView(route)
|
val view = shell.rememberedView(route)
|
||||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
val fixture by produceState<LibraryFixture?>(null, context, ConductarrPreference.fixtures) {
|
||||||
if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) {
|
value = null
|
||||||
|
if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) {
|
||||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) }
|
val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } }
|
||||||
val grid = remember(shell.person.key, route, view) {
|
val grid = remember(shell.person.key, route, view) {
|
||||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/")) else LibraryGridState(
|
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
||||||
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||||
onTitle = { navigation.navigateTo(libraryTitleDestination(it)) },
|
onTitle = { navigation.navigateTo(libraryTitleDestination(it)) },
|
||||||
onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) },
|
onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) },
|
||||||
|
|
@ -71,25 +72,32 @@ fun WolfskinLibraryContent(
|
||||||
ShellPlaceholder("See all list unavailable", modifier)
|
ShellPlaceholder("See all list unavailable", modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LaunchedEffect(grid, grid.refreshRevision, fixture) {
|
val watchRefresh = rememberWatchRefresh(cacheScope)
|
||||||
if (route in LIBRARY_DESTINATIONS && (!BuildConfig.DEBUG || fixture != null)) {
|
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
|
val wasLoading = grid.status == LibraryStatus.Loading
|
||||||
|
if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
|
||||||
|
val entryRevision = shell.inputRevision
|
||||||
if (fixture != null) {
|
if (fixture != null) {
|
||||||
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||||
grid.status = LibraryStatus.Ready
|
grid.status = LibraryStatus.Ready
|
||||||
} else {
|
} else {
|
||||||
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
val store = withContext(Dispatchers.IO) { snapshots.value }
|
||||||
|
store.read(cacheScope, route, force = grid.refreshRevision > 0, invalidatedAtMillis = watchRefresh.first) { facade.readLibrary(user.id, route) }.collect { snapshot ->
|
||||||
|
val focusedId = grid.focusedTitle?.id
|
||||||
grid.source = snapshot.items
|
grid.source = snapshot.items
|
||||||
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
|
||||||
|
grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||||
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
||||||
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
||||||
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
||||||
|
if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
|
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view)
|
||||||
}
|
}
|
||||||
|
|
||||||
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
|
||||||
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home"))
|
private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
|
||||||
|
|
||||||
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
fun read(scope: String): Pair<HomeLibrary, Long>? {
|
||||||
val entry = store.read(scope, KEY) ?: return null
|
val entry = store.read(scope, KEY) ?: return null
|
||||||
|
|
|
||||||
|
|
@ -21,46 +21,52 @@ import org.justinvisser.wolfskin.design.HomeLibrary
|
||||||
import org.justinvisser.wolfskin.design.HomeTitle
|
import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
|
|
||||||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
/** 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) {
|
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||||
suspend fun read(userId: UUID): HomeLibrary {
|
val audiences get() = libraries.audiences
|
||||||
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title)
|
/** Watch rows are publishable without waiting for any library shelf. */
|
||||||
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title)
|
suspend fun readWatch(userId: UUID): HomeLibrary {
|
||||||
|
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||||
|
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||||
|
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||||
|
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
|
||||||
|
catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val resume = resumeItems.map { title(it, series) }
|
||||||
|
val next = nextItems.map { title(it, series) }
|
||||||
|
return HomeLibrary(resume = resume, nextUp = next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Library failures do not discard an already successful watch read. */
|
||||||
|
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||||
val newest = mutableListOf<HomeTitle>()
|
val newest = mutableListOf<HomeTitle>()
|
||||||
val totals = linkedMapOf<String, Int>()
|
val totals = linkedMapOf<String, Int>()
|
||||||
for (library in libraries) {
|
for (library in libraries) {
|
||||||
val key = when {
|
val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue
|
||||||
"anime" in library.name.orEmpty().lowercase() -> "anime"
|
|
||||||
library.collectionType == CollectionType.MOVIES -> "movies"
|
|
||||||
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
|
||||||
else -> continue
|
|
||||||
}
|
|
||||||
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
|
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
|
||||||
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title))
|
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) })
|
||||||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
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))
|
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? {
|
/** Resolve the current checkpoint even when the hero was painted from disk. */
|
||||||
title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id ->
|
suspend fun preparePlayback(userId: UUID, title: HomeTitle): BaseItemDto? {
|
||||||
val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content
|
val id = runCatching { UUID.fromString(title.id) }.getOrNull() ?: run {
|
||||||
if (item.type == BaseItemKind.SERIES) {
|
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
||||||
return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull()
|
api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
||||||
?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull())
|
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
||||||
?.takeIf { it.type == BaseItemKind.EPISODE }?.id
|
}?.id
|
||||||
}
|
} ?: return null
|
||||||
return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE }
|
return readPlayback(api, userId, id)
|
||||||
}
|
|
||||||
val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE)
|
|
||||||
return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull {
|
|
||||||
it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode))
|
|
||||||
}?.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun title(item: BaseItemDto): HomeTitle {
|
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||||
val wrapped = BaseItem(item)
|
val wrapped = BaseItem(item)
|
||||||
return HomeTitle(
|
return HomeTitle(
|
||||||
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
||||||
|
|
@ -69,7 +75,8 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
|
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
|
||||||
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
||||||
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
||||||
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
|
||||||
|
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
package org.justinvisser.wolfskin.tv.read
|
package org.justinvisser.wolfskin.tv.read
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
|
@ -16,33 +22,48 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
||||||
|
@javax.inject.Singleton
|
||||||
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
|
var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null)
|
||||||
|
|
||||||
|
fun destination(name: String, collection: CollectionType?): String? {
|
||||||
|
audiences?.let { known ->
|
||||||
|
val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null
|
||||||
|
return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows"
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
name.contains("anime", ignoreCase = true) -> "anime"
|
||||||
|
collection == CollectionType.MOVIES -> "movies"
|
||||||
|
collection == CollectionType.TVSHOWS -> "shows"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
suspend fun readDestinations(userId: UUID): Map<String, Destination> {
|
suspend fun readDestinations(userId: UUID): Map<String, Destination> {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
||||||
return buildMap {
|
return buildMap {
|
||||||
libraries.forEach { library ->
|
libraries.forEach { library ->
|
||||||
val name = library.name.orEmpty().lowercase()
|
val key = destination(library.name.orEmpty(), library.collectionType)
|
||||||
val key = when {
|
|
||||||
"anime" in name -> "anime"
|
|
||||||
library.collectionType == CollectionType.MOVIES -> "movies"
|
|
||||||
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
|
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
|
||||||
|
suspend fun hasAnimeTitles(userId: UUID): Boolean {
|
||||||
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
||||||
|
for (library in libraries) {
|
||||||
|
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
|
||||||
|
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
|
||||||
|
if (titles.isNotEmpty()) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** A destination spans every matching accessible library; containers never become titles. */
|
/** A destination spans every matching accessible library; containers never become titles. */
|
||||||
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> {
|
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
||||||
val anime = library.name.orEmpty().contains("anime", ignoreCase = true)
|
destination(library.name.orEmpty(), library.collectionType) == destination
|
||||||
when (destination) {
|
|
||||||
"anime" -> anime
|
|
||||||
"movies" -> !anime && library.collectionType == CollectionType.MOVIES
|
|
||||||
"shows" -> !anime && library.collectionType == CollectionType.TVSHOWS
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val titles = linkedMapOf<UUID, LibraryTitle>()
|
val titles = linkedMapOf<UUID, LibraryTitle>()
|
||||||
for (library in libraries) {
|
for (library in libraries) {
|
||||||
|
|
@ -51,7 +72,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
val response = api.itemsApi.getItems(
|
val response = api.itemsApi.getItems(
|
||||||
userId = userId, parentId = library.id, recursive = true,
|
userId = userId, parentId = library.id, recursive = true,
|
||||||
includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES),
|
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,
|
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset,
|
||||||
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
limit = WolfskinTokens.Behaviour.libraryChunk.toInt(),
|
||||||
).content
|
).content
|
||||||
|
|
@ -69,8 +90,9 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
offset += chunk.size
|
offset += chunk.size
|
||||||
|
if (chunk.isNotEmpty()) emit(titles.values.toList())
|
||||||
} while (chunk.isNotEmpty() && offset < response.totalRecordCount)
|
} while (chunk.isNotEmpty() && offset < response.totalRecordCount)
|
||||||
}
|
}
|
||||||
return titles.values.toList()
|
if (titles.isEmpty()) emit(emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
|
||||||
|
|
||||||
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
|
/** 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) {
|
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
|
||||||
fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> 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 key = "library:$destination"
|
||||||
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
|
||||||
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
|
||||||
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
|
||||||
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
|
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 {
|
try {
|
||||||
val current = load()
|
load().collect { chunk ->
|
||||||
|
current = chunk
|
||||||
|
if (titles == null) emit(LibrarySnapshot(chunk, null, false))
|
||||||
|
}
|
||||||
val time = now()
|
val time = now()
|
||||||
|
if (titles != null) emit(LibrarySnapshot(current, time, false))
|
||||||
try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ }
|
try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ }
|
||||||
emit(LibrarySnapshot(current, time, false))
|
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true))
|
emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true))
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
import org.justinvisser.wolfskin.design.TitlePerson
|
import org.justinvisser.wolfskin.design.TitlePerson
|
||||||
import org.justinvisser.wolfskin.design.TitleTrack
|
import org.justinvisser.wolfskin.design.TitleTrack
|
||||||
import org.justinvisser.wolfskin.design.TitleVersion
|
import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
|
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -52,7 +53,21 @@ class TitleReadFacade @Inject constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
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? {
|
||||||
|
val response = api.request(org.jellyfin.sdk.api.client.HttpMethod.GET, "/Items",
|
||||||
|
mapOf("UserId" to repository.currentUser?.id, "Recursive" to true,
|
||||||
|
"IncludeItemTypes" to if (media == "movie") "Movie" else "Series",
|
||||||
|
"AnyProviderIdEquals" to "Tmdb.$tmdb", "Fields" to "ProviderIds", "Limit" to 1), emptyMap(), null)
|
||||||
|
.createContent<org.jellyfin.sdk.model.api.BaseItemDtoQueryResult>()
|
||||||
|
return response.items?.firstOrNull { row ->
|
||||||
|
row.type == (if (media == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) &&
|
||||||
|
row.providerIds?.entries?.any { it.key.equals("Tmdb", true) && it.value?.toLongOrNull() == tmdb } == true
|
||||||
|
}?.id
|
||||||
|
}
|
||||||
|
|
||||||
/** A disk copy is decoded before any network read; ownership is isolated by server and user. */
|
/** A disk copy is decoded before any network read; ownership is isolated by server and user. */
|
||||||
suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) {
|
suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -63,23 +78,23 @@ class TitleReadFacade @Inject constructor(
|
||||||
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items"))
|
||||||
val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } }
|
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)
|
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 }
|
} catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An episode opens its series with that exact episode as both play and watched target. */
|
/** An episode opens its series with that exact episode as both play and watched target. */
|
||||||
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
|
||||||
val file = cacheFile(id)
|
val file = cacheFile(id)
|
||||||
val opened = api.userLibraryApi.getItem(id).content
|
val opened = readItem(id)
|
||||||
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
|
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 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 {
|
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 selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber
|
||||||
val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull()
|
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 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 }
|
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 rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id }
|
||||||
val numbers = seasons.mapNotNull { it.indexNumber }
|
val numbers = seasons.mapNotNull { it.indexNumber }
|
||||||
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences)
|
||||||
|
|
@ -88,13 +103,29 @@ class TitleReadFacade @Inject constructor(
|
||||||
val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows))
|
val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows))
|
||||||
.put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString())
|
.put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString())
|
||||||
.put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty())
|
.put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty())
|
||||||
|
if (cacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Title identity changed")
|
||||||
file.parentFile?.mkdirs()
|
file.parentFile?.mkdirs()
|
||||||
try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ }
|
try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ }
|
||||||
result.copy(trailerUrl = trailer)
|
result.copy(trailerUrl = trailer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
|
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
|
||||||
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
|
private suspend fun readItem(id: UUID): BaseItemDto {
|
||||||
|
val item = api.userLibraryApi.getItem(id).content
|
||||||
|
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
|
||||||
|
val metadata = try {
|
||||||
|
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
|
||||||
|
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
null
|
||||||
|
} ?: return item
|
||||||
|
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Series actions re-resolve NextUp; explicit episodes and Restart retain their exact identity. */
|
||||||
|
suspend fun preparePlayback(id: UUID): BaseItemDto? =
|
||||||
|
readPlayback(api, repository.currentUser?.id, id)
|
||||||
|
|
||||||
/** Track selection uses the same repository consumed by the upstream player. */
|
/** Track selection uses the same repository consumed by the upstream player. */
|
||||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||||
|
|
@ -135,9 +166,10 @@ class TitleReadFacade @Inject constructor(
|
||||||
} else emptyList()
|
} else emptyList()
|
||||||
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content
|
||||||
val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true,
|
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()
|
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
|
||||||
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
|
||||||
|
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
|
||||||
file.parentFile?.mkdirs()
|
file.parentFile?.mkdirs()
|
||||||
try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) }
|
try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) }
|
||||||
catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ }
|
catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ }
|
||||||
|
|
@ -160,26 +192,44 @@ class TitleReadFacade @Inject constructor(
|
||||||
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
|
||||||
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
|
||||||
}
|
}
|
||||||
return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) },
|
val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
|
||||||
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
|
||||||
|
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
|
||||||
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
|
||||||
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
|
||||||
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
|
||||||
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
|
||||||
},
|
},
|
||||||
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) },
|
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
|
||||||
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem(
|
private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem(
|
||||||
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
|
||||||
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
|
||||||
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
|
||||||
backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) },
|
backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) },
|
||||||
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
|
||||||
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
|
||||||
|
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? {
|
||||||
|
val type = when {
|
||||||
|
item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY
|
||||||
|
item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (type != null) return images.getItemImageUrl(item.id, type,
|
||||||
|
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type))
|
||||||
|
return seriesBackdrop ?: item.parentBackdropItemId?.let { parent ->
|
||||||
|
item.parentBackdropImageTags?.firstOrNull()?.let { tag ->
|
||||||
|
images.getItemImageUrl(parent, ImageType.BACKDROP,
|
||||||
|
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
|
||||||
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
|
||||||
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# Conductarr TV client
|
# Conductarr TV client
|
||||||
|
|
||||||
A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app depends on this module; no screen, navigation, login flow or production service is changed.
|
A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app supplies the currently selected Jellyfin login through its person-scoped `ConductarrSession`.
|
||||||
|
|
||||||
## Pinned contract and generator decision
|
## Pinned contract and generator decision
|
||||||
|
|
||||||
`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `481492d3a8a0651a89901de548cd511218c0562e`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service.
|
`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `7e8f3f0a1c9a88d67b2a9518c1161db84ade531a`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service.
|
||||||
|
|
||||||
The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation.
|
The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation.
|
||||||
|
|
||||||
|
|
@ -18,8 +18,8 @@ Thus the generator does **not** handle every construct in this pinned document.
|
||||||
|
|
||||||
`ContractTest` checks:
|
`ContractTest` checks:
|
||||||
|
|
||||||
- All 20 TV method/path pairs against the pinned paths, including response and mutation body schemas.
|
- All 22 TV method/path pairs against the pinned paths, including response and mutation body schemas.
|
||||||
- All 68 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them.
|
- All 70 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them.
|
||||||
- A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority.
|
- A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority.
|
||||||
|
|
||||||
When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema.
|
When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema.
|
||||||
|
|
@ -28,7 +28,7 @@ When updating the pin, update the models and reviewed schema snapshot together.
|
||||||
|
|
||||||
Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved.
|
Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved.
|
||||||
|
|
||||||
`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary.
|
`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/ratings/display/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary.
|
||||||
|
|
||||||
`client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists.
|
`client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists.
|
||||||
|
|
||||||
|
|
@ -49,9 +49,9 @@ Cache keys partition by normalized server URL, credential digest, endpoint, path
|
||||||
| Feed | Server `expires_at`; conditional ETag revalidation |
|
| Feed | Server `expires_at`; conditional ETag revalidation |
|
||||||
| Catalogue options | `FOREGROUND` refreshes |
|
| Catalogue options | `FOREGROUND` refreshes |
|
||||||
| Discovery pages | 30 minutes per parameterized lane/page |
|
| Discovery pages | 30 minutes per parameterized lane/page |
|
||||||
| Enrichment ratings | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG |
|
| Ratings-only read | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG |
|
||||||
| Title, cast, related, performer, filmography, dismissals, readiness | `OPEN` refreshes |
|
| Title, cast, related, enrichment, display, performer, filmography, dismissals, readiness | `OPEN` refreshes |
|
||||||
| Seasons | `OPEN` on season focus; retained per season, 300 entries |
|
| Seasons | 6 hours for episode scores; `OPEN` refreshes on title-season focus; retained per season, 300 entries |
|
||||||
| Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` |
|
| Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` |
|
||||||
| Title requests and request mutations | No cache |
|
| Title requests and request mutations | No cache |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
481492d3a8a0651a89901de548cd511218c0562e
|
7e8f3f0a1c9a88d67b2a9518c1161db84ade531a
|
||||||
|
|
|
||||||
|
|
@ -1097,6 +1097,16 @@
|
||||||
"unknown"
|
"unknown"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"vote_average": {
|
||||||
|
"description": "Provider episode score on a 0–10 scale; absence is not zero.",
|
||||||
|
"format": "double",
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"vote_count": {
|
||||||
|
"description": "Provider episode vote count; explicit zero is retained.",
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|
@ -7058,6 +7068,33 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"TitleRatings": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"description": "A URL to the JSON Schema for this object.",
|
||||||
|
"examples": [
|
||||||
|
"https://example.com/schemas/TitleRatings.json"
|
||||||
|
],
|
||||||
|
"format": "uri",
|
||||||
|
"readOnly": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ratings": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Rating"
|
||||||
|
},
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ratings"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TitleRequestsBody": {
|
"TitleRequestsBody": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -8299,7 +8336,7 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/activity": {
|
"/api/v1/activity": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-activity",
|
"operationId": "get-activity",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -8311,6 +8348,14 @@
|
||||||
"description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting",
|
"description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -8322,7 +8367,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -8375,7 +8439,7 @@
|
||||||
},
|
},
|
||||||
"/api/v1/activity/titles/{tmdb}/{type}": {
|
"/api/v1/activity/titles/{tmdb}/{type}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-activity-title",
|
"operationId": "get-activity-title",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -8409,6 +8473,14 @@
|
||||||
"description": "Household member to read readiness for. Defaults to the signed-in operator",
|
"description": "Household member to read readiness for. Defaults to the signed-in operator",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -8420,7 +8492,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -9516,7 +9607,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/discover": {
|
"/catalogue/discover": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-discover",
|
"operationId": "get-catalogue-discover",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -9584,6 +9675,14 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -9595,7 +9694,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -9648,7 +9766,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/options": {
|
"/catalogue/options": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-options",
|
"operationId": "get-catalogue-options",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -9663,6 +9781,14 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -9674,7 +9800,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -9727,7 +9872,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/performers/{performer}": {
|
"/catalogue/performers/{performer}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-performer",
|
"operationId": "get-catalogue-performer",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -9739,6 +9884,14 @@
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -9750,7 +9903,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -9803,7 +9975,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/performers/{performer}/filmography": {
|
"/catalogue/performers/{performer}/filmography": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-filmography",
|
"operationId": "get-catalogue-filmography",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -9839,6 +10011,14 @@
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -9850,7 +10030,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -10006,7 +10205,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/titles/{tmdb}/{type}": {
|
"/catalogue/titles/{tmdb}/{type}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-title",
|
"operationId": "get-catalogue-title",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -10030,6 +10229,14 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -10041,7 +10248,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -10094,7 +10320,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/titles/{tmdb}/{type}/cast": {
|
"/catalogue/titles/{tmdb}/{type}/cast": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-cast",
|
"operationId": "get-catalogue-cast",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -10118,6 +10344,14 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -10129,7 +10363,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -10444,9 +10697,97 @@
|
||||||
"summary": "Retained title genres and NL streaming offers"
|
"summary": "Retained title genres and NL streaming offers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/catalogue/titles/{tmdb}/{type}/ratings": {
|
||||||
|
"get": {
|
||||||
|
"description": "Authenticated ratings-only display read with the same source observations as enrichment. Server cache: 6 hours for positive results, 1 minute for missing results, at most 300 typed title identities; concurrent identical reads share a fetch. Provider failures are not cached. Expired entries are never served. Reads no Arr, files, mirrors, memberships or watch state and never authorizes a title action. Enrichment and committed title reads remain fresh. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
||||||
|
"operationId": "get-catalogue-ratings",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "tmdb",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "type",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"enum": [
|
||||||
|
"tv",
|
||||||
|
"movie"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/TitleRatings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Authentik sign-in required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Operator or target is not permitted"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Identity verification or operation unavailable"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorModel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"authentikSession": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Cached title source ratings"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/catalogue/titles/{tmdb}/{type}/related": {
|
"/catalogue/titles/{tmdb}/{type}/related": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-related",
|
"operationId": "get-catalogue-related",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -10482,6 +10823,14 @@
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -10493,7 +10842,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -10546,7 +10914,7 @@
|
||||||
},
|
},
|
||||||
"/catalogue/tv/{tmdb}/seasons/{season}": {
|
"/catalogue/tv/{tmdb}/seasons/{season}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-catalogue-season",
|
"operationId": "get-catalogue-season",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -10568,6 +10936,14 @@
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -10579,7 +10955,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -11726,8 +12121,18 @@
|
||||||
},
|
},
|
||||||
"/curation/library-audiences": {
|
"/curation/library-audiences": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
|
"description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
|
||||||
"operationId": "get-library-audiences",
|
"operationId": "get-library-audiences",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
|
||||||
|
"in": "header",
|
||||||
|
"name": "If-None-Match",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
@ -11737,7 +12142,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "OK"
|
"description": "OK",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
"content": {
|
"content": {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity:
|
||||||
DISCOVERY(30 * 60 * 1000L),
|
DISCOVERY(30 * 60 * 1000L),
|
||||||
RATINGS(6 * 60 * 60 * 1000L, 300),
|
RATINGS(6 * 60 * 60 * 1000L, 300),
|
||||||
ON_OPEN(Long.MAX_VALUE),
|
ON_OPEN(Long.MAX_VALUE),
|
||||||
SEASON(Long.MAX_VALUE, 300),
|
SEASON(6 * 60 * 60 * 1000L, 300),
|
||||||
ACTIVITY(30 * 1000L),
|
ACTIVITY(30 * 1000L),
|
||||||
NO_CACHE(0),
|
NO_CACHE(0),
|
||||||
;
|
;
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ class ConductarrClient(
|
||||||
timeouts: ClientTimeouts = ClientTimeouts(),
|
timeouts: ClientTimeouts = ClientTimeouts(),
|
||||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||||
private val onEvent: (ClientEvent) -> Unit = {},
|
private val onEvent: (ClientEvent) -> Unit = {},
|
||||||
|
dns: PersistentDns? = null,
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
private val base = baseUrl.toHttpUrl()
|
private val base = baseUrl.toHttpUrl()
|
||||||
private val scope: String
|
private val scope: String
|
||||||
|
|
@ -74,6 +75,7 @@ class ConductarrClient(
|
||||||
private val cacheIdentityLock = Any()
|
private val cacheIdentityLock = Any()
|
||||||
private val catalogueReads = Semaphore(2)
|
private val catalogueReads = Semaphore(2)
|
||||||
private val http = OkHttpClient.Builder()
|
private val http = OkHttpClient.Builder()
|
||||||
|
.apply { if (dns != null) DnsConnectionRetry.configure(this, dns) }
|
||||||
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
|
.connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS)
|
||||||
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
|
.readTimeout(timeouts.readSeconds, TimeUnit.SECONDS)
|
||||||
.callTimeout(timeouts.callSeconds, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,8 @@ object TvRoutes {
|
||||||
private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast")
|
private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast")
|
||||||
private val related = RouteContract("GET", "${title.path}/related", "Page")
|
private val related = RouteContract("GET", "${title.path}/related", "Page")
|
||||||
private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment")
|
private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment")
|
||||||
|
private val ratings = RouteContract("GET", "${title.path}/ratings", "TitleRatings")
|
||||||
|
private val display = RouteContract("GET", "${title.path}/display", "Display")
|
||||||
private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail")
|
private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail")
|
||||||
private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail")
|
private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail")
|
||||||
private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage")
|
private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage")
|
||||||
|
|
@ -63,7 +65,7 @@ object TvRoutes {
|
||||||
|
|
||||||
val contracts: List<RouteContract> = listOf(
|
val contracts: List<RouteContract> = listOf(
|
||||||
operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast,
|
operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast,
|
||||||
related, enrichment, season, performer, filmography, activity, readiness, requests, request, audiences,
|
related, enrichment, ratings, display, season, performer, filmography, activity, readiness, requests, request, audiences,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH)
|
fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH)
|
||||||
|
|
@ -97,7 +99,9 @@ object TvRoutes {
|
||||||
|
|
||||||
fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
|
fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
|
||||||
fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
|
fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
|
||||||
fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS)
|
fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
|
||||||
|
fun ratings(tmdb: Long, media: MediaType) = ReadCall(ratings, TitleRatings.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS)
|
||||||
|
fun display(tmdb: Long, media: MediaType) = ReadCall(display, Display.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
|
||||||
fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall(
|
fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall(
|
||||||
related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN,
|
related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.conductarr.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Display wire contract from the pinned Conductarr schema. */
|
||||||
|
@Serializable
|
||||||
|
data class Display(
|
||||||
|
@SerialName("media_type") val mediaType: MediaType,
|
||||||
|
@SerialName("metadata_state") val metadataState: MetadataState,
|
||||||
|
@SerialName("poster_url") val posterUrl: String? = null,
|
||||||
|
@SerialName("title") val title: String? = null,
|
||||||
|
@SerialName("tmdb_id") val tmdbId: Long,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
enum class MediaType { @SerialName("movie") MOVIE, @SerialName("tv") TV }
|
||||||
|
@Serializable
|
||||||
|
enum class MetadataState {
|
||||||
|
@SerialName("observed") OBSERVED,
|
||||||
|
@SerialName("missing") MISSING,
|
||||||
|
@SerialName("unavailable") UNAVAILABLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ data class Episode(
|
||||||
@SerialName("number") val number: Long,
|
@SerialName("number") val number: Long,
|
||||||
@SerialName("overview") val overview: String,
|
@SerialName("overview") val overview: String,
|
||||||
@SerialName("recorded") val recorded: Recorded,
|
@SerialName("recorded") val recorded: Recorded,
|
||||||
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
@SerialName("vote_count") val voteCount: Long? = null,
|
||||||
) {
|
) {
|
||||||
/** Wire values for recorded. */
|
/** Wire values for recorded. */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue