Compare commits
88 commits
fm/tv-slic
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fc10b7af2 | |||
| 9061384ad1 | |||
| 9bbb4bab25 | |||
| 57cf17aaad | |||
| 51799669e7 | |||
| 383802611c | |||
| 94a69de25f | |||
| c78a8dfa8d | |||
| d171aaae9e | |||
| 83ebf25d9f | |||
| 37ad87d058 | |||
| 2b8c729f62 | |||
| 271cee2cea | |||
| ee3f3ae11e | |||
| 19df50c0f1 | |||
| 7a0cbdf909 | |||
| e189d9318a | |||
| 152dac97c0 | |||
| 19bb587c95 | |||
| 165a3f5e7d | |||
| c3e9b3cd3b | |||
| 017004bf39 | |||
| b44519f01b | |||
| a0237fa975 | |||
| c2f7ccc438 | |||
| fe44a3c24f | |||
| 00e8e83bb6 | |||
| b80b67f736 | |||
| fdff85c8a1 | |||
| 332fc6476d | |||
| d18a05bdb0 | |||
| 1668445418 | |||
| fd48426fa7 | |||
| 052d0b8898 | |||
| acb72335e3 | |||
| 53a2cff2c8 | |||
| 327f631f0d | |||
| e340d470c1 | |||
| f4fb6e22c9 | |||
| 5bd2affc1a | |||
| 8e40ac0869 | |||
| 54c23e076a | |||
|
|
159edc875d | ||
| 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 | |||
|
|
bd46ff3e03 | ||
| 59c2728d9c | |||
| f53defad90 |
298 changed files with 26761 additions and 1202 deletions
12
Makefile
12
Makefile
|
|
@ -4,7 +4,7 @@ WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle
|
||||||
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
|
WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin
|
||||||
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
|
export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR
|
||||||
|
|
||||||
.PHONY: build-image apk apk-debug test release-tag
|
.PHONY: build-image apk apk-debug test release-tag tv-release tv-publish tv-tooling-test
|
||||||
build-image:
|
build-image:
|
||||||
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker
|
||||||
|
|
||||||
|
|
@ -19,3 +19,13 @@ test: build-image
|
||||||
|
|
||||||
release-tag:
|
release-tag:
|
||||||
@bash docker/release-tag.sh
|
@bash docker/release-tag.sh
|
||||||
|
|
||||||
|
TV_RELEASE_DIR ?= /var/tmp/wolfskin-tv-release-$(VERSION)
|
||||||
|
tv-release: build-image
|
||||||
|
@python3 docker/tv_release.py --version "$(VERSION)" --version-code "$(VERSION_CODE)" --output "$(TV_RELEASE_DIR)" $(TV_RELEASE_FLAGS)
|
||||||
|
|
||||||
|
tv-publish:
|
||||||
|
@python3 docker/tv_publish.py --directory "$(TV_RELEASE_DIR)" $(TV_PUBLISH_FLAGS)
|
||||||
|
|
||||||
|
tv-tooling-test:
|
||||||
|
@python3 -m unittest discover -s docker -p 'test_tv_*.py' -v
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ Wolfskin is an Android TV client for one household. It is released through Forge
|
||||||
|
|
||||||
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
||||||
|
|
||||||
|
## Wolfskin TV updates
|
||||||
|
|
||||||
|
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
|
||||||
|
|
||||||
|
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
|
||||||
|
|
||||||
|
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wholphin - an OSS Android TV client for Jellyfin
|
# Wholphin - an OSS Android TV client for Jellyfin
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ val gitDescribe =
|
||||||
.standardOutput.asText
|
.standardOutput.asText
|
||||||
.getOrElse("v0.0.0")
|
.getOrElse("v0.0.0")
|
||||||
|
|
||||||
|
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||||
|
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||||
|
apply(from = "household-version.gradle.kts")
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
|
||||||
|
|
@ -82,6 +86,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 +137,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 +210,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 {
|
||||||
|
|
@ -216,6 +232,13 @@ configure<ApplicationExtension> {
|
||||||
|
|
||||||
androidComponents {
|
androidComponents {
|
||||||
onVariants(selector().all()) { variant ->
|
onVariants(selector().all()) { variant ->
|
||||||
|
if (variant.name == "defaultOptimized") {
|
||||||
|
variant.outputs.forEach {
|
||||||
|
it.versionName.set(tvVersion)
|
||||||
|
// The sentinel is configuration-only: optimized tasks require the explicit input above.
|
||||||
|
it.versionCode.set(tvVersionCode.map(String::toInt).orElse(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
variant.outputs
|
variant.outputs
|
||||||
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
.map { it as com.android.build.api.variant.impl.VariantOutputImpl }
|
||||||
.forEach { output ->
|
.forEach { output ->
|
||||||
|
|
@ -227,6 +250,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 +476,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)
|
||||||
|
|
|
||||||
18
app/household-version.gradle.kts
Normal file
18
app/household-version.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Household assembly inputs remain mandatory even through aggregate Gradle tasks.
|
||||||
|
val tvVersion = providers.gradleProperty("tvVersion").orElse("1.1.1")
|
||||||
|
val tvVersionCode = providers.gradleProperty("tvVersionCode")
|
||||||
|
|
||||||
|
fun requireHouseholdVersion() {
|
||||||
|
require(tvVersionCode.isPresent) { "Household builds require -PtvVersionCode=<monotonic code>" }
|
||||||
|
require(tvVersionCode.get().toInt() in 1..2100000000) { "Invalid household versionCode" }
|
||||||
|
require(Regex("""\d+\.\d+\.\d+""").matches(tvVersion.get())) { "Household versionName must be plain major.minor.patch" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gradle.startParameter.taskNames.any { it.contains("Optimized", ignoreCase = true) }) {
|
||||||
|
requireHouseholdVersion()
|
||||||
|
}
|
||||||
|
tasks.configureEach {
|
||||||
|
if (name.contains("DefaultOptimized", ignoreCase = true)) {
|
||||||
|
doFirst { requireHouseholdVersion() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,18 @@
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.INSTALL_PACKAGE" />
|
||||||
|
<data android:mimeType="application/vnd.android.package-archive" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:hasFragileUserData="true"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|
@ -69,6 +77,13 @@
|
||||||
<data android:scheme="wholphin" />
|
<data android:scheme="wholphin" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<receiver
|
||||||
|
android:name="org.justinvisser.wolfskin.tv.update.UpdateInstallReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name="org.justinvisser.wolfskin.tv.update.UpdateConfirmationActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
@ -627,7 +628,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
val UpdateUrl =
|
val UpdateUrl =
|
||||||
AppStringPreference<AppPreferences>(
|
AppStringPreference<AppPreferences>(
|
||||||
title = R.string.update_url,
|
title = R.string.update_url,
|
||||||
defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest",
|
defaultValue = UpdateChannel.defaultUrl(BuildConfig.APPLICATION_ID),
|
||||||
getter = { it.updateUrl },
|
getter = { it.updateUrl },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.update { updateUrl = value }
|
prefs.update { updateUrl = value }
|
||||||
|
|
@ -660,6 +661,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 +1346,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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import org.justinvisser.wolfskin.tv.update.AndroidUpdateService
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateTransport
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -43,6 +46,7 @@ import java.io.OutputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import javax.inject.Provider
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
@ -55,6 +59,7 @@ class UpdateChecker
|
||||||
constructor(
|
constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||||
|
private val household: Provider<AndroidUpdateService>,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val ASSET_NAME = "Wolfskin"
|
const val ASSET_NAME = "Wolfskin"
|
||||||
|
|
@ -123,6 +128,10 @@ class UpdateChecker
|
||||||
updateUrl: String,
|
updateUrl: String,
|
||||||
showNegativeToast: Boolean = false,
|
showNegativeToast: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||||
|
household.get().controller.check(manual = showNegativeToast)
|
||||||
|
return
|
||||||
|
}
|
||||||
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
val pref = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
val now = Date()
|
val now = Date()
|
||||||
val lastUpdateCheckThreshold =
|
val lastUpdateCheckThreshold =
|
||||||
|
|
@ -181,6 +190,12 @@ class UpdateChecker
|
||||||
* so this searches the release list instead of fetching a tag.
|
* so this searches the release list instead of fetching a tag.
|
||||||
*/
|
*/
|
||||||
suspend fun getRelease(version: Version): Release? {
|
suspend fun getRelease(version: Version): Release? {
|
||||||
|
if (context.packageName == UpdateChannel.PACKAGE) {
|
||||||
|
return withContext(WholphinDispatchers.IO) {
|
||||||
|
val releases = UpdateTransport().releasesForLatest(household.get().channelUrl())
|
||||||
|
findReleaseByVersion(releases, version)?.let { parseRelease(it, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -233,6 +248,9 @@ class UpdateChecker
|
||||||
release: Release,
|
release: Release,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
) {
|
) {
|
||||||
|
check(context.packageName != UpdateChannel.PACKAGE) {
|
||||||
|
"Household updates require the verified update controller"
|
||||||
|
}
|
||||||
withContext(WholphinDispatchers.IO) {
|
withContext(WholphinDispatchers.IO) {
|
||||||
cleanup()
|
cleanup()
|
||||||
val request =
|
val request =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import coil3.request.crossfade
|
import coil3.request.transitionFactory
|
||||||
|
import org.justinvisser.wolfskin.design.NetworkArtTransition
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -89,7 +90,7 @@ fun CoilConfig(
|
||||||
memoryCache(null)
|
memoryCache(null)
|
||||||
diskCache(null)
|
diskCache(null)
|
||||||
}
|
}
|
||||||
}.crossfade(false)
|
}.transitionFactory(NetworkArtTransition)
|
||||||
.logger(if (debugLogging) DebugLogger() else null)
|
.logger(if (debugLogging) DebugLogger() else null)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.imageLoader
|
import coil3.imageLoader
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -89,6 +90,7 @@ import com.github.damontecres.wholphin.util.DataLoadingState
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
@ -355,7 +357,9 @@ fun PreferencesContent(
|
||||||
AppPreference.Update -> {
|
AppPreference.Update -> {
|
||||||
ClickPreference(
|
ClickPreference(
|
||||||
title =
|
title =
|
||||||
if (release != null && updateAvailable) {
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
stringResource(R.string.check_for_updates)
|
||||||
|
} else if (release != null && updateAvailable) {
|
||||||
stringResource(R.string.install_update)
|
stringResource(R.string.install_update)
|
||||||
} else if (!preferences.autoCheckForUpdates && release == null) {
|
} else if (!preferences.autoCheckForUpdates && release == null) {
|
||||||
stringResource(R.string.check_for_updates)
|
stringResource(R.string.check_for_updates)
|
||||||
|
|
@ -364,7 +368,9 @@ fun PreferencesContent(
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
if (movementSounds) playOnClickSound(context)
|
if (movementSounds) playOnClickSound(context)
|
||||||
if (release != null && updateAvailable) {
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
|
||||||
|
} else if (release != null && updateAvailable) {
|
||||||
release?.let {
|
release?.let {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.UpdateApp,
|
Destination.UpdateApp,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.DownloadCallback
|
import com.github.damontecres.wholphin.services.DownloadCallback
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
|
@ -73,6 +74,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import org.justinvisser.wolfskin.tv.update.ClassicUpdatePage
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
|
import org.justinvisser.wolfskin.tv.update.UpdateController
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -82,6 +86,7 @@ class UpdateViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
val updater: UpdateChecker,
|
val updater: UpdateChecker,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
val householdController: UpdateController,
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
DownloadCallback {
|
DownloadCallback {
|
||||||
private val _state = MutableStateFlow(InstallUpdateState())
|
private val _state = MutableStateFlow(InstallUpdateState())
|
||||||
|
|
@ -93,6 +98,10 @@ class UpdateViewModel
|
||||||
val currentVersion = updater.getInstalledVersion()
|
val currentVersion = updater.getInstalledVersion()
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
householdController.check(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
_state.update { it.copy(loading = LoadingState.Loading) }
|
_state.update { it.copy(loading = LoadingState.Loading) }
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl
|
||||||
|
|
@ -166,6 +175,10 @@ fun InstallUpdatePage(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: UpdateViewModel = hiltViewModel(),
|
viewModel: UpdateViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
if (BuildConfig.APPLICATION_ID == UpdateChannel.PACKAGE) {
|
||||||
|
ClassicUpdatePage(viewModel.householdController, modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
OneTimeLaunchedEffect { viewModel.init() }
|
OneTimeLaunchedEffect { viewModel.init() }
|
||||||
|
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ class SwitchSeerrViewModel
|
||||||
_state.update { it.copy(loading = LoadingState.Success) }
|
_state.update { it.copy(loading = LoadingState.Success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Nothing to check so set success
|
||||||
|
_state.update { it.copy(loading = LoadingState.Success) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
if (!released) {
|
||||||
TIMER.schedule(task, delay, delay)
|
TIMER.schedule(task, delay, delay)
|
||||||
initialized = true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,16 @@ import java.nio.file.Paths
|
||||||
import kotlin.io.path.readText
|
import kotlin.io.path.readText
|
||||||
|
|
||||||
class TestUpdateChecker {
|
class TestUpdateChecker {
|
||||||
|
@Test
|
||||||
|
fun householdChannelMigrationDoesNotChangeCustomOrOldProductionFeeds() {
|
||||||
|
val channel = org.justinvisser.wolfskin.tv.update.UpdateChannel
|
||||||
|
Assert.assertEquals(channel.LATEST_URL, channel.migrateUrl(channel.PACKAGE, channel.LEGACY_URL))
|
||||||
|
Assert.assertEquals("https://custom.test/releases/latest", channel.migrateUrl(channel.PACKAGE, "https://custom.test/releases/latest"))
|
||||||
|
Assert.assertEquals(channel.LEGACY_URL, channel.migrateUrl("org.justinvisser.wolfskin.tv", channel.LEGACY_URL))
|
||||||
|
Assert.assertEquals(channel.LEGACY_URL, channel.defaultUrl("org.justinvisser.wolfskin.tv"))
|
||||||
|
Assert.assertEquals(channel.LATEST_URL, channel.defaultUrl(channel.PACKAGE))
|
||||||
|
}
|
||||||
|
|
||||||
lateinit var releaseJson: JsonObject
|
lateinit var releaseJson: JsonObject
|
||||||
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,19 @@ 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.assertTrue(optimizedVersion.isGreaterThan(Version.fromString("1.1.0-140-g5162c299")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun householdAvailabilityOrdersAndroidCodesNotDisplayVersions() {
|
||||||
|
Assert.assertTrue(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 65))
|
||||||
|
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(65, 100001))
|
||||||
|
Assert.assertFalse(org.justinvisser.wolfskin.tv.update.UpdateChannel.isNewer(100001, 100001))
|
||||||
|
}
|
||||||
|
|
||||||
@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,16 +62,22 @@ 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
|
||||||
@TestInstallIn(
|
@TestInstallIn(
|
||||||
components = [SingletonComponent::class],
|
components = [SingletonComponent::class],
|
||||||
replaces = [DeviceModule::class, AppModule::class],
|
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
||||||
)
|
)
|
||||||
object TestModule {
|
object TestModule {
|
||||||
val testDispatcher = StandardTestDispatcher()
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
|
||||||
|
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun deviceInfo(
|
fun deviceInfo(
|
||||||
|
|
@ -91,12 +97,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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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,51 @@
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.Assert.*
|
||||||
|
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
|
||||||
|
|
||||||
|
/** A pending shelf cannot capture the D-pad or break the shell's Back ladder. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
class DiscoverSkeletonKeysTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun downAndUpSkipPendingLanesAndBackReturnsToNavWhenOnlySkeletonsRemain() {
|
||||||
|
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
|
||||||
|
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
|
||||||
|
}
|
||||||
|
val pending = DiscoverLane("pending", "Pending", emptyList(), pending = true)
|
||||||
|
val ready = DiscoverLane("ready", "Available", listOf(DiscoverTitle(1, "movie", "A real title")))
|
||||||
|
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Ready, lanes = listOf(pending, ready))
|
||||||
|
val state = DiscoverState(shell, snapshot, mockk(relaxed = true), CoroutineScope(Dispatchers.Main.immediate), {}, {})
|
||||||
|
state.focus(1)
|
||||||
|
rule.setContent { WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } }
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
rule.runOnIdle { assertEquals("ready", state.focusedLane?.id); assertEquals("ready:0", state.cardKey) }
|
||||||
|
press(Key.DirectionUp)
|
||||||
|
rule.runOnIdle {
|
||||||
|
assertEquals(1, state.focusedRow)
|
||||||
|
state.updateSnapshot(snapshot.copy(lanes = listOf(pending)))
|
||||||
|
}
|
||||||
|
press(Key.DirectionDown)
|
||||||
|
rule.runOnIdle { assertEquals(1, state.focusedRow); assertNull(state.cardKey) }
|
||||||
|
press(Key.Back)
|
||||||
|
rule.runOnIdle { assertTrue(shell.navFocused); assertFalse(shell.pickerVisible); assertEquals("discover", shell.route) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
|
||||||
|
}
|
||||||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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,159 @@
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).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) {
|
||||||
|
// Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition.
|
||||||
|
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||||
|
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||||
|
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||||
|
models.last().conductarr.identity.value?.signedIn == true &&
|
||||||
|
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
|
||||||
|
session.state?.people?.size == 3 &&
|
||||||
|
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
|
||||||
|
}
|
||||||
|
} catch (timeout: ComposeTimeoutException) {
|
||||||
|
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
|
||||||
|
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
|
||||||
|
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(keys: List<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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import java.io.File
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
|
||||||
|
/** Exercises the production transport, verification store and state machine against HTTP bytes. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class HouseholdUpdateControllerTest {
|
||||||
|
@get:Rule val temporary = TemporaryFolder()
|
||||||
|
private lateinit var server: HttpServer
|
||||||
|
private lateinit var directory: File
|
||||||
|
private lateinit var base: String
|
||||||
|
private val apk = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||||
|
private var code = 100001L
|
||||||
|
private var releaseStatus = 200
|
||||||
|
private var assetStatus = 200
|
||||||
|
private var wireTruncated = false
|
||||||
|
private var metadataTruncated = false
|
||||||
|
private var listStatus = 200
|
||||||
|
private var listBody = "[]"
|
||||||
|
private var invalidJson = false
|
||||||
|
private var badHash = false
|
||||||
|
private var truncated = false
|
||||||
|
private var chunked = false
|
||||||
|
private var unsupported = false
|
||||||
|
private var manifestSigner = UpdateChannel.SIGNER
|
||||||
|
private var redirect = false
|
||||||
|
private var redirectTarget: String? = null
|
||||||
|
private val requests = mutableListOf<Map<String, List<String>>>()
|
||||||
|
private val platform = Platform()
|
||||||
|
|
||||||
|
private inner class Platform : UpdatePlatform {
|
||||||
|
var archive = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.1", 100001), setOf(UpdateChannel.SIGNER))
|
||||||
|
var installed = ApkIdentity(UpdateChannel.PACKAGE, InstalledVersion("1.1.0", 65), setOf(UpdateChannel.SIGNER))
|
||||||
|
var permission = true
|
||||||
|
var settingsAvailable = true
|
||||||
|
var installs = 0
|
||||||
|
var settingsOpens = 0
|
||||||
|
var inspectAction: (() -> Unit)? = null
|
||||||
|
var outcome = InstallOutcome.Cancelled
|
||||||
|
override fun installed() = installed
|
||||||
|
override fun inspect(file: File): ApkIdentity { inspectAction?.invoke(); return archive }
|
||||||
|
override fun hasInstallPermission() = permission
|
||||||
|
override fun openPermissionSettings(): Boolean { settingsOpens++; return settingsAvailable }
|
||||||
|
override fun install(file: File, asset: UpdateAsset, outcome: (InstallOutcome) -> Unit) { installs++; outcome(this.outcome) }
|
||||||
|
}
|
||||||
|
private val preferences = object : UpdatePreferences {
|
||||||
|
override val autoCheck = MutableStateFlow(true)
|
||||||
|
override suspend fun url() = "$base/releases/latest"
|
||||||
|
override suspend fun enabled() = autoCheck.value
|
||||||
|
override suspend fun setAutoCheck(enabled: Boolean) { autoCheck.value = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before fun start() {
|
||||||
|
directory = temporary.newFolder()
|
||||||
|
server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
base = "http://127.0.0.1:${server.address.port}"
|
||||||
|
server.createContext("/") { exchange ->
|
||||||
|
synchronized(requests) { requests += exchange.requestHeaders.mapValues { it.value.toList() } }
|
||||||
|
val path = exchange.requestURI.path
|
||||||
|
val status = when (path) {
|
||||||
|
"/releases/latest" -> releaseStatus
|
||||||
|
"/releases" -> listStatus
|
||||||
|
"/apk" -> assetStatus
|
||||||
|
else -> 200
|
||||||
|
}
|
||||||
|
val content = when (path) {
|
||||||
|
"/releases/latest", "/redirected" -> if (invalidJson) "not json" else """{"name":"v1.1.1","body":"Household notes","published_at":"2026-11-07T12:00:00Z","assets":[{"name":"wolfskin-update.json","browser_download_url":"$base/manifest"},{"name":"Wolfskin-armeabi-v7a.apk","browser_download_url":"$base/apk"}]}"""
|
||||||
|
"/manifest" -> UpdateManifest.encode(manifest())
|
||||||
|
"/releases" -> listBody
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
if (redirect && path == "/releases/latest") {
|
||||||
|
exchange.responseHeaders.add("Location", redirectTarget ?: "$base/redirected")
|
||||||
|
exchange.responseHeaders.add("Set-Cookie", "private-session=never-forward")
|
||||||
|
exchange.sendResponseHeaders(302, -1)
|
||||||
|
} else {
|
||||||
|
val bytes = if (path == "/apk") (if (truncated) apk.copyOf(apk.size / 2) else apk) else content.toByteArray()
|
||||||
|
val length = when {
|
||||||
|
chunked && path == "/apk" -> 0L
|
||||||
|
wireTruncated && path == "/apk" -> apk.size.toLong()
|
||||||
|
metadataTruncated && path == "/releases/latest" -> bytes.size + 1L
|
||||||
|
else -> bytes.size.toLong()
|
||||||
|
}
|
||||||
|
exchange.sendResponseHeaders(status, length)
|
||||||
|
exchange.responseBody.use { it.write(bytes) }
|
||||||
|
}
|
||||||
|
exchange.close()
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
}
|
||||||
|
@After fun stop() { server.stop(0) }
|
||||||
|
|
||||||
|
private fun manifest() = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", code,
|
||||||
|
"2026-11-07T12:00:00Z", "a".repeat(40), "pwolfskin-opt-v1.1.1", manifestSigner,
|
||||||
|
listOf(UpdateAsset(if (unsupported) "x86_64" else "armeabi-v7a", if (unsupported) "Wolfskin-x86_64.apk" else "Wolfskin-armeabi-v7a.apk", apk.size.toLong(),
|
||||||
|
if (badHash) "0".repeat(64) else MessageDigest.getInstance("SHA-256").digest(apk).joinToString("") { "%02x".format(it) }, 23)))
|
||||||
|
|
||||||
|
private fun controller(scope: kotlinx.coroutines.CoroutineScope) = HouseholdUpdateController(scope, preferences, platform,
|
||||||
|
UpdateTransport(allowLocalTestHttp = true), VerifiedUpdateStore(directory, platform), listOf("armeabi-v7a"), 30)
|
||||||
|
|
||||||
|
@Test fun newerReleaseDownloadsVerifiesAndRestoresWithoutInstalling() = runTest {
|
||||||
|
val controller = controller(this)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||||
|
val metadata = controller.release.value
|
||||||
|
assertEquals(controller.state.value, metadata)
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
assertEquals(metadata, controller.release.value)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
val restored = controller(this); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), restored.state.value)
|
||||||
|
assertEquals(metadata, restored.release.value)
|
||||||
|
restored.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.InstallCancelled, restored.state.value)
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
assertTrue(File(directory, "verified.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun existingManifestOnlyReadyRecordRemainsReadable() = runTest {
|
||||||
|
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||||
|
val metadata = old.release.value!!
|
||||||
|
assertEquals(manifest(), UpdateManifest.parse(File(directory, "ready.json").readText()))
|
||||||
|
File(directory, "ready.json").writeText(UpdateManifest.encode(manifest()))
|
||||||
|
val recreated = controller(this); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), recreated.state.value)
|
||||||
|
assertEquals(metadata.copy(notes = ""), recreated.release.value)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun equalCodeIsUpToDateAndOlderCodeIsNotASuccess() = runTest {
|
||||||
|
code = 65
|
||||||
|
val equal = controller(this); equal.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.UpToDate, equal.state.value)
|
||||||
|
code = 64
|
||||||
|
val older = controller(this); older.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.LowerVersionCode, (older.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun emptyChannelIsDistinctFromMissingOrRefusedEndpoint() = runTest {
|
||||||
|
releaseStatus = 404
|
||||||
|
val empty = controller(this); empty.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.NoReleasePublished, empty.state.value)
|
||||||
|
for (status in listOf(401, 403, 404, 500, 503)) {
|
||||||
|
listStatus = status
|
||||||
|
val failure = controller(this); failure.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.HttpRefused, (failure.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refusedLatestNeverBecomesUpToDate() = runTest {
|
||||||
|
for (status in listOf(401, 403, 500, 503)) {
|
||||||
|
releaseStatus = status
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun invalidJsonAndUnavailableAbiAreTypedFailures() = runTest {
|
||||||
|
invalidJson = true
|
||||||
|
val invalid = controller(this); invalid.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.InvalidMetadata, (invalid.state.value as UpdateUiState.Failed).kind)
|
||||||
|
invalidJson = false; unsupported = true
|
||||||
|
val unsupported = controller(this); unsupported.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.UnsupportedAbi, (unsupported.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun manifestSignerDifferentFromInstallationFailsBeforeDownload() = runTest {
|
||||||
|
manifestSigner = "b".repeat(64)
|
||||||
|
val controller = controller(this)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.WrongSigner, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun checksumAndLengthFailuresNeverCommitReady() = runTest {
|
||||||
|
for (badChecksum in listOf(true, false)) {
|
||||||
|
badHash = badChecksum; truncated = !badChecksum
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.ChecksumMismatch, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun wrongPackageSignerAndArchiveCodeCannotBecomeReady() = runTest {
|
||||||
|
val original = platform.archive
|
||||||
|
for ((archive, expected) in listOf(
|
||||||
|
original.copy(packageId = "other.package") to UpdateFailure.WrongPackage,
|
||||||
|
original.copy(signers = setOf("b".repeat(64))) to UpdateFailure.WrongSigner,
|
||||||
|
original.copy(version = InstalledVersion("1.1.1", 65)) to UpdateFailure.LowerVersionCode,
|
||||||
|
)) {
|
||||||
|
platform.archive = archive
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(expected, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun chunkedTransferReportsUnknownTotalAndCancelDeletesPartial() = runTest {
|
||||||
|
chunked = true
|
||||||
|
val controller = controller(this)
|
||||||
|
var observedUnknownLength = false
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
controller.state.collect { state ->
|
||||||
|
if (state is UpdateUiState.Downloading && state.bytesDone > 0) {
|
||||||
|
observedUnknownLength = state.bytesTotal == null
|
||||||
|
controller.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertTrue(observedUnknownLength)
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun redirectRequestsContainNoCredentialsAndRejectUrlUserInfo() = runTest {
|
||||||
|
redirect = true
|
||||||
|
redirectTarget = "http://localhost:${server.address.port}/redirected"
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Available)
|
||||||
|
assertTrue(requests.size >= 3)
|
||||||
|
requests.forEach { headers ->
|
||||||
|
assertFalse(headers.keys.any { it.equals("Authorization", true) || it.equals("Cookie", true) ||
|
||||||
|
it.contains("Jellyfin", true) || it.contains("Emby", true) || it.contains("MediaBrowser", true) })
|
||||||
|
}
|
||||||
|
redirectTarget = "http://user:password@127.0.0.1:${server.address.port}/redirected"
|
||||||
|
val rejected = controller(this); rejected.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.InvalidMetadata, (rejected.state.value as UpdateUiState.Failed).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun manualCheckWorksWithAutoOffAndPermissionDenialKeepsReady() = runTest {
|
||||||
|
preferences.autoCheck.value = false
|
||||||
|
val controller = controller(this)
|
||||||
|
controller.check(false); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Idle, controller.state.value)
|
||||||
|
controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.permission = false
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
controller.onResume(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
platform.settingsAvailable = false
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.NoInstaller, controller.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun permissionDismissalRetainsReadyAndInstallReopensSettings() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
val metadata = controller.release.value
|
||||||
|
platform.permission = false
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(2, platform.settingsOpens)
|
||||||
|
controller.cancel(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
assertEquals(metadata, controller.release.value)
|
||||||
|
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.PermissionNeeded, controller.state.value)
|
||||||
|
assertEquals(3, platform.settingsOpens)
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun handoffDismissalKeepsReadyWithoutAllowingAnotherOwnedSession() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.outcome = InstallOutcome.Pending
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
controller.cancel(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
controller.handleInstallOutcome(InstallOutcome.Pending); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
val before = requests.size
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
assertEquals(before, requests.size)
|
||||||
|
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||||
|
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(2, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun dismissalDuringInstallVerificationDoesNotDiscardVerifiedBytes() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.inspectAction = { controller.cancel() }
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
assertArrayEquals(apk, File(directory, "verified.apk").readBytes())
|
||||||
|
assertEquals(0, platform.installs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun corruptPersistedBytesAndUnverifiedPartialsAreDiscarded() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
File(directory, "verified.apk").appendText("corrupt")
|
||||||
|
File(directory, "download.part").writeText("incomplete")
|
||||||
|
val recreated = controller(this); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Idle, recreated.state.value)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun downloadHttpFailureRetriesWithoutFalseSuccess() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assetStatus = 403
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.HttpRefused, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assetStatus = 200
|
||||||
|
controller.retry(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Ready("1.1.1"), controller.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun failuresIdentifyCheckDownloadAndInstallWithoutParsingMessages() = runTest {
|
||||||
|
val controller = controller(this)
|
||||||
|
releaseStatus = 403
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateOperation.Check, (controller.state.value as UpdateUiState.Failed).operation)
|
||||||
|
releaseStatus = 200
|
||||||
|
controller.retry(); advanceUntilIdle()
|
||||||
|
assetStatus = 403
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateOperation.Download, (controller.state.value as UpdateUiState.Failed).operation)
|
||||||
|
assetStatus = 200
|
||||||
|
controller.retry(); advanceUntilIdle()
|
||||||
|
File(directory, "verified.apk").writeBytes(ByteArray(apk.size))
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
val failed = controller.state.value as UpdateUiState.Failed
|
||||||
|
assertEquals(UpdateOperation.Install, failed.operation)
|
||||||
|
assertEquals(UpdateFailure.ChecksumMismatch, failed.kind)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun interruptedWireDownloadDoesNotPersistReady() = runTest {
|
||||||
|
truncated = true; wireTruncated = true
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertTrue(controller.state.value is UpdateUiState.Failed)
|
||||||
|
assertFalse(File(directory, "ready.json").exists())
|
||||||
|
assertFalse(File(directory, "download.part").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun exhaustedStorageFailsBeforeDownloading() = runTest {
|
||||||
|
val fullDirectory = object : File(directory.absolutePath) { override fun getUsableSpace(): Long = 0 }
|
||||||
|
val controller = HouseholdUpdateController(this, preferences, platform, UpdateTransport(true),
|
||||||
|
VerifiedUpdateStore(fullDirectory, platform), listOf("armeabi-v7a"), 30)
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
val before = requests.size
|
||||||
|
controller.download(); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.InsufficientStorage, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertEquals(before, requests.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun successCallbackRefreshesPackageManagerInsteadOfClaimingAnUpgrade() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.outcome = InstallOutcome.Success
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(65L, controller.installedVersion.value.versionCode)
|
||||||
|
assertTrue(File(directory, "verified.apk").exists())
|
||||||
|
controller.cancel(); advanceUntilIdle(); controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
platform.installed = platform.archive
|
||||||
|
controller.onResume(); advanceUntilIdle()
|
||||||
|
assertEquals(100001L, controller.installedVersion.value.versionCode)
|
||||||
|
assertEquals(UpdateUiState.Idle, controller.state.value)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun successCallbackDeliveredToTheReplacedProcessDoesNotLeaveAFalseHandoff() = runTest {
|
||||||
|
val old = controller(this); old.check(true); advanceUntilIdle(); old.download(); advanceUntilIdle()
|
||||||
|
platform.installed = platform.archive
|
||||||
|
val replaced = controller(this); advanceUntilIdle()
|
||||||
|
replaced.handleInstallOutcome(InstallOutcome.Success); advanceUntilIdle()
|
||||||
|
assertEquals(100001L, replaced.installedVersion.value.versionCode)
|
||||||
|
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||||
|
replaced.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Idle, replaced.state.value)
|
||||||
|
assertFalse(File(directory, "verified.apk").exists())
|
||||||
|
replaced.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.UpToDate, replaced.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun reentryCheckCannotReplacePendingConfirmationOrStartAnotherSession() = runTest {
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle(); controller.download(); advanceUntilIdle()
|
||||||
|
platform.outcome = InstallOutcome.Pending
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
val before = requests.size
|
||||||
|
controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.HandingOff, controller.state.value)
|
||||||
|
controller.install(); advanceUntilIdle()
|
||||||
|
assertEquals(1, platform.installs)
|
||||||
|
assertEquals(before, requests.size)
|
||||||
|
controller.handleInstallOutcome(InstallOutcome.Cancelled); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.InstallCancelled, controller.state.value)
|
||||||
|
assertTrue(File(directory, "verified.apk").exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun productionTransportRefusesCleartextBeforeRequesting() = runTest {
|
||||||
|
try {
|
||||||
|
UpdateTransport().latest("$base/releases/latest", listOf("armeabi-v7a"), 30)
|
||||||
|
fail("Cleartext should be refused")
|
||||||
|
} catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
|
||||||
|
assertTrue(requests.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unavailableNetworkIsOfflineNotUpToDate() = runTest {
|
||||||
|
server.stop(0)
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateUiState.Offline, controller.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun interruptedFirstCheckIsNetworkFailureNotMissingDownloadStorage() = runTest {
|
||||||
|
directory = File(directory, "not-created")
|
||||||
|
metadataTruncated = true
|
||||||
|
val controller = controller(this); controller.check(true); advanceUntilIdle()
|
||||||
|
assertEquals(UpdateFailure.Network, (controller.state.value as UpdateUiState.Failed).kind)
|
||||||
|
assertFalse(directory.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun compatibleArm32IsPreferredAndUniversalRespectsMinimumSdk() {
|
||||||
|
val arm32 = manifest().assets.single()
|
||||||
|
val arm64 = arm32.copy(abi = "arm64-v8a", name = "Wolfskin-arm64-v8a.apk")
|
||||||
|
val universal = arm32.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 31)
|
||||||
|
val manifest = manifest().copy(assets = listOf(arm64, arm32, universal))
|
||||||
|
assertEquals(arm32, manifest.selectAsset(listOf("arm64-v8a", "armeabi-v7a"), 30))
|
||||||
|
try { manifest.selectAsset(listOf("unknown"), 30); fail("Minimum SDK must be enforced") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.justinvisser.wolfskin.design.ShellKey
|
||||||
|
|
||||||
|
/** Behavior boundaries are controller outcomes, not incidental UI wording. */
|
||||||
|
class UpdateBehaviorTest {
|
||||||
|
@Test fun everyOutcomeHasOnlyExplicitUserActions() {
|
||||||
|
val expected = listOf(
|
||||||
|
UpdateUiState.Idle to listOf(UpdateCommand.Check),
|
||||||
|
UpdateUiState.Checking to listOf(UpdateCommand.Back),
|
||||||
|
UpdateUiState.UpToDate to listOf(UpdateCommand.Check),
|
||||||
|
UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check),
|
||||||
|
updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes),
|
||||||
|
UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel),
|
||||||
|
UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||||
|
UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel),
|
||||||
|
UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel),
|
||||||
|
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||||
|
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
||||||
|
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
|
||||||
|
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
|
||||||
|
expected.forEach { (state, commands) ->
|
||||||
|
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
||||||
|
}
|
||||||
|
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
|
||||||
|
val installed = "1.1.1"
|
||||||
|
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
|
||||||
|
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
|
||||||
|
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
|
||||||
|
assertEquals(presentUpdate(UpdateUiState.Checking, null),
|
||||||
|
presentUpdate(UpdateUiState.Checking, null, installed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||||
|
listOf(null, 0L, -1L).forEach { total ->
|
||||||
|
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||||
|
assertNull(ui.fraction)
|
||||||
|
}
|
||||||
|
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
|
||||||
|
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() {
|
||||||
|
val controller = FakeUpdateController(updateReleaseFixture())
|
||||||
|
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||||
|
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||||
|
focus.key(ShellKey.Right)
|
||||||
|
focus.key(ShellKey.Down)
|
||||||
|
assertTrue(focus.autoFocused)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
focus.key(ShellKey.Ok)
|
||||||
|
assertFalse(controller.autoCheck.value)
|
||||||
|
focus.key(ShellKey.Up)
|
||||||
|
assertFalse(focus.autoFocused)
|
||||||
|
assertEquals(1, focus.actionIndex)
|
||||||
|
assertFalse(controller.autoCheck.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notesTrapFocusAndBackRestoresNotesAction() {
|
||||||
|
val controller = FakeUpdateController(updateReleaseFixture())
|
||||||
|
var backs = 0
|
||||||
|
var navs = 0
|
||||||
|
val steps = mutableListOf<Int>()
|
||||||
|
val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add)
|
||||||
|
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||||
|
focus.key(ShellKey.Right); focus.key(ShellKey.Ok)
|
||||||
|
listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key)
|
||||||
|
assertTrue(focus.notesOpen)
|
||||||
|
assertEquals(listOf(-1, 1), steps)
|
||||||
|
assertEquals(0, navs)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
focus.key(ShellKey.Back)
|
||||||
|
assertFalse(focus.notesOpen)
|
||||||
|
assertEquals(1, focus.actionIndex)
|
||||||
|
assertEquals(0, backs)
|
||||||
|
focus.key(ShellKey.Up)
|
||||||
|
assertEquals(1, navs)
|
||||||
|
focus.key(ShellKey.Back)
|
||||||
|
assertEquals(1, backs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() {
|
||||||
|
val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100))
|
||||||
|
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||||
|
fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) }
|
||||||
|
update(controller.state.value)
|
||||||
|
focus.key(ShellKey.Down)
|
||||||
|
update(UpdateUiState.Downloading(90, 100))
|
||||||
|
assertTrue(focus.autoFocused)
|
||||||
|
update(UpdateUiState.Ready("1.1.1"))
|
||||||
|
assertFalse(focus.autoFocused)
|
||||||
|
focus.key(ShellKey.Right)
|
||||||
|
update(UpdateUiState.PermissionNeeded)
|
||||||
|
update(UpdateUiState.InstallCancelled)
|
||||||
|
assertEquals(0, focus.actionIndex)
|
||||||
|
assertFalse(focus.autoFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same release facts as the approved television frames. */
|
||||||
|
fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026",
|
||||||
|
"Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.")
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Malformed channel metadata must never become a compatible version comparison. */
|
||||||
|
class UpdateManifestTest {
|
||||||
|
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", 128, "a".repeat(64), 23)
|
||||||
|
private val manifest = UpdateManifest(1, UpdateChannel.PACKAGE, "1.1.1", 100001,
|
||||||
|
"2026-11-07T12:00:00Z", "b".repeat(40), "pwolfskin-opt-v1.1.1", UpdateChannel.SIGNER, listOf(asset))
|
||||||
|
|
||||||
|
@Test fun malformedManifestFieldsAreRejectedBeforeVersionComparison() {
|
||||||
|
val invalid = listOf(
|
||||||
|
manifest.copy(schemaVersion = 2),
|
||||||
|
manifest.copy(versionName = "1.1.1-opt"),
|
||||||
|
manifest.copy(versionCode = 0),
|
||||||
|
manifest.copy(publishedAt = "yesterday"),
|
||||||
|
manifest.copy(sourceCommit = "main"),
|
||||||
|
manifest.copy(sourceTag = ""),
|
||||||
|
manifest.copy(signerSha256 = "not-a-certificate-fingerprint"),
|
||||||
|
manifest.copy(assets = emptyList()),
|
||||||
|
manifest.copy(assets = listOf(asset.copy(bytes = 0))),
|
||||||
|
manifest.copy(assets = listOf(asset.copy(sha256 = "not-a-checksum"))),
|
||||||
|
manifest.copy(assets = listOf(asset.copy(abi = "arm64-v8a"))),
|
||||||
|
manifest.copy(assets = listOf(asset, asset)),
|
||||||
|
)
|
||||||
|
invalid.forEach { candidate ->
|
||||||
|
try { UpdateManifest.parse(UpdateManifest.encode(candidate)); fail("Invalid manifest was accepted: $candidate") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.InvalidMetadata, error.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun fallbackRequiresACompatibleStandaloneAsset() {
|
||||||
|
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk", minSdk = 35)
|
||||||
|
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
|
||||||
|
assertEquals(universal, parsed.selectAsset(listOf("arm64-v8a"), 35))
|
||||||
|
try { parsed.selectAsset(listOf("arm64-v8a"), 34); fail("Unsupported minimum SDK was accepted") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun universalDoesNotMakeAnUnsupportedProcessorCompatible() {
|
||||||
|
val universal = asset.copy(abi = "universal", name = "Wolfskin.apk")
|
||||||
|
val parsed = UpdateManifest.parse(UpdateManifest.encode(manifest.copy(assets = listOf(universal))))
|
||||||
|
try { parsed.selectAsset(listOf("riscv64"), 34); fail("Unsupported processor was accepted") }
|
||||||
|
catch (error: UpdateException) { assertEquals(UpdateFailure.UnsupportedAbi, error.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** A replacement or incomplete stream must never reach the session commit boundary. */
|
||||||
|
class UpdateSessionStreamTest {
|
||||||
|
private val original = ByteArray(128 * 1024) { (it % 251).toByte() }
|
||||||
|
private val asset = UpdateAsset("armeabi-v7a", "Wolfskin-armeabi-v7a.apk", original.size.toLong(),
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(original).toHexString(), 23)
|
||||||
|
|
||||||
|
@Test fun sameLengthReplacementAfterVerificationIsRejected() {
|
||||||
|
val replaced = original.copyOf().also { it[it.lastIndex] = (it.last() + 1).toByte() }
|
||||||
|
expectMismatch(replaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun truncatedAndOversizedSessionStreamsAreRejected() {
|
||||||
|
expectMismatch(original.copyOf(original.size - 1))
|
||||||
|
expectMismatch(original + byteArrayOf(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectMismatch(bytes: ByteArray) {
|
||||||
|
try {
|
||||||
|
bytes.inputStream().use { copyVerifiedUpdate(it, ByteArrayOutputStream(), asset) }
|
||||||
|
fail("Changed installation bytes were accepted")
|
||||||
|
} catch (error: UpdateException) {
|
||||||
|
assertEquals(UpdateFailure.ChecksumMismatch, error.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv.update
|
||||||
|
|
||||||
|
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.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
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 androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import java.io.File
|
||||||
|
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.design.*
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class UpdatesKeysTest {
|
||||||
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("updates")
|
||||||
|
settingsRow = 1
|
||||||
|
}
|
||||||
|
private lateinit var controller: FakeUpdateController
|
||||||
|
private val reduced = mutableStateOf(false)
|
||||||
|
private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File)
|
||||||
|
|
||||||
|
@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
|
||||||
|
rule.mainClock.autoAdvance = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) {
|
||||||
|
controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture())
|
||||||
|
if (settings) shell.enterDestination("settings")
|
||||||
|
rule.setContent {
|
||||||
|
CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
|
||||||
|
WolfskinTheme {
|
||||||
|
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||||
|
settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier ->
|
||||||
|
UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String) {
|
||||||
|
val target = directory ?: return
|
||||||
|
target.mkdirs()
|
||||||
|
rule.waitForIdle()
|
||||||
|
val file = File(target, "$name.png")
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun press(keys: List<Key>) {
|
||||||
|
keys.forEach { key ->
|
||||||
|
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||||
|
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
}
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
|
||||||
|
shell.settingsRow = 0
|
||||||
|
launch(UpdateUiState.Checking, settings = true)
|
||||||
|
repeat(9) { press(listOf(Key.DirectionDown)) }
|
||||||
|
assertEquals(9, shell.settingsRow)
|
||||||
|
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
|
||||||
|
capture("classic")
|
||||||
|
press(listOf(Key.DirectionCenter))
|
||||||
|
assertEquals("classic", shell.handOff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
|
||||||
|
launch(updateReleaseFixture())
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
|
||||||
|
assertTrue(shell.contentOverlayVisible)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertFalse(shell.contentOverlayVisible)
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertEquals("settings", shell.route)
|
||||||
|
assertEquals(1, shell.settingsRow)
|
||||||
|
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() {
|
||||||
|
launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" }))
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown))
|
||||||
|
val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
|
||||||
|
assertTrue(range.value() > 0f)
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
|
press(listOf(Key.Back))
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
|
||||||
|
launch(updateReleaseFixture())
|
||||||
|
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
press(listOf(Key.DirectionUp))
|
||||||
|
assertTrue(shell.navFocused)
|
||||||
|
press(listOf(Key.DirectionDown))
|
||||||
|
assertFalse(shell.navFocused)
|
||||||
|
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||||
|
assertTrue(controller.autoCheck.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
|
||||||
|
launch(UpdateUiState.UpToDate)
|
||||||
|
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
|
||||||
|
rule.mainClock.advanceTimeBy(320)
|
||||||
|
rule.onNodeWithTag("update-progress").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
|
||||||
|
launch(UpdateUiState.Checking)
|
||||||
|
val target = directory
|
||||||
|
if (target != null) {
|
||||||
|
repeat(113) { frame ->
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
capture("motion/checking-%03d".format(frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
|
||||||
|
rule.mainClock.advanceTimeByFrame()
|
||||||
|
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
|
||||||
|
if (target != null) {
|
||||||
|
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
|
||||||
|
}
|
||||||
|
press(listOf(Key.DirectionDown))
|
||||||
|
rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) }
|
||||||
|
rule.mainClock.advanceTimeBy(16)
|
||||||
|
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
|
rule.runOnIdle { reduced.value = true }
|
||||||
|
rule.mainClock.advanceTimeBy(96)
|
||||||
|
capture("motion/reduced-000")
|
||||||
|
rule.mainClock.advanceTimeBy(1600)
|
||||||
|
capture("motion/reduced-100")
|
||||||
|
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,13 +23,30 @@ 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 {
|
||||||
|
requestMutex.lock()
|
||||||
|
try {
|
||||||
val title = intent.title
|
val title = intent.title
|
||||||
require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" }
|
if (!canRequest || allowedRequesters?.contains(intent.requester) == false ||
|
||||||
|
(title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused)
|
||||||
|
val key = title.key to intent.requester
|
||||||
|
val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()
|
||||||
|
if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
|
val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull {
|
||||||
|
it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong)))
|
||||||
|
}
|
||||||
|
if (existing != null) return existing.outcome()
|
||||||
|
if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
val body = RequestIntentBody(
|
val body = RequestIntentBody(
|
||||||
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
|
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
|
||||||
recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester,
|
recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester,
|
||||||
seasons = title.seasons?.map(Int::toLong),
|
seasons = title.seasons?.map(Int::toLong),
|
||||||
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
|
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
|
||||||
tmdbId = title.tmdb,
|
tmdbId = title.tmdb,
|
||||||
|
|
@ -37,29 +55,32 @@ class ConductarrDiscoverOperations(
|
||||||
is MutationResult.Success -> result.value.outcome()
|
is MutationResult.Success -> result.value.outcome()
|
||||||
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed)
|
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()
|
||||||
|
var libraryAvailable = true
|
||||||
|
var librarySavedAt: String? = null
|
||||||
|
var watchReady = false
|
||||||
|
val identity = conductarr.identity.value
|
||||||
|
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||||
|
fun publish() {
|
||||||
|
if (revision != loadRevision || sourceScope != scope) return
|
||||||
|
val status = when (catalogueCopy.status) {
|
||||||
|
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
|
||||||
|
DiscoverStatus.Loading -> HomeAvailability.Loading
|
||||||
|
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||||
|
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||||
|
}
|
||||||
|
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable).map {
|
||||||
|
if (it.kind == HomeRowKind.Hero) it.copy(watchReady = watchReady) else it
|
||||||
|
}, status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
val watch = withContext(WholphinDispatchers.IO) { home.readWatch(user.id) }
|
||||||
val savedAt = System.currentTimeMillis()
|
if (revision != loadRevision) return@launch
|
||||||
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. */ } }
|
libraryCopy = libraryCopy.copy(resume = watch.resume, nextUp = watch.nextUp)
|
||||||
if (revision != loadRevision) return
|
watchReady = true
|
||||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
libraryAvailable = true
|
||||||
|
publish()
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is CancellationException) throw exception
|
if (exception is CancellationException) throw exception
|
||||||
if (revision != loadRevision) return
|
if (revision != loadRevision) return@launch
|
||||||
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
|
libraryAvailable = false
|
||||||
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||||
|
publish()
|
||||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
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 -> {
|
||||||
|
val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) {
|
||||||
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
"movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE
|
||||||
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
"episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE
|
||||||
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES
|
||||||
}) } ?: Destination.Wolfskin(titleRoute(action.item.id)))
|
}) } ?: Destination.Wolfskin(titleRoute(action.item.id))
|
||||||
is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000)
|
if (action.episodeSelection && title?.data?.watchTarget?.kind == "episode") navigation.replaceTop(destination)
|
||||||
is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0)
|
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,44 @@ 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,
|
||||||
|
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||||
) : 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) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
WolfskinScreenPreference.initialize(context)
|
WolfskinScreenPreference.initialize(context)
|
||||||
signedInPerson = user.name.orEmpty().lowercase()
|
ConductarrPreference.initialize(context)
|
||||||
if (session.state == null || session.serverId != server.id.toString()) {
|
|
||||||
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 +124,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,37 +193,52 @@ 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" || action.route == "updates" -> 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) }
|
|
||||||
}
|
|
||||||
if (saved == null || saved.isProtected) {
|
|
||||||
signedInPerson = ""
|
signedInPerson = ""
|
||||||
return@launch
|
conductarr.close()
|
||||||
|
libraries.audiences = null
|
||||||
|
personSwitchJob = viewModelScope.launch {
|
||||||
|
personSwitchMutex.withLock {
|
||||||
|
val saved = withContext(Dispatchers.IO) {
|
||||||
|
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
if (saved == null || saved.isProtected) return@launch
|
||||||
try {
|
try {
|
||||||
val current = repository.changeUser(server, saved)
|
val current = repository.changeUser(server, saved)
|
||||||
|
coroutineContext.ensureActive()
|
||||||
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
setupNavigation.navigateTo(SetupDestination.AppContent(current))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
signedInPerson = ""
|
coroutineContext.ensureActive()
|
||||||
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
|
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
|
||||||
|
|
@ -170,8 +259,19 @@ 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()
|
||||||
|
val installedVersion by viewModel.updates.installedVersion.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 +280,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) {
|
||||||
|
|
@ -199,18 +305,30 @@ fun WolfskinApp(
|
||||||
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
|
||||||
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}",
|
||||||
|
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
|
||||||
|
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
|
||||||
|
} else emptyMap(),
|
||||||
|
) {
|
||||||
|
// 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
|
||||||
|
|
@ -219,11 +337,15 @@ fun WolfskinApp(
|
||||||
} else TitleDestination(route, state, preferences)
|
} else TitleDestination(route, state, preferences)
|
||||||
} else {
|
} else {
|
||||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||||
|
settingsInstalledVersion = installedVersion.versionName,
|
||||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||||
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 == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
|
||||||
|
onBack = { state.navFocused = false; navigationManager.goBack() },
|
||||||
|
onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = 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")
|
||||||
|
|
|
||||||
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