Compare commits

...
Sign in to create a new pull request.

41 commits

Author SHA1 Message Date
5162c299db Merge pull request 'Ground Discover watch claims and request history in evidence' (#46) from fm/tv-fb1-truth into main 2026-11-06 20:37:47 +01:00
278335f009 Present watched recommendation seeds and request history truthfully
All checks were successful
review/code-gpt Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
review/design-claude Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
2026-11-06 20:12:06 +01:00
deb24e3c9f Merge pull request 'Persist the debug signing key across container builds' (#45) from fm/tv-stable-key into main 2026-11-06 19:54:26 +01:00
556d633e88 Persist the debug signing key across container builds
All checks were successful
review/code-gpt Build-script only (docker/gradle.sh + README): persistent debug keystore outside the checkout, readonly bind-mount, fails loudly; firstmate read the diff; lane showed identical signer SHA-256 across two optimized builds and the debug build
2026-11-06 17:37:36 +01:00
34087d8eb5 Merge pull request 'Unify TV control focus rings with the approved card style' (#44) from fm/tv-focus-style into main 2026-11-06 15:58:25 +01:00
33dc65bf18 Merge remote-tracking branch 'origin/main' into fm/tv-focus-style
All checks were successful
review/code-gpt Firstmate read the PR evidence and viewed the before/after pairs and native captures against the approved card ring (4 px straddle, no gap); measured ring bounds, inventory with unchanged fill surfaces listed; 20+ targeted native captures and key/session tests; device verification follows on the optimized build
review/design-claude Firstmate read the PR evidence and viewed the before/after pairs and native captures against the approved card ring (4 px straddle, no gap); measured ring bounds, inventory with unchanged fill surfaces listed; 20+ targeted native captures and key/session tests; device verification follows on the optimized build
2026-11-06 15:52:02 +01:00
ef3ae3f21b Unify focus rings on the approved accent straddle style 2026-11-06 15:52:02 +01:00
d0e38306b3 Merge pull request 'Add a release-derived optimized TV comparison build' (#43) from fm/tv-opt-build into main 2026-11-06 15:45:03 +01:00
5306f8ddc4 Keep optimized versions compatible with upgrade and update parsing
All checks were successful
review/code-gpt Build-only change (new optimized build type, own package, baseline seed, version parser test) read by firstmate; release and debug unchanged; the optimized APK was installed and run on the real box (sign-in, picker, Home, Movies work under R8; 0.4 s cold start, 14x lower p99 frame time than debug); version-suffix defect found on the box and fixed in this head
2026-11-06 15:36:20 +01:00
59f4a06815 Register the optimized baseline profile source explicitly 2026-11-06 15:21:19 +01:00
427b18cdc5 Add a release-derived optimized TV comparison variant 2026-11-06 15:12:01 +01:00
93a41ea2d3 Merge pull request 'Keep the person picker closed after switching users' (#42) from fm/tv-picker-ok into main 2026-11-06 15:01:46 +01:00
1c21f50acb Keep the person picker closed after switching users
All checks were successful
review/code-gpt Picker double-OK fix verified on the real box (B9): three single-OK person switches justin/mindel/shared, no crash, logcat clean; 45 targeted tests, final-head apk build
2026-11-06 14:48:36 +01:00
e8d0c52088 Merge pull request 'Keep TV person switches off the main-thread socket close path' (#41) from fm/tv-person-switch-fix into main 2026-11-06 14:18:51 +01:00
db2541feff Move person-switch client disposal off the main thread
All checks were successful
review/code-gpt Person-switch crash fix verified on the real box (B8: justin/mindel/shared switched via the picker three times, no crash, logcat clean); firstmate read the ConductarrSession disposal-scope delta (generation-guarded, IO disposal, no double close); 53 targeted tests
2026-11-06 14:05:01 +01:00
f37ad0f0fe Merge pull request 'Give TV cards roomy spacing and edge-straddling focus rings' (#40) from fm/tv-spacing-b7 into main 2026-11-06 13:24:16 +01:00
92c5661be0 Preserve poster progress and focus across library rail changes
All checks were successful
review/code-gpt GPT review blockers fixed and read by firstmate: poster progress inset back to Title.cardProgressInset (220 px track) with test tag + regression; library focus-follow effect keyed on columns/focusedRow/header/rowHeight with a 199<->200 regression; token values/ring bounds/toolbar gap passed review on c17447c5; walked on the real box
2026-11-06 13:21:06 +01:00
b9ea593735 Merge remote-tracking branch 'origin/main' into fm/tv-spacing-b7 2026-11-06 13:17:37 +01:00
711dbebf28 Merge pull request 'Fix TV startup navigation and complete title pages' (#39) from fm/tv-walk-fix1 into main 2026-11-06 13:16:43 +01:00
6e0f9952cf Reconcile removed Anime roots without discarding pushed pages
All checks were successful
review/code-gpt GPT review blocker B1 (Anime root removal strands Back) fixed: delta read by firstmate (ShellState reconciles route/root to Home, preservePushed replaces stack root, 111-line regression, 16 targeted tests green); all other review areas cleared on 9e15dbee
2026-11-06 13:11:08 +01:00
c17447c528 Give TV cards roomy spacing and edge-straddling focus rings
Some checks failed
review/code-gpt Spacing review: progress-bar inset regression; rail transition focus invalidation
2026-11-06 12:53:51 +01:00
9e15dbee8c Preserve navigation focus when Anime availability changes
Some checks failed
review/code-gpt Anime removal leaves active root invalid; Back pops a singleton stack (B1).
2026-11-06 12:33:10 +01:00
53b8358317 Hide Anime destinations without visible library titles 2026-11-06 12:25:51 +01:00
5a84666074 Synchronize fresh-sign-in navigation and restore test globals 2026-11-06 12:11:42 +01:00
22850632ec Restrict shell route and Back handling to the current entry 2026-11-06 12:03:20 +01:00
afff383b75 Read live shell state inside cached navigation entries 2026-11-06 11:52:48 +01:00
d401ebc355 Fix TV startup navigation and complete title pages 2026-11-06 11:27:35 +01:00
e794dec253 Merge pull request 'Fix post-sign-in crash from snapshot state writes' (#38) from fm/tv-hotfix-snapshot-write into main 2026-11-06 11:09:51 +01:00
4e6377b628 Keep shell state reads safe in read-only snapshots
All checks were successful
review/code-claude Hotfix verified on the real Xiaomi box (B3 runs, no crash across Home/Discover/Movies/Shows/title/Settings/Requests); 5-file delta read by firstmate: ShellState.rememberedView no longer writes on read, 3 regression tests incl. snapshotFlow and an audit
2026-11-06 10:50:56 +01:00
e9a92eba49 Merge pull request 'Align TV fidelity and recovery behavior' (#37) from fm/tv-fidelity-followups into main 2026-11-06 02:35:14 +01:00
81073c410d Synchronize progressive focus assertions with UI publication
All checks were successful
review/design-claude Claude review: PASS, no blocking finding (head 81073c41)
2026-11-06 02:20:13 +01:00
04b1bdd6aa Align TV layouts and close recovery gaps 2026-11-06 02:15:09 +01:00
bfe3dbbb16 Merge pull request 'Wire real Conductarr data into Wolfskin TV' (#36) from fm/tv-conductarr-wiring into main 2026-11-06 01:41:47 +01:00
ec32346c71 Isolate the artwork test singleton lifecycle
All checks were successful
review/code-claude B1 (artwork credential scoped to the exact Jellyfin scheme/host/port by a network interceptor, fresh ImageLoader, redirect-safe, 2-host test) and B2 (audiences null keeps the name rule, foreground recovery, test) verified by firstmate reading the 9-file delta; final gates green
2026-11-06 01:32:59 +01:00
34ea983a51 Scope artwork credentials and preserve library recovery 2026-11-06 01:25:47 +01:00
09f1d72f30 Keep Home responsive and resolve authorized request recipients
Some checks failed
review/code-claude Block: token sent to image hosts via Coil auth client; failed audiences read hides Jellyfin libraries
2026-11-06 00:31:48 +01:00
78322f588a Integrate real Conductarr TV title and recovery providers 2026-11-06 00:10:27 +01:00
a00db96dc8 Merge current TV recovery states into Conductarr wiring 2026-11-05 23:24:21 +01:00
71a055987c Merge pull request 'Build the Wolfskin TV title, episode and person pages' (#34) from fm/tv-slice5-titles into main 2026-11-05 23:21:03 +01:00
59c2728d9c Wire selected Jellyfin users to Conductarr TV data 2026-11-05 22:55:54 +01:00
f53defad90 Update Conductarr TV contracts and ratings reads 2026-11-05 22:55:25 +01:00
133 changed files with 17470 additions and 564 deletions

View file

@ -82,6 +82,7 @@ configure<ApplicationExtension> {
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"")
} }
signingConfigs { signingConfigs {
@ -132,6 +133,13 @@ configure<ApplicationExtension> {
isDebuggable = true isDebuggable = true
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
create("optimized") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".opt"
matchingFallbacks += "release"
}
} }
flavorDimensions += "version" flavorDimensions += "version"
productFlavors { productFlavors {
@ -198,6 +206,10 @@ configure<ApplicationExtension> {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
kotlin.directories += "src/wolfskin/java" kotlin.directories += "src/wolfskin/java"
} }
getByName("optimized") {
kotlin.directories += "src/release/java"
baselineProfiles.directories += "src/optimized/baselineProfiles"
}
} }
testOptions { testOptions {
@ -227,6 +239,11 @@ androidComponents {
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk" "Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
output.outputFileName = outputFileName output.outputFileName = outputFileName
} }
if (variant.name == "defaultOptimized") {
tasks.withType<Test>().configureEach {
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
}
}
if (variant.name == "defaultDebug") { if (variant.name == "defaultDebug") {
val packagedApks = variant.artifacts.get(SingleArtifact.APK) val packagedApks = variant.artifacts.get(SingleArtifact.APK)
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader() val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
@ -448,6 +465,7 @@ dependencies {
testImplementation(libs.mockk.android) testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent) testImplementation(libs.mockk.agent)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation("com.squareup.okhttp3:mockwebserver3")
testImplementation(libs.androidx.core.testing) testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)

View file

@ -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

View file

@ -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>

View file

@ -660,6 +660,31 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val ConductarrUrl =
AppStringPreference<AppPreferences>(
title = R.string.wolfskin_conductarr_url,
defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL,
getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl },
setter = { prefs, value ->
org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value)
prefs
},
summary = R.string.wolfskin_conductarr_url_summary,
)
val WolfskinFixtures =
AppSwitchPreference<AppPreferences>(
title = R.string.wolfskin_fixtures,
defaultValue = false,
getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures },
setter = { prefs, value ->
org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value)
prefs
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val UserProfileSettings = val UserProfileSettings =
AppDestinationPreference<AppPreferences>( AppDestinationPreference<AppPreferences>(
title = R.string.override_user_profile_settings, title = R.string.override_user_profile_settings,
@ -1320,6 +1345,8 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.WolfskinScreens, AppPreference.WolfskinScreens,
AppPreference.ConductarrUrl,
*if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(),
AppPreference.ShowClock, AppPreference.ShowClock,
AppPreference.BackdropStylePref, AppPreference.BackdropStylePref,
AppPreference.ShowLogos, AppPreference.ShowLogos,

View 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;

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
</resources>

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.test package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
@ -18,6 +19,12 @@ class VersionCompareTests {
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234") val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
} }
@Test
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
}
@Test @Test
fun testIsAtLeast() { fun testIsAtLeast() {
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0)) Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))

View file

@ -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)
}
}

View file

@ -0,0 +1,106 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeAvailabilityTest {
private var previousFixtures = false
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
conductarr.select("${server.id}:${user.id}", token, fake.url)
conductarr.authenticate()
val identity = conductarr.identity.value!!
val audiences = identity.provider.audiences()!!.filter {
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
}
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
val api = mockk<ApiClient>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
})
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
val libraries = LibraryReadFacade(api)
val session = WolfskinSession().apply {
state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply {
pickerVisible = false
focusNav(tabs.size)
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
assertEquals("The avatar must remain focused when Anime appears or disappears",
session.state!!.tabs.size, session.state!!.navIndex)
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
destinationTotals = mapOf("anime" to count))
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
} finally { model.viewModelScope.cancel() }
}
}
}
}
}

View file

@ -0,0 +1,111 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeRootRemovalTest {
private var previousFixtures = false
private var previousUrl = ""
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
previousUrl = ConductarrPreference.baseUrl
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking {
val title = Destination.Wolfskin("title/tv%3A123")
val person = Destination.Wolfskin("person/456")
val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true,
listOf(title) to false, listOf(title, person) to false)
for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val context = ApplicationProvider.getApplicationContext<Application>()
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns null
var hasTitles = true
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles }
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()
val shell = requireNotNull(session.state)
assertTrue(shell.person.hasAnime)
shell.key(ShellKey.Ok)
shell.focusNav(shell.tabs.indexOf("anime"))
shell.key(ShellKey.Ok)
assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList())
for (page in pushed) {
navigation.navigateTo(page)
shell.enterDestination(page.route)
}
if (avatarFocused) shell.focusNav(shell.tabs.size)
hasTitles = false
requireNotNull(model.onForeground(server, user)).join()
assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs)
assertEquals("Home must replace the missing root, not the current pushed page",
listOf(Destination.Home()) + pushed, navigation.backStack.toList())
assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route)
assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab)
if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex)
shell.key(ShellKey.Back)
if (pushed.isEmpty()) {
assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused)
assertEquals("home", shell.route)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
if (!avatarFocused) assertEquals(0, shell.navIndex)
} else {
assertEquals("Back pops only the current page and retains the replacement Home root",
listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList())
}
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ConductarrAudienceRecoveryTest {
@Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
@After fun restoreMain() { Dispatchers.resetMain() }
@Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
for (empty in listOf(false, true)) FakeConductarr().use { fake ->
fake.audiencesStatus = if (empty) 200 else 503
fake.emptyAudiences = empty
ConductarrSession(MemoryCacheStore()).use { session ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
session.authenticate()
assertTrue(session.identity.value!!.signedIn)
val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences)
assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES))
assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS))
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
if (!empty) {
fake.audiencesStatus = 200
requireNotNull(model.onForeground(server, user)).join()
assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries)
assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS))
assertSame(client, session.identity.value!!.client)
}
} finally { model.viewModelScope.cancel() }
}
}
}
@Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previousUrl = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { session ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
session.authenticate()
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
try {
val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared")
fake.postStatus = 503
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
identity.provider.operations(identity.operator!!).request(intent).phase)
model.retryConductarr(server, user)
val refreshed = session.identity.value!!
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
refreshed.provider.operations(refreshed.operator!!).request(intent).phase)
assertEquals(1, fake.calls.count { it.method == "POST" })
} finally {
model.viewModelScope.cancel()
ConductarrPreference.updateBaseUrl(previousUrl)
}
}
}
}
}

View file

@ -0,0 +1,95 @@
// 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.read(user.id) } returns HomeLibrary(resume = listOf(
HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true)))
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() }
}
}
}
}

View file

@ -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))
}
}
}
}

View file

@ -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() }
}
}

View file

@ -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)
}
}
}
}

View file

@ -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())
}
}

View file

@ -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() }
}
}
}
}

View file

@ -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() }
}

View file

@ -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)
} }
} }

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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()
}
}
}
}
}

View 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()
}

View file

@ -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.SORT_NAME, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
}
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model,
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
}
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
conductarr.identity.value?.signedIn == true && model.libraries.audiences != null
}
rule.onNodeWithText("Mindel").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible)
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
pressKey(Key.DirectionUp)
pressKey(Key.DirectionRight)
pressKey(Key.DirectionRight)
pressKey(Key.DirectionCenter)
}
rule.waitForIdle()
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("library-grid").assertIsDisplayed()
rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed()
rule.onNodeWithText("Desperate Housewives").assertDoesNotExist()
assertEquals(6, session.state!!.view.library!!.source.size)
assertFalse(session.state!!.person.hasAnime)
rule.onNodeWithText("Shows").performClick()
rule.waitForIdle()
try {
rule.waitUntil(timeoutMillis = 15_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodes(hasTestTag("library-grid") and
hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty()
}
} catch (timeout: ComposeTimeoutException) {
val shell = session.state!!
throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " +
"movies=${shell.rememberedView("movies").library?.source?.size}, " +
"shows=${shell.rememberedView("shows").library?.source?.size}/" +
"${shell.rememberedView("shows").library?.status}", timeout)
}
rule.onNodeWithTag("library-grid").assertIsDisplayed()
assertEquals(29, session.state!!.view.library!!.source.size)
rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed()
rule.onNodeWithText("Home").performClick()
rule.waitForIdle()
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible)
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -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))
@ -42,8 +44,8 @@ class HomeRecoveryTest {
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.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists()) val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) } 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)
response.completeExceptionally(IOException("fixture offline")) response.completeExceptionally(IOException("fixture offline"))
@ -62,11 +64,11 @@ class HomeRecoveryTest {
val responseA = CompletableDeferred<HomeLibrary>() val responseA = CompletableDeferred<HomeLibrary>()
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.read(personB.id) } throws IOException("fixture offline") coEvery { facade.read(personB.id) } throws IOException("fixture offline")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists()) val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") } 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 })
@ -82,10 +84,24 @@ class HomeRecoveryTest {
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.read(personA.id) } returns library
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 } })
}
} }

View file

@ -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()

View file

@ -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) }
}
}

View file

@ -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.SORT_NAME, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
assertEquals(200, offsets.last())
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)
}
}

View file

@ -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,31 @@ 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 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)
}
}
} }

View file

@ -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)
}
}
}

View file

@ -0,0 +1,157 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
/** A selected identity gets a new app ViewModel without opening a second launch picker. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class PersonPickerSessionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var previousUrl: String
private var previousFixtures = false
private var previousScreens = true
private val models = mutableListOf<WolfskinShellViewModel>()
private lateinit var session: WolfskinSession
@Before fun capturePreferences() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousUrl = ConductarrPreference.baseUrl
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(false)
WolfskinScreenPreference.enabled = true
}
@After fun restorePreferences() {
models.forEach { it.viewModelScope.cancel() }
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
}
private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) {
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) ->
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
}
var current by mutableStateOf(CurrentUser(server, users[initial]))
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
val setup = mockk<SetupNavigationManager>(relaxed = true)
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
coEvery { libraries.hasAnimeTitles(any()) } returns false
session = WolfskinSession()
try {
rule.setContent {
key(current.user.id) {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
viewModel = model, homeContent = { _, _, user, modifier ->
androidx.tv.material3.Text("Home for ${user.name}", modifier)
})
}
}
awaitLoaded(users[initial])
if (fromAvatar) {
press(listOf(Key.DirectionCenter))
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
}
val actions = mutableListOf<ShellAction.Person>()
rule.runOnIdle {
val dispatch = session.onAction
session.onAction = { action ->
if (action is ShellAction.Person) actions.add(action)
dispatch(action)
}
}
if (fast) press(moves + Key.DirectionCenter) else {
press(moves)
press(listOf(Key.DirectionCenter))
}
awaitLoaded(users[selected])
rule.runOnIdle {
assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible)
assertEquals("home", session.state!!.route)
assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId)
assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId })
assertEquals(if (initial == selected) 1 else 2, models.size)
}
rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed()
} finally { models.forEach { it.viewModelScope.cancel() } }
}
}
}
private fun awaitLoaded(user: JellyfinUser) {
try {
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
models.last().conductarr.identity.value?.signedIn == true &&
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
session.state?.people?.size == 3 &&
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
}
} catch (timeout: ComposeTimeoutException) {
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
}
rule.waitForIdle()
}
private fun press(keys: List<Key>) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
}
@Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight))
@Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft))
@Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight))
@Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft))
@Test fun initialPersonNeedsOnlyOneOk() = select(0, 0)
@Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
@Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft))
}

View file

@ -0,0 +1,252 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import io.mockk.*
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class PersonSwitchThreadingTest {
private val context = ApplicationProvider.getApplicationContext<Application>()
private lateinit var previousUrl: String
private var previousFixtures = false
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(context)
previousUrl = ConductarrPreference.baseUrl
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
unmockkConstructor(ConductarrClient::class)
}
@Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking {
assertSame(Looper.getMainLooper(), Looper.myLooper())
val closeThreads = CopyOnWriteArrayList<Boolean>()
val releaseCloses = CountDownLatch(1)
val closed = CountDownLatch(4)
val disposalErrors = CopyOnWriteArrayList<Throwable>()
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) })
val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true)
mockkConstructor(ConductarrClient::class)
every { anyConstructed<ConductarrClient>().close() } answers {
val onMain = Looper.myLooper() == Looper.getMainLooper()
closeThreads.add(onMain)
check(!onMain) { "StrictMode: Conductarr socket close on main" }
check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" }
callOriginal()
closed.countDown()
if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure")
}
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
val setup = mockk<SetupNavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()
clients.add(conductarr.identity.value!!.client)
val shell = session.state!!
shell.key(ShellKey.Ok)
for (index in listOf(1, 2, 0)) {
val switched = CompletableDeferred<CurrentUser>()
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(index)
shell.key(ShellKey.Ok)
assertEquals(users[index].id.toString(), shell.person.jellyfinUserId)
assertEquals("home", shell.route)
assertFalse(shell.pickerVisible)
assertNull("Old identity must not serve the newly selected person", conductarr.identity.value)
val current = withTimeout(5_000) { switched.await() }
assertEquals(users[index].id, current.user.id)
model.load(current.server, current.user)
requireNotNull(model.onForeground(server, current.user)).join()
assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester)
assertEquals(users[index].name!!.lowercase(), model.signedInPerson)
clients.add(conductarr.identity.value!!.client)
}
conductarr.close()
conductarr.close()
model.viewModelScope.cancel()
releaseCloses.countDown()
assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) })
assertEquals(listOf(false, false, false, false), closeThreads.toList())
for (client in clients) {
val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last()
assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error)
}
disposalScope.coroutineContext.job.children.toList().joinAll()
assertTrue("A failing socket close must be contained", disposalErrors.isEmpty())
} finally {
releaseCloses.countDown()
model.viewModelScope.cancel()
withContext(Dispatchers.IO) { conductarr.close() }
disposalScope.coroutineContext.job.children.toList().joinAll()
disposalScope.cancel()
}
}
}
@Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking {
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
val pending = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
coEvery { repository.changeUser(server, any()) } coAnswers {
val user = secondArg<JellyfinUser>()
if (user.id == users[1].id) {
pending.complete(Unit)
withContext(NonCancellable) { release.await() }
}
CurrentUser(server, user)
}
val switched = CompletableDeferred<CurrentUser>()
val setup = mockk<SetupNavigationManager>(relaxed = true)
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()
val shell = session.state!!
shell.key(ShellKey.Ok)
for (index in listOf(1, 2, 0)) {
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(index)
shell.key(ShellKey.Ok)
if (index == 1) withTimeout(5_000) { pending.await() }
}
release.complete(Unit)
val current = withTimeout(5_000) { switched.await() }
assertEquals(justin.id, current.user.id)
verify(exactly = 1) { setup.navigateTo(any()) }
model.load(server, current.user)
requireNotNull(model.onForeground(server, current.user)).join()
assertEquals(justin.id.toString(), shell.person.jellyfinUserId)
assertEquals("justin", model.signedInPerson)
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
} finally {
release.complete(Unit)
model.viewModelScope.cancel()
}
}
}
}
@Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking {
val closeThreads = CopyOnWriteArrayList<Boolean>()
val closed = CountDownLatch(3)
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
mockkConstructor(ConductarrClient::class)
every { anyConstructed<ConductarrClient>().close() } answers {
val onMain = Looper.myLooper() == Looper.getMainLooper()
closeThreads.add(onMain)
check(!onMain) { "StrictMode: retry socket close on main" }
callOriginal()
closed.countDown()
}
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
val repository = mockk<ServerRepository>()
every { repository.currentUser } returns user
every { repository.currentServer } returns server
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
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())
}
}
}

View file

@ -52,7 +52,7 @@ 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() {

View file

@ -0,0 +1,164 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ShellStartupTimingTest {
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
@After fun resetMain() { Dispatchers.resetMain() }
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previous = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
conductarr.authenticate()
val activityEntered = CompletableDeferred<Unit>()
val releaseActivity = java.util.concurrent.CountDownLatch(1)
fake.beforeResponse = {
if (it == "/api/v1/activity") {
activityEntered.complete(Unit)
releaseActivity.await()
}
}
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
withTimeout(5_000) { activityEntered.await() }
val savedPeople = CompletableDeferred<Unit>()
val savedPeopleStarted = CompletableDeferred<Unit>()
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null }
val navigation = mockk<NavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val load = launch { model.load(server, user) }
try {
withTimeout(5_000) { savedPeopleStarted.await() }
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
shell.key(ShellKey.Ok)
for (route in listOf("movies", "shows", "discover")) {
shell.focusNav(shell.tabs.indexOf(route))
shell.key(ShellKey.Ok)
verify { navigation.replace(Destination.Wolfskin(route)) }
assertEquals(route, shell.route)
}
savedPeople.complete(Unit)
load.join()
assertSame(shell, session.state)
assertEquals("discover", shell.route)
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
} finally {
releaseActivity.countDown()
activity.cancelAndJoin()
savedPeople.complete(Unit)
load.cancelAndJoin()
model.viewModelScope.cancel()
}
}
}
ConductarrPreference.updateBaseUrl(previous)
}
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previous = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
val switched = CompletableDeferred<Unit>()
coEvery { repository.changeUser(server, mindel) } coAnswers {
switched.complete(Unit)
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
}
val navigation = mockk<NavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
fake.beforeResponse = {
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
pending.complete(Unit)
release.await()
}
}
try {
model.load(server, shared)
withTimeout(5_000) { pending.await() }
val shell = requireNotNull(session.state)
shell.key(ShellKey.Ok)
shell.enterDestination("discover")
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(1)
shell.key(ShellKey.Ok)
assertEquals("home", shell.route)
verify { navigation.replace(Destination.Home()) }
withTimeout(5_000) { switched.await() }
model.load(server, mindel)
requireNotNull(model.onForeground(server, mindel)).join()
release.countDown()
assertSame(shell, session.state)
assertEquals("mindel", model.signedInPerson)
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
assertEquals("Mindel", shell.person.name)
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
assertEquals("home", shell.route)
} finally {
release.countDown()
model.viewModelScope.cancel()
}
}
}
ConductarrPreference.updateBaseUrl(previous)
}
}

View file

@ -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)

View file

@ -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) }
} }

View file

@ -81,6 +81,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,6 +104,29 @@ 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 heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
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 { @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
facade.read(firstId, null, preferences) facade.read(firstId, null, preferences)
@ -164,6 +188,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)

View file

@ -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"

View file

@ -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()
}
}
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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
}
}

View file

@ -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)
}

View file

@ -2,6 +2,7 @@
package org.justinvisser.wolfskin.tv package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.flow.last import kotlinx.coroutines.flow.last
import kotlinx.coroutines.sync.withLock
import org.justinvisser.wolfskin.conductarr.ClientError import org.justinvisser.wolfskin.conductarr.ClientError
import org.justinvisser.wolfskin.conductarr.ConductarrClient import org.justinvisser.wolfskin.conductarr.ConductarrClient
import org.justinvisser.wolfskin.conductarr.MediaType import org.justinvisser.wolfskin.conductarr.MediaType
@ -22,44 +23,64 @@ import org.justinvisser.wolfskin.design.RequestPhase
class ConductarrDiscoverOperations( class ConductarrDiscoverOperations(
private val client: ConductarrClient, private val client: ConductarrClient,
private val refreshSnapshot: suspend () -> DiscoverSnapshot, private val refreshSnapshot: suspend () -> DiscoverSnapshot,
private val profile: String? = null,
private val allowedRequesters: Set<String>? = null,
private val canRequest: Boolean = true,
) : DiscoverOperations { ) : DiscoverOperations {
private val submissions = mutableSetOf<Pair<String, String>>()
private val requestMutex = kotlinx.coroutines.sync.Mutex()
private val dismissalMutex = kotlinx.coroutines.sync.Mutex()
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult {
val title = intent.title requestMutex.lock()
require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" } try {
val body = RequestIntentBody( val title = intent.title
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, if (!canRequest || allowedRequesters?.contains(intent.requester) == false ||
recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester, (title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused)
seasons = title.seasons?.map(Int::toLong), val key = title.key to intent.requester
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()
tmdbId = title.tmdb, if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed)
) val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull {
return when (val result = client.mutate(TvRoutes.request(body))) { it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong)))
is MutationResult.Success -> result.value.outcome() }
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) if (existing != null) return existing.outcome()
} if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed)
val body = RequestIntentBody(
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester,
seasons = title.seasons?.map(Int::toLong),
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
tmdbId = title.tmdb,
)
return when (val result = client.mutate(TvRoutes.request(body))) {
is MutationResult.Success -> result.value.outcome()
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed)
}
} finally { requestMutex.unlock() }
} }
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) is ReadResult.Value -> result.saved.value?.requests?.firstOrNull {
it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong)))
}?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed) is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
} }
} }
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock {
val result = client.mutate(TvRoutes.hide(profile, val result = client.mutate(TvRoutes.hide(this.profile ?: profile,
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) title = title.title, tmdbId = title.tmdb, year = title.year?.toLong())))
return result is MutationResult.Success || observedHidden(profile, title) == true result is MutationResult.Success || observedHidden(profile, title) == true
} }
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock {
val result = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType())) val result = client.mutate(TvRoutes.restore(this.profile ?: profile, title.tmdb, title.mediaType()))
return result is MutationResult.Success || observedHidden(profile, title) == false result is MutationResult.Success || observedHidden(profile, title) == false
} }
private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? { private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? {
return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
is ReadResult.Value -> result.saved.value?.dismissals?.any { is ReadResult.Value -> result.saved.value?.dismissals?.any {
it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name
} }

View file

@ -0,0 +1,68 @@
// 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
/** 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): ConductarrSession =
ConductarrSession(ConductarrDiskCache(context), scope)
}
/** 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)
}

View file

@ -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"
}
}

View file

@ -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())) }
}

View file

@ -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)) }
}
}

View file

@ -0,0 +1,117 @@
// 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),
) : 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), 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")
}

View file

@ -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(),
)

View file

@ -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)
}

View file

@ -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()
} }

View file

@ -34,25 +34,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(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
if (retry && conductarr.identity.value?.signedIn != true) {
withContext(Dispatchers.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}"
@ -63,30 +76,72 @@ class WolfskinHomeViewModel @Inject constructor(
} }
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
if (revision != loadRevision) return 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
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),
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)
}
}
try { try {
val library = withContext(Dispatchers.IO) { home.read(user.id) } val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis() val savedAt = System.currentTimeMillis()
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 = library
if (revision != loadRevision) return publish()
snapshot = HomeSnapshot(buildHomeRows(library, continuation)) withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } }
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
if (revision != loadRevision) return if (revision != loadRevision) return
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)
} }
} }
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 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
} }
@ -102,6 +157,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 +203,16 @@ 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()
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) }
val view = shell.rememberedView("home")
LaunchedEffect(view.row, model.snapshot.rows) {
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
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) }
} }
} }

View file

@ -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()
} }
} }

View file

@ -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())
} }
} }

View file

@ -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
@ -52,6 +62,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 +77,29 @@ 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
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
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 +122,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.takeIf { it.data.status == TitleReadStatus.Ready }?.season, 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 +182,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) } }
@ -178,13 +247,17 @@ 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)
} }
} }
@ -233,18 +306,28 @@ 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()
LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) }
val title = viewModel.title?.takeIf { it.person.key == shell.person.key } val 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 }

View file

@ -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
}

View file

@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -60,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
class WolfskinSession @Inject constructor() { class WolfskinSession @Inject constructor() {
var state by mutableStateOf<ShellState?>(null) var state by mutableStateOf<ShellState?>(null)
var serverId: String? = null var serverId: String? = null
internal var fixtureMode: Boolean? = null
var onAction: (ShellAction) -> Unit = {} var onAction: (ShellAction) -> Unit = {}
} }
@ -74,24 +78,43 @@ class WolfskinShellViewModel @Inject constructor(
private val backdrop: BackdropService, private val backdrop: BackdropService,
val libraries: LibraryReadFacade, val libraries: LibraryReadFacade,
val seeAll: WolfskinSeeAllLists, val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
) : ViewModel() { ) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
private val requestsSnapshot by lazy { readShellRequests(context) } private val requestsSnapshot by lazy { readShellRequests(context) }
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap()) var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
private set private set
var signedInPerson by mutableStateOf("") var signedInPerson by mutableStateOf("")
private set private set
private var identityJob: kotlinx.coroutines.Job? = null
private var personSwitchJob: kotlinx.coroutines.Job? = null
private val personSwitchMutex = Mutex()
suspend fun load(server: JellyfinServer, user: JellyfinUser) { suspend fun load(server: JellyfinServer, user: JellyfinUser) {
WolfskinScreenPreference.initialize(context) withContext(Dispatchers.IO) {
signedInPerson = user.name.orEmpty().lowercase() WolfskinScreenPreference.initialize(context)
if (session.state == null || session.serverId != server.id.toString()) { ConductarrPreference.initialize(context)
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
session.serverId = server.id.toString()
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
} }
identityJob?.cancel()
libraries.audiences = null
if (ConductarrPreference.fixtures) conductarr.close()
else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
session.onAction = { action -> handleAction(action, server, user) } session.onAction = { action -> handleAction(action, server, user) }
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
session.serverId = server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
session.state?.updatePeople(savedPeople)
}
identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user)
}
try { try {
libraryDestinations = libraries.readDestinations(user.id) libraryDestinations = libraries.readDestinations(user.id)
} catch (exception: Exception) { } catch (exception: Exception) {
@ -100,18 +123,68 @@ class WolfskinShellViewModel @Inject constructor(
} }
} }
private suspend fun loadHousehold(user: JellyfinUser) {
conductarr.authenticate()
val identity = conductarr.identity.value ?: return
if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return
val profiles = identity.provider.profiles()?.profiles.orEmpty()
val audiences = identity.provider.audiences()
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
val hasAnime = try {
libraries.hasAnimeTitles(user.id)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
}
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
val updated = session.state?.people.orEmpty().map { person ->
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
val current = person.jellyfinUserId == user.id.toString()
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
hasAnime = if (current) hasAnime else person.hasAnime)
}
session.state?.updatePeople(updated)
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
}
/** Foreground recovery retains the current identity and any usable audience mapping. */
fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? {
if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null
identityJob?.cancel()
return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it }
}
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
withContext(Dispatchers.IO) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
retry = conductarr.identity.value?.signedIn != true)
}
loadHousehold(user)
}
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ /** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */
fun refreshRequests() = refreshRequests(retry = false) fun refreshRequests() = refreshRequests(retry = false)
private fun refreshRequests(retry: Boolean) { private fun refreshRequests(retry: Boolean) {
val shell = session.state ?: return val shell = session.state ?: return
val target = shell.requests val target = shell.requests
val identity = conductarr.identity.value
viewModelScope.launch { viewModelScope.launch {
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } if (ConductarrPreference.fixtures) {
target.update(snapshot) target.update(withContext(Dispatchers.IO) { requestsSnapshot })
if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) { } else if (identity?.signedIn != true) {
target.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
} else identity.provider.requests().collect { snapshot ->
if (conductarr.identity.value?.client === identity.client) {
target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot())
}
}
if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
} }
} }
@ -119,34 +192,49 @@ class WolfskinShellViewModel @Inject constructor(
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
when (action) { when (action) {
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)) is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when {
action.route == "settings" -> navigation.navigateTo(target)
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target)
}
}
ShellAction.Pop -> navigation.goBack() ShellAction.Pop -> navigation.goBack()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user) is ShellAction.Person -> selectPerson(action.person, server)
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
ShellAction.RetryRequests -> refreshRequests(retry = true) ShellAction.RetryRequests -> viewModelScope.launch {
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
refreshRequests(retry = true)
}
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}"))) is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}")))
} }
} }
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
navigation.replace(Destination.Home()) navigation.replace(Destination.Home())
if (person.key == user.name.orEmpty().lowercase()) return if (person.key == signedInPerson) return
viewModelScope.launch { identityJob?.cancel()
val saved = withContext(Dispatchers.IO) { personSwitchJob?.cancel()
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) } signedInPerson = ""
} conductarr.close()
if (saved == null || saved.isProtected) { libraries.audiences = null
signedInPerson = "" personSwitchJob = viewModelScope.launch {
return@launch personSwitchMutex.withLock {
} val saved = withContext(Dispatchers.IO) {
try { repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
val current = repository.changeUser(server, saved) }
setupNavigation.navigateTo(SetupDestination.AppContent(current)) if (saved == null || saved.isProtected) return@launch
} catch (exception: Exception) { try {
if (exception is kotlinx.coroutines.CancellationException) throw exception val current = repository.changeUser(server, saved)
signedInPerson = "" coroutineContext.ensureActive()
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } setupNavigation.navigateTo(SetupDestination.AppContent(current))
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive()
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
}
} }
} }
} }
@ -170,8 +258,18 @@ fun WolfskinApp(
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WolfskinShellViewModel = hiltViewModel(), viewModel: WolfskinShellViewModel = hiltViewModel(),
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) { ) {
LaunchedEffect(server.id, user.id) { viewModel.load(server, user) } val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) {
if (!ConductarrPreference.fixtures) {
if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
else viewModel.refreshRequests()
}
}
val loading = remember(user.id) { val loading = remember(user.id) {
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
} }
@ -180,10 +278,16 @@ fun WolfskinApp(
state, state,
{ route -> navigationManager.navigateTo(Destination.Wolfskin(route)) }, { route -> navigationManager.navigateTo(Destination.Wolfskin(route)) },
navigationManager::goBack, navigationManager::goBack,
identity = identity,
fixtures = ConductarrPreference.fixtures,
retrySignIn = {
viewModel.retryConductarr(server, user)
},
) )
RequestsRefresh(state, viewModel::refreshRequests) RequestsRefresh(state, viewModel::refreshRequests)
WolfskinTheme { WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
Box(modifier) { Box(modifier) {
val current = navigationManager.backStack.last() val current = navigationManager.backStack.last()
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
@ -200,17 +304,23 @@ fun WolfskinApp(
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination -> entryProvider = { destination ->
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
// Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current
val discover = LocalDiscoverState.current
val route = when (destination) { val route = when (destination) {
is Destination.Home -> "home" is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route is Destination.Wolfskin -> destination.route
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
else -> "pushed" else -> "pushed"
} }
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } val isCurrent = navigationManager.backStack.lastOrNull() == destination
LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
}
if (destination.fullScreen) { if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else { } else {
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
@ -223,7 +333,7 @@ fun WolfskinApp(
when { when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -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)
}

View file

@ -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,31 @@ fun WolfskinLibraryContent(
ShellPlaceholder("See all list unavailable", modifier) ShellPlaceholder("See all list unavailable", modifier)
return return
} }
LaunchedEffect(grid, grid.refreshRevision, fixture) { LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
if (route in LIBRARY_DESTINATIONS && (!BuildConfig.DEBUG || fixture != null)) { 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) { 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")

View file

@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */ /** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
@Singleton @Singleton
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) { class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home")) private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
fun read(scope: String): Pair<HomeLibrary, Long>? { fun read(scope: String): Pair<HomeLibrary, Long>? {
val entry = store.read(scope, KEY) ?: return null val entry = store.read(scope, KEY) ?: return null

View file

@ -21,25 +21,30 @@ import org.justinvisser.wolfskin.design.HomeLibrary
import org.justinvisser.wolfskin.design.HomeTitle import org.justinvisser.wolfskin.design.HomeTitle
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) { class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
val audiences get() = libraries.audiences
suspend fun read(userId: UUID): HomeLibrary { suspend fun read(userId: UUID): HomeLibrary {
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title) val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title) val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
}
}
val resume = resumeItems.map { title(it, series) }
val next = nextItems.map { title(it, series) }
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
val destinations = linkedMapOf<String, MutableList<HomeTitle>>() val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
val newest = mutableListOf<HomeTitle>() val newest = mutableListOf<HomeTitle>()
val totals = linkedMapOf<String, Int>() val totals = linkedMapOf<String, Int>()
for (library in libraries) { for (library in libraries) {
val key = when { val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue
"anime" in library.name.orEmpty().lowercase() -> "anime"
library.collectionType == CollectionType.MOVIES -> "movies"
library.collectionType == CollectionType.TVSHOWS -> "shows"
else -> continue
}
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title)) destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) })
totals[key] = (totals[key] ?: 0) + response.totalRecordCount totals[key] = (totals[key] ?: 0) + response.totalRecordCount
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title)) newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
} }
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
} }
@ -60,7 +65,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
}?.id }?.id
} }
private fun title(item: BaseItemDto): HomeTitle { private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
val wrapped = BaseItem(item) val wrapped = BaseItem(item)
return HomeTitle( return HomeTitle(
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
@ -69,7 +74,8 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720), backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(), addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(),
) )
} }

View file

@ -1,5 +1,11 @@
package org.justinvisser.wolfskin.tv.read package org.justinvisser.wolfskin.tv.read
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -16,33 +22,48 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */ /** SDK reads are confined here so shell and screen state never depend on SDK responses. */
@javax.inject.Singleton
class LibraryReadFacade @Inject constructor(private val api: ApiClient) { class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null)
fun destination(name: String, collection: CollectionType?): String? {
audiences?.let { known ->
val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null
return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows"
}
return when {
name.contains("anime", ignoreCase = true) -> "anime"
collection == CollectionType.MOVIES -> "movies"
collection == CollectionType.TVSHOWS -> "shows"
else -> null
}
}
suspend fun readDestinations(userId: UUID): Map<String, Destination> { suspend fun readDestinations(userId: UUID): Map<String, Destination> {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
return buildMap { return buildMap {
libraries.forEach { library -> libraries.forEach { library ->
val name = library.name.orEmpty().lowercase() val key = destination(library.name.orEmpty(), library.collectionType)
val key = when {
"anime" in name -> "anime"
library.collectionType == CollectionType.MOVIES -> "movies"
library.collectionType == CollectionType.TVSHOWS -> "shows"
else -> null
}
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
} }
} }
} }
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
suspend fun hasAnimeTitles(userId: UUID): Boolean {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
for (library in libraries) {
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
if (titles.isNotEmpty()) return true
}
return false
}
/** A destination spans every matching accessible library; containers never become titles. */ /** A destination spans every matching accessible library; containers never become titles. */
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> { fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
val anime = library.name.orEmpty().contains("anime", ignoreCase = true) destination(library.name.orEmpty(), library.collectionType) == destination
when (destination) {
"anime" -> anime
"movies" -> !anime && library.collectionType == CollectionType.MOVIES
"shows" -> !anime && library.collectionType == CollectionType.TVSHOWS
else -> false
}
} }
val titles = linkedMapOf<UUID, LibraryTitle>() val titles = linkedMapOf<UUID, LibraryTitle>()
for (library in libraries) { for (library in libraries) {
@ -69,8 +90,9 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
) )
} }
offset += chunk.size offset += chunk.size
if (chunk.isNotEmpty()) emit(titles.values.toList())
} while (chunk.isNotEmpty() && offset < response.totalRecordCount) } while (chunk.isNotEmpty() && offset < response.totalRecordCount)
} }
return titles.values.toList() if (titles.isEmpty()) emit(emptyList())
} }
} }

View file

@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */ /** Library snapshots share the existing atomic disk store, partitioned by server and person. */
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> List<LibraryTitle>): Flow<LibrarySnapshot> = flow { fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
val key = "library:$destination" val key = "library:$destination"
val saved = try { cache.read(scope, key) } catch (_: IOException) { null } val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
var current: List<LibraryTitle> = emptyList()
try { try {
val current = load() load().collect { chunk ->
current = chunk
if (titles == null) emit(LibrarySnapshot(chunk, null, false))
}
val time = now() val time = now()
if (titles != null) emit(LibrarySnapshot(current, time, false))
try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ } try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ }
emit(LibrarySnapshot(current, time, false))
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true)) emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true))
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)

View file

@ -54,6 +54,19 @@ class TitleReadFacade @Inject constructor(
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID)
/** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */
suspend fun findHeld(tmdb: Long, media: String): UUID? {
val response = api.request(org.jellyfin.sdk.api.client.HttpMethod.GET, "/Items",
mapOf("UserId" to repository.currentUser?.id, "Recursive" to true,
"IncludeItemTypes" to if (media == "movie") "Movie" else "Series",
"AnyProviderIdEquals" to "Tmdb.$tmdb", "Fields" to "ProviderIds", "Limit" to 1), emptyMap(), null)
.createContent<org.jellyfin.sdk.model.api.BaseItemDtoQueryResult>()
return response.items?.firstOrNull { row ->
row.type == (if (media == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) &&
row.providerIds?.entries?.any { it.key.equals("Tmdb", true) && it.value?.toLongOrNull() == tmdb } == true
}?.id
}
/** A disk copy is decoded before any network read; ownership is isolated by server and user. */ /** A disk copy is decoded before any network read; ownership is isolated by server and user. */
suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) { suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) {
val file = cacheFile(id) val file = cacheFile(id)
@ -70,8 +83,8 @@ class TitleReadFacade @Inject constructor(
/** An episode opens its series with that exact episode as both play and watched target. */ /** An episode opens its series with that exact episode as both play and watched target. */
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
val file = cacheFile(id) val file = cacheFile(id)
val opened = api.userLibraryApi.getItem(id).content val opened = readItem(id)
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
@ -88,11 +101,26 @@ class TitleReadFacade @Inject constructor(
val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows))
.put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString()) .put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString())
.put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty()) .put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty())
if (cacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Title identity changed")
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ } try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ }
result.copy(trailerUrl = trailer) result.copy(trailerUrl = trailer)
} }
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
private suspend fun readItem(id: UUID): BaseItemDto {
val item = api.userLibraryApi.getItem(id).content
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
val metadata = try {
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
} ?: return item
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
}
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */ /** A failed reachability read leaves navigation on the title instead of opening a dead player. */
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }
@ -138,6 +166,7 @@ class TitleReadFacade @Inject constructor(
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200,
sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty()
val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id }
if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed")
file.parentFile?.mkdirs() file.parentFile?.mkdirs()
try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) } try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) }
catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ } catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ }
@ -178,6 +207,7 @@ class TitleReadFacade @Inject constructor(
backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) }, backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) },
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000, held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0, seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
) )
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")

View file

@ -1,10 +1,10 @@
# Conductarr TV client # Conductarr TV client
A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app depends on this module; no screen, navigation, login flow or production service is changed. A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app supplies the currently selected Jellyfin login through its person-scoped `ConductarrSession`.
## Pinned contract and generator decision ## Pinned contract and generator decision
`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `481492d3a8a0651a89901de548cd511218c0562e`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service. `openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `7e8f3f0a1c9a88d67b2a9518c1161db84ade531a`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service.
The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation. The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation.
@ -18,8 +18,8 @@ Thus the generator does **not** handle every construct in this pinned document.
`ContractTest` checks: `ContractTest` checks:
- All 20 TV method/path pairs against the pinned paths, including response and mutation body schemas. - All 22 TV method/path pairs against the pinned paths, including response and mutation body schemas.
- All 68 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. - All 70 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them.
- A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority. - A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority.
When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema. When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema.
@ -28,7 +28,7 @@ When updating the pin, update the models and reviewed schema snapshot together.
Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved. Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved.
`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. `TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/ratings/display/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary.
`client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists. `client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists.
@ -49,9 +49,9 @@ Cache keys partition by normalized server URL, credential digest, endpoint, path
| Feed | Server `expires_at`; conditional ETag revalidation | | Feed | Server `expires_at`; conditional ETag revalidation |
| Catalogue options | `FOREGROUND` refreshes | | Catalogue options | `FOREGROUND` refreshes |
| Discovery pages | 30 minutes per parameterized lane/page | | Discovery pages | 30 minutes per parameterized lane/page |
| Enrichment ratings | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | | Ratings-only read | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG |
| Title, cast, related, performer, filmography, dismissals, readiness | `OPEN` refreshes | | Title, cast, related, enrichment, display, performer, filmography, dismissals, readiness | `OPEN` refreshes |
| Seasons | `OPEN` on season focus; retained per season, 300 entries | | Seasons | 6 hours for episode scores; `OPEN` refreshes on title-season focus; retained per season, 300 entries |
| Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` | | Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` |
| Title requests and request mutations | No cache | | Title requests and request mutations | No cache |

View file

@ -1 +1 @@
481492d3a8a0651a89901de548cd511218c0562e 7e8f3f0a1c9a88d67b2a9518c1161db84ade531a

View file

@ -1097,6 +1097,16 @@
"unknown" "unknown"
], ],
"type": "string" "type": "string"
},
"vote_average": {
"description": "Provider episode score on a 0–10 scale; absence is not zero.",
"format": "double",
"type": "number"
},
"vote_count": {
"description": "Provider episode vote count; explicit zero is retained.",
"format": "int64",
"type": "integer"
} }
}, },
"required": [ "required": [
@ -7058,6 +7068,33 @@
], ],
"type": "object" "type": "object"
}, },
"TitleRatings": {
"additionalProperties": false,
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
"examples": [
"https://example.com/schemas/TitleRatings.json"
],
"format": "uri",
"readOnly": true,
"type": "string"
},
"ratings": {
"items": {
"$ref": "#/components/schemas/Rating"
},
"type": [
"array",
"null"
]
}
},
"required": [
"ratings"
],
"type": "object"
},
"TitleRequestsBody": { "TitleRequestsBody": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@ -8299,7 +8336,7 @@
"paths": { "paths": {
"/api/v1/activity": { "/api/v1/activity": {
"get": { "get": {
"description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-activity", "operationId": "get-activity",
"parameters": [ "parameters": [
{ {
@ -8311,6 +8348,14 @@
"description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting",
"type": "string" "type": "string"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -8322,7 +8367,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -8375,7 +8439,7 @@
}, },
"/api/v1/activity/titles/{tmdb}/{type}": { "/api/v1/activity/titles/{tmdb}/{type}": {
"get": { "get": {
"description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-activity-title", "operationId": "get-activity-title",
"parameters": [ "parameters": [
{ {
@ -8409,6 +8473,14 @@
"description": "Household member to read readiness for. Defaults to the signed-in operator", "description": "Household member to read readiness for. Defaults to the signed-in operator",
"type": "string" "type": "string"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -8420,7 +8492,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -9516,7 +9607,7 @@
}, },
"/catalogue/discover": { "/catalogue/discover": {
"get": { "get": {
"description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-discover", "operationId": "get-catalogue-discover",
"parameters": [ "parameters": [
{ {
@ -9584,6 +9675,14 @@
], ],
"type": "string" "type": "string"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -9595,7 +9694,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -9648,7 +9766,7 @@
}, },
"/catalogue/options": { "/catalogue/options": {
"get": { "get": {
"description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-options", "operationId": "get-catalogue-options",
"parameters": [ "parameters": [
{ {
@ -9663,6 +9781,14 @@
], ],
"type": "string" "type": "string"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -9674,7 +9800,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -9727,7 +9872,7 @@
}, },
"/catalogue/performers/{performer}": { "/catalogue/performers/{performer}": {
"get": { "get": {
"description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-performer", "operationId": "get-catalogue-performer",
"parameters": [ "parameters": [
{ {
@ -9739,6 +9884,14 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -9750,7 +9903,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -9803,7 +9975,7 @@
}, },
"/catalogue/performers/{performer}/filmography": { "/catalogue/performers/{performer}/filmography": {
"get": { "get": {
"description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-filmography", "operationId": "get-catalogue-filmography",
"parameters": [ "parameters": [
{ {
@ -9839,6 +10011,14 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -9850,7 +10030,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -10006,7 +10205,7 @@
}, },
"/catalogue/titles/{tmdb}/{type}": { "/catalogue/titles/{tmdb}/{type}": {
"get": { "get": {
"description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-title", "operationId": "get-catalogue-title",
"parameters": [ "parameters": [
{ {
@ -10030,6 +10229,14 @@
], ],
"type": "string" "type": "string"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -10041,7 +10248,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -10094,7 +10320,7 @@
}, },
"/catalogue/titles/{tmdb}/{type}/cast": { "/catalogue/titles/{tmdb}/{type}/cast": {
"get": { "get": {
"description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-cast", "operationId": "get-catalogue-cast",
"parameters": [ "parameters": [
{ {
@ -10118,6 +10344,14 @@
], ],
"type": "string" "type": "string"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -10129,7 +10363,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -10444,9 +10697,97 @@
"summary": "Retained title genres and NL streaming offers" "summary": "Retained title genres and NL streaming offers"
} }
}, },
"/catalogue/titles/{tmdb}/{type}/ratings": {
"get": {
"description": "Authenticated ratings-only display read with the same source observations as enrichment. Server cache: 6 hours for positive results, 1 minute for missing results, at most 300 typed title identities; concurrent identical reads share a fetch. Provider failures are not cached. Expired entries are never served. Reads no Arr, files, mirrors, memberships or watch state and never authorizes a title action. Enrichment and committed title reads remain fresh. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.",
"operationId": "get-catalogue-ratings",
"parameters": [
{
"in": "path",
"name": "tmdb",
"required": true,
"schema": {
"format": "int64",
"minimum": 1,
"type": "integer"
}
},
{
"in": "path",
"name": "type",
"required": true,
"schema": {
"enum": [
"tv",
"movie"
],
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TitleRatings"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Authentik sign-in required"
},
"403": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Operator or target is not permitted"
},
"503": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Identity verification or operation unavailable"
},
"default": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Error"
}
},
"security": [
{
"authentikSession": []
}
],
"summary": "Cached title source ratings"
}
},
"/catalogue/titles/{tmdb}/{type}/related": { "/catalogue/titles/{tmdb}/{type}/related": {
"get": { "get": {
"description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-related", "operationId": "get-catalogue-related",
"parameters": [ "parameters": [
{ {
@ -10482,6 +10823,14 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -10493,7 +10842,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -10546,7 +10914,7 @@
}, },
"/catalogue/tv/{tmdb}/seasons/{season}": { "/catalogue/tv/{tmdb}/seasons/{season}": {
"get": { "get": {
"description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-catalogue-season", "operationId": "get-catalogue-season",
"parameters": [ "parameters": [
{ {
@ -10568,6 +10936,14 @@
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
} }
},
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@ -10579,7 +10955,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {
@ -11726,8 +12121,18 @@
}, },
"/curation/library-audiences": { "/curation/library-audiences": {
"get": { "get": {
"description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.",
"operationId": "get-library-audiences", "operationId": "get-library-audiences",
"parameters": [
{
"description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison",
"in": "header",
"name": "If-None-Match",
"schema": {
"type": "string"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -11737,7 +12142,26 @@
} }
} }
}, },
"description": "OK" "description": "OK",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
},
"304": {
"description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.",
"headers": {
"ETag": {
"description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes",
"schema": {
"type": "string"
}
}
}
}, },
"401": { "401": {
"content": { "content": {

View file

@ -13,7 +13,7 @@ enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity:
DISCOVERY(30 * 60 * 1000L), DISCOVERY(30 * 60 * 1000L),
RATINGS(6 * 60 * 60 * 1000L, 300), RATINGS(6 * 60 * 60 * 1000L, 300),
ON_OPEN(Long.MAX_VALUE), ON_OPEN(Long.MAX_VALUE),
SEASON(Long.MAX_VALUE, 300), SEASON(6 * 60 * 60 * 1000L, 300),
ACTIVITY(30 * 1000L), ACTIVITY(30 * 1000L),
NO_CACHE(0), NO_CACHE(0),
; ;

View file

@ -52,6 +52,8 @@ object TvRoutes {
private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast") private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast")
private val related = RouteContract("GET", "${title.path}/related", "Page") private val related = RouteContract("GET", "${title.path}/related", "Page")
private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment") private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment")
private val ratings = RouteContract("GET", "${title.path}/ratings", "TitleRatings")
private val display = RouteContract("GET", "${title.path}/display", "Display")
private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail") private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail")
private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail") private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail")
private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage") private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage")
@ -63,7 +65,7 @@ object TvRoutes {
val contracts: List<RouteContract> = listOf( val contracts: List<RouteContract> = listOf(
operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast, operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast,
related, enrichment, season, performer, filmography, activity, readiness, requests, request, audiences, related, enrichment, ratings, display, season, performer, filmography, activity, readiness, requests, request, audiences,
) )
fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH) fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH)
@ -97,7 +99,9 @@ object TvRoutes {
fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
fun ratings(tmdb: Long, media: MediaType) = ReadCall(ratings, TitleRatings.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS)
fun display(tmdb: Long, media: MediaType) = ReadCall(display, Display.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN)
fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall( fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall(
related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN, related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN,
) )

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Display wire contract from the pinned Conductarr schema. */
@Serializable
data class Display(
@SerialName("media_type") val mediaType: MediaType,
@SerialName("metadata_state") val metadataState: MetadataState,
@SerialName("poster_url") val posterUrl: String? = null,
@SerialName("title") val title: String? = null,
@SerialName("tmdb_id") val tmdbId: Long,
) {
@Serializable
enum class MediaType { @SerialName("movie") MOVIE, @SerialName("tv") TV }
@Serializable
enum class MetadataState {
@SerialName("observed") OBSERVED,
@SerialName("missing") MISSING,
@SerialName("unavailable") UNAVAILABLE,
}
}

View file

@ -12,6 +12,8 @@ data class Episode(
@SerialName("number") val number: Long, @SerialName("number") val number: Long,
@SerialName("overview") val overview: String, @SerialName("overview") val overview: String,
@SerialName("recorded") val recorded: Recorded, @SerialName("recorded") val recorded: Recorded,
@SerialName("vote_average") val voteAverage: Double? = null,
@SerialName("vote_count") val voteCount: Long? = null,
) { ) {
/** Wire values for recorded. */ /** Wire values for recorded. */
@Serializable @Serializable

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.conductarr.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** TitleRatings wire contract from the pinned Conductarr schema. */
@Serializable
data class TitleRatings(
@SerialName("ratings") val ratings: List<Rating>?,
)

View file

@ -13,6 +13,7 @@ class CacheTest {
Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L), Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L),
Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L), Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L),
Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L), Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L),
Triple(CachePolicy.SEASON, ReadTrigger.IF_STALE, 21_600_000L),
Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L), Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L),
) )
for ((policy, trigger, lifetime) in rows) { for ((policy, trigger, lifetime) in rows) {

View file

@ -121,7 +121,7 @@ class ClientTest {
@Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking { @Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking {
val client = client() val client = client()
val call = TvRoutes.enrichment(42, MediaType.MOVIE) val call = TvRoutes.ratings(42, MediaType.MOVIE)
server.enqueue(response("missing", 404)) server.enqueue(response("missing", 404))
assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent)
now += 6 * 60 * 60 * 1000L - 1 now += 6 * 60 * 60 * 1000L - 1

View file

@ -29,7 +29,7 @@ class ContractTest {
for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name]) for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name])
} }
@Test fun `all twenty TV operations exist with their typed request and response contracts`() { @Test fun `all twenty two TV operations exist with their typed request and response contracts`() {
val expected = setOf( val expected = setOf(
"GET /api/v1/operator", "GET /api/v1/recommendations/profiles", "GET /api/v1/operator", "GET /api/v1/recommendations/profiles",
"GET /api/v1/recommendations/profiles/{profile}/feed", "GET /api/v1/recommendations/profiles/{profile}/feed",
@ -39,6 +39,7 @@ class ContractTest {
"GET /catalogue/options", "GET /catalogue/discover", "GET /catalogue/options", "GET /catalogue/discover",
"GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast", "GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast",
"GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment", "GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment",
"GET /catalogue/titles/{tmdb}/{type}/ratings", "GET /catalogue/titles/{tmdb}/{type}/display",
"GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}", "GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}",
"GET /catalogue/performers/{performer}/filmography", "GET /catalogue/performers/{performer}/filmography",
"GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}", "GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}",
@ -47,6 +48,19 @@ class ContractTest {
) )
assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet()) assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet())
assertEquals(expected.size, TvRoutes.contracts.size) assertEquals(expected.size, TvRoutes.contracts.size)
val operations = setOf(
"get-cockpit-operator", "get-recommendation-profiles", "get-library-audiences",
"get-recommendation-feed", "get-catalogue-options", "get-catalogue-discover",
"get-recommendation-dismissals", "post-recommendation-dismissal", "delete-recommendation-dismissal",
"get-catalogue-title", "get-catalogue-cast", "get-catalogue-related", "get-catalogue-season",
"get-catalogue-enrichment", "get-catalogue-display", "get-activity-title", "get-title-requests",
"get-catalogue-performer", "get-catalogue-filmography", "post-request-intent", "get-activity",
"get-catalogue-ratings",
)
assertEquals(operations, TvRoutes.contracts.map { route ->
document.getValue("paths").jsonObject.getValue(route.path).jsonObject
.getValue(route.method.lowercase()).jsonObject.getValue("operationId").jsonPrimitive.content
}.toSet())
val paths = document["paths"]!!.jsonObject val paths = document["paths"]!!.jsonObject
for (route in TvRoutes.contracts) { for (route in TvRoutes.contracts) {
val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject

View file

@ -13,6 +13,7 @@ internal val modelSerializers: List<KSerializer<*>> = listOf(
CastMember.serializer(), CastMember.serializer(),
Detail.serializer(), Detail.serializer(),
Discovery.serializer(), Discovery.serializer(),
Display.serializer(),
Enrichment.serializer(), Enrichment.serializer(),
Episode.serializer(), Episode.serializer(),
EpisodeFileChip.serializer(), EpisodeFileChip.serializer(),
@ -72,6 +73,7 @@ internal val modelSerializers: List<KSerializer<*>> = listOf(
TitleCast.serializer(), TitleCast.serializer(),
TitleMetadata.serializer(), TitleMetadata.serializer(),
TitleRequestsBody.serializer(), TitleRequestsBody.serializer(),
TitleRatings.serializer(),
TitleVersion.serializer(), TitleVersion.serializer(),
ViewerReadiness.serializer(), ViewerReadiness.serializer(),
) )

View file

@ -444,6 +444,16 @@
"unknown" "unknown"
], ],
"type": "string" "type": "string"
},
"vote_average": {
"description": "Provider episode score on a 0\u201310 scale; absence is not zero.",
"format": "double",
"type": "number"
},
"vote_count": {
"description": "Provider episode vote count; explicit zero is retained.",
"format": "int64",
"type": "integer"
} }
}, },
"required": [ "required": [
@ -3169,5 +3179,77 @@
"sources" "sources"
], ],
"type": "object" "type": "object"
},
"Display": {
"additionalProperties": false,
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
"examples": [
"https://example.com/schemas/Display.json"
],
"format": "uri",
"readOnly": true,
"type": "string"
},
"media_type": {
"enum": [
"movie",
"tv"
],
"type": "string"
},
"metadata_state": {
"enum": [
"observed",
"missing",
"unavailable"
],
"type": "string"
},
"poster_url": {
"type": "string"
},
"title": {
"type": "string"
},
"tmdb_id": {
"format": "int64",
"type": "integer"
}
},
"required": [
"tmdb_id",
"media_type",
"metadata_state"
],
"type": "object"
},
"TitleRatings": {
"additionalProperties": false,
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
"examples": [
"https://example.com/schemas/TitleRatings.json"
],
"format": "uri",
"readOnly": true,
"type": "string"
},
"ratings": {
"items": {
"$ref": "#/components/schemas/Rating"
},
"type": [
"array",
"null"
]
}
},
"required": [
"ratings"
],
"type": "object"
} }
} }

View file

@ -1,8 +1,8 @@
# Wolfskin TV container builds # Wolfskin TV container builds
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the host needs Docker, GNU Make, Bash, Git, `realpath` (GNU coreutils), and `flock`
Android SDK. (util-linux), not Java or the Android SDK.
```sh ```sh
git fetch origin --tags git fetch origin --tags
@ -27,6 +27,85 @@ Upstream enables JVM unit tests only for debug variants; there is no
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
task without changing upstream's test configuration. task without changing upstream's test configuration.
## Optimized side-by-side comparison
The `optimized` build type inherits `release`: R8 optimization/minification,
resource shrinking, non-debuggable code, dependency consumer keep rules and
dependency baseline profiles. It changes only signing (the Android debug key),
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
The launcher icon/banner and release-compatible version name are unchanged:
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
`make apk` remains the clean, release-key-signed production build. Debug and
optimized containers share the persistent debug key described below; release
signing is unchanged.
Build all four default-flavor outputs in one invocation through the container
wrapper, without a release keystore. On a host whose Gradle cache already holds
the pinned native extensions and their metadata:
```sh
bash docker/gradle.sh optimized --offline \
-PWholphinExtensionsUsername=JustinZeus \
-PWholphinExtensionsPassword=unused \
:app:assembleDefaultOptimized
```
The unused password is not a credential: offline resolution never contacts
GitHub Packages. A cold cache instead needs a read-packages credential supplied
through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container,
or securely seeded cached artifacts. No release signing inputs are needed.
Without native extension configuration, the existing dependency selection uses
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
playback-complete comparison. Inspect native libraries before installing.
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
armeabi-v7a and x86_64. Keep
`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to
deobfuscate crashes. This variant shares release library variants and the release
`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork.
`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode
or Compose diagnostic stack traces.
This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal
server/user flow again; it does not inherit the debug/release app's logins or
preferences. **Wolfskin screens** defaults on, and the same Advanced switch can
restore upstream screens. The same Conductarr door
(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected
Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential
or embedded secret is added. All other settings retain release behavior,
including update checks; a production release APK is not an update for this
separate package/debug signer.
`androidx.profileinstaller` and library-provided profiles already exist. There
is no recorded app baseline-profile source or Macrobenchmark module.
`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and
activity classes plus their `onCreate` methods, `WolfskinAppKt`,
`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library
profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and
`baseline.profm`. It is not a measured navigation profile or evidence of a speedup.
To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark
module, record cold launch, Quick Connect/sign-in, person selection, Home,
Libraries, Discover, title pages and playback on a suitable device, then rebuild
and compare startup/frame timings. Device-driven recording and runtime checks
remain separate from an APK build.
The optimized source set explicitly registers that profile directory; a bare
`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration.
Static verification: use `aapt2 dump badging` for package/label/SDKs and absence
of `application-debuggable`; `apksigner verify --print-certs` for the debug
signer; `unzip -l` for both dexopt assets, native ABI contents and absence of
`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective
consumer rules rather than adding blanket keeps. JVM tests do not prove
minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real
Conductarr reads, artwork, database reopen and each player on the comparison box.
`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug
variant has no JVM unit-test task, just like release. The existing debug JVM
`VersionCompareTests` receives the actual optimized version name from the variant
API and checks it with the same `Version.fromString` used by upgrade/update handling.
Build logic is otherwise checked through actual APK assembly and static inspection.
## Upstream v1.1.0 integration ## Upstream v1.1.0 integration
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
@ -78,6 +157,13 @@ resolves consistently in native captures; the approved JSON weights stay unchang
Nav, picker names, labels and pill weights use that same mapping. Nav label colour Nav, picker names, labels and pill weights use that same mapping. Nav label colour
fades for 200 ms ease-out; the blob retains its critically damped spring. fades for 200 ms ease-out; the blob retains its critically damped spring.
Shell view accessors are read-only, including the first read from Discover's
`snapshotFlow`. Construction and explicit person/navigation events retain root
and destination memory; reading an unknown destination returns an unsaved default.
Per-person focus, cards and scrollers still survive route changes, while reselecting
the current root resets it. JVM regressions exercise both read-only snapshots and
the signed-in asynchronous Discover host without a screen prewarming its memory.
Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring.
The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
@ -92,10 +178,34 @@ Settings list. Native Up traversal stays in content until the first focusable ro
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
Debug people, tabs and initials come from the shell-only projection of the locked Saved Jellyfin identities use the existing upstream session. Conductarr supplies
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources household labels, requester capabilities and library audiences after sign-in.
do not package it. Saved Jellyfin identities use existing upstream services. The The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
shell makes no Conductarr calls and does not add Conductarr sign-in. only when the Advanced debug fixture switch is enabled; release excludes it.
Person selection immediately clears the old Conductarr identity and Jellyfin
read gate before saved-login I/O. Detached clients are disposed exactly once on
the application's IO scope, after the replacement (or signed-out) identity is
published; a slow or failing socket close cannot block the picker or abort a
switch. Cleanup outlives the replaced screen. Retry and session replacement use
the same disposal owner. Superseded sign-ins are cancelled and serialized so a
late reply cannot navigate back to an earlier person.
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
server and shell state, not to the user-scoped ViewModel. Switching people replaces
the upstream app entry and ViewModel but retains the same-server, same-mode shell:
the picker stays closed on Home after the first OK. A cold start still opens it,
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
shell and user-entry replacement for launch/avatar Left and Right, the initial
person, gear and back, and fast Left-Left-OK; each selection emits one person action.
Preference and people-fixture reads, Conductarr client construction (including
Home/title retries), and private-cache directory initialization run on IO.
`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel →
Shared → Justin while all closes are held pending, verifies retired clients
cannot start reads, contains a failing close, and checks a burst of choices
against a late sign-in reply. The thread-recording close fake rejects the main
Looper; actual TLS/StrictMode verification remains a real-device check.
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
@ -103,14 +213,21 @@ destination is an explicit title-family integration stub exposing only Discover'
request and pick actions. Unheld titles have no trailer control until the pinned request and pick actions. Unheld titles have no trailer control until the pinned
Conductarr title contract supplies a trailer. Conductarr title contract supplies a trailer.
Named “Because you watched” shelves require watched contribution evidence.
Seeds are grouped by media type and TMDB identity, never by caption; repeated
contributions retain both watched/added flags. The current feed has no per-seed
watch timestamp, so eligible shelves keep wire order. Added-only contributions
remain available in the general For-you collections, but make no named shelf.
Titleless contributions do not make a shelf. Recommendations remain acquisition
suggestions, not a filter to the viewer's current library.
The debug-only fixture and local art live in The debug-only fixture and local art live in
`app/src/debug/assets/wolfskin-discover/`. Release builds remain on the explicit `app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
Conductarr sign-in state. The fixture host cannot construct a network client: network-free fixture host; real providers are the default in debug and release.
debug request outcomes and dismissals stay in memory. The real mutation adapter The mutation adapter accepts the selected person's `ConductarrClient` and uses
accepts an injected `ConductarrClient` and uses only its typed routes; it is not only typed routes. Request submission first reads title receipts. Unknown outcomes
wired to a live server. Unknown request outcomes force a fresh title-requests read are checked, never blindly re-sent, including after closing a sheet or choosing
instead of blindly submitting again, including after closing a sheet or choosing the same uncertain recipient again. Whole-series requests require season metadata.
the same uncertain recipient from another chooser. Whole-series requests require every season.
Discover hero and Restore text use explicit per-line CSS allocations so native Discover hero and Restore text use explicit per-line CSS allocations so native
paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles
@ -210,10 +327,10 @@ before mounting any source read with the prior person's Jellyfin session.
Debug variants additionally package the locked fixture and local artwork under Debug variants additionally package the locked fixture and local artwork under
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
The fixture provider supplies the approved household data and Home states without The explicit fixture provider supplies approved household data and Home states
network reads in tests. Conductarr sign-in is not implemented here: real signed-out without network reads in tests. Real signed-out Home hides every rating and shows
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is "Discover needs sign-in"; signed-in Home reads the Conductarr continuation and
used. Catalogue data and ratings in debug captures are fixture-backed. ratings-only endpoint. No Seerr fallback is used.
Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`; Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`;
those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters, those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters,
@ -242,22 +359,31 @@ are recorded differences, not a claim of browser pixel equality.
## Requests under Settings ## Requests under Settings
The Requests row and Coming-in sheet share one immutable household snapshot. The Requests row and Coming-in sheet share one immutable household history snapshot.
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready, Settings counts returned requests, including Ready, rather than calling every
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows
with readiness expanded. Right expands Request/Download/Library/Playback; Left read Removed; absent holdings read Not in any library; unreleased titles read
collapses; Up/Down collapse while moving. Opening a title preserves the request Not out yet; holdings outside the recipient's libraries read In another library.
for Back, and closing the sheet restores its opener. Selecting a root or changing Waiting for an import is not evidence of an active download. Downloading and
person clears the title-return link. Shared's authenticated actor always reads Searching require explicit acquisition signals. Visibility-proof faults read
“by Shared”, never “by you”. Check needed; playback faults read File health needs attention, even when a
library holding is present. Right expands the actual stage evidence and the
relevant summary, including episode/source names when supplied by that summary.
The activity feed currently supplies only a title-level summary for Avatar.
The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and Attention-needed rows sort first, then active acquisition/checks, waiting and
its eight local posters, projected from the locked prototype fixture. Six stage settled history. The bounded window shows five rows, or four with readiness
rows feed the running debug shell; the Shared and focused-rating records feed expanded. Left collapses; Up/Down collapse while moving. Opening a title preserves
native acceptance states. The identical projection is in design-test resources. the request for Back, and closing the sheet restores its opener. Selecting a root
No “sample” or “example stages” pill is drawn. Release builds package none of or changing person clears the title-return link. Shared's authenticated actor
these assets and show Requests unavailable until Conductarr sign-in supplies always reads “by Shared”, never “by you”; absent actors have no “by” clause.
the generated-client data path. There is no Seerr fallback or handwritten HTTP.
The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
its eight local posters. Shared and focused-rating records also feed the native
acceptance states; the identical projection is in design-test resources.
No “sample” or “example stages” pill is drawn. The default provider instead maps
Conductarr household activity, authenticated actors and display artwork into
the same sheet. Release excludes all fixture assets. There is no Seerr fallback.
Foreground refreshes once; an open sheet or unsettled requests refresh every Foreground refreshes once; an open sheet or unsettled requests refresh every
30 seconds while started. The immutable debug projection is loaded once, not 30 seconds while started. The immutable debug projection is loaded once, not
decoded again on every poll. decoded again on every poll.
@ -302,12 +428,22 @@ type and TMDB identity. Back restores the exact Coming-in row and expansion;
the temporary `request-title/...` placeholder is removed. TV sign-in, release the temporary `request-title/...` placeholder is removed. TV sign-in, release
and device deployment remain separate. and device deployment remain separate.
`ActivityTruthProjectionTest` feeds recorded activity JSON through the TV adapter
for Steins;Gate, Yu Yu Hakusho, Love is Blind, Spider-Man: Homecoming, deleted
One Hour Photo, unreleased The Body, Avatar health and Partition waiting.
`ConductarrTruthCaptureTest` renders those provider snapshots and Mindel's recorded
recommendation feed on the native Compose surfaces at 1920×1080, writing
`build/wolfskin-captures/truth/`. Artwork is deliberately omitted to keep these
captures offline; missing-art rendering, tokens, focus, sheet geometry and shelf
spacing are the shipped components.
## Libraries and See all ## Libraries and See all
Movies, Shows and Anime use the shell's retained view and a continuous six-column Movies, Shows and Anime use the shell's retained view and a continuous six-column
lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves lazy grid, reduced to five columns while the letter rail is visible so scaled
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav;
exists only at 200 titles with A–Z selected. Card OK activates on key-up through Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating.
The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through
the same shell hold guard, without a long-press menu. the same shell hold guard, without a long-press menu.
Down clamps a short final title row before entering the separate Try again / Show Down clamps a short final title row before entering the separate Try again / Show
more row; an empty slot cannot activate recovery. An unavailable library hides more row; an empty slot cannot activate recovery. An unavailable library hides
@ -323,8 +459,8 @@ The debug-only `wolfskin-library.json` and `library/assets/` contain the locked
prototype's titles and local poster art. Release libraries read through prototype's titles and local poster art. Release libraries read through
`LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person `LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person
snapshots using the existing atomic disk cache. Saved data is painted before a snapshots using the existing atomic disk cache. Saved data is painted before a
quiet refresh; a failed refresh keeps it. Production ratings remain hidden until quiet refresh; a failed refresh keeps it. Production ratings remain hidden before
Conductarr sign-in exists. Conductarr sign-in and use the shared ratings-only provider afterwards.
Home/Discover shelves open a full list through Home/Discover shelves open a full list through
`WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`. `WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`.
@ -342,6 +478,65 @@ music keep their upstream destinations.
failure/retry, stale copies, and person/server isolation. Library captures are written failure/retry, stale copies, and person/server isolation. Library captures are written
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
### Roomy card spacing
The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf
heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080.
Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px,
preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px:
chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear).
Discover and season-chip rows retain their 104 px height.
Cards and cast circles draw a single 4 px accent stroke centred on the art edge,
above the content, with no background gap. Card lift parameters are unchanged;
cast portraits and episode tiles now receive that lift too. The stroke and art
scale together at 1.08.
Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
Poster progress uses the independent 18 px title-card inset, not caption spacing:
the track remains 220 px wide on a 256 px poster. Library focus-follow restarts
when the effective column count, focused row or header geometry changes, retaining
deep focus across live snapshots that add or remove the letter rail.
`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged
focused index across the 199↔200-title boundary.
`LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and
`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states
at native resolution. Their files are `library/spacing-shows.png`,
`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`.
These captures are recordings, not automated reference-pixel assertions; pair
measurements are a separate check. Platform Roboto metrics and fixture ordering
are not browser pixel parity.
### Shared focus rings
The same `focusRing` stroke now covers pill/icon buttons (hero, title, state,
recovery and sheet actions), the nav avatar, picker people and gear. It is always
accent, 4 px / 2 dp wide, centred on the surface edge with no background gap.
The former outside-gap renderer is removed. The stroke draws after opaque
content, so neither half can be covered by the background or portrait.
Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`;
avatar and picker tokens use the same geometry. Existing scales remain 1.05 for
buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card
and button lifts remain; avatars, people and gear gain no shadow. Existing
clearance accommodates the smaller outside reach without layout movement.
Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail
are unchanged. Selected-only chip/icon/radio borders and decorative borders
are not focus rings. These distinctions are explicit in the inventory and
reference states rather than silently adding rings to filled controls.
`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the
two outside and two inside pixels against opaque content for pill, circle and
card radii, including clearing the stroke on blur. The focused hero Details,
nav avatar, picker, Discover chip, Library toolbar chip and title Request
captures exercise the actual screens at 1920×1080. Browser and native glyph
metrics differ; their ring offsets are measured relative to each surface edge.
## Title pages and people ## Title pages and people
`TitleSurface` owns the film/series/episode header, season chips, episode tiles, `TitleSurface` owns the film/series/episode header, season chips, episode tiles,
@ -367,19 +562,20 @@ target: Down stays in nav, while Back can still leave the title. A missing ratin
set emits no layout node, so it cannot reserve an invisible extra facts gap. set emits no layout node, so it cannot reserve an invisible extra facts gap.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. Cached content appears person credits and playback mutations to one boundary. `ConductarrTitlePages`
before refresh. Production ratings stay hidden until Conductarr sign-in exists. adds real detail, cast, related titles, performer/filmography, readiness/receipts
Debug-only `wolfskin-titles.json` and local `wolfskin-titles/assets/` project the and source ratings. A catalogue title gains held/play controls only after an
locked household fixture, including portraits; release never packages them. exact TMDB lookup returns this Jellyfin user's actual item identity. Episode
Those synthetic IDs are not invented Jellyfin playback IDs. Real held-title votes enrich those real episode identities; no catalogue stub becomes playable.
playback uses the existing player; debug catalogue identities cannot play a file. Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit
Advanced fixture switch; release never packages or selects them.
Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an
installed YouTube handler such as SmartTube, never a browser. No handler leaves installed YouTube handler such as SmartTube, never a browser. No handler leaves
the title visible with a calm message. Titles nobody holds have no Trailer the title visible with a calm message. Titles nobody holds have no Trailer
button until Conductarr's trailer field exists. Conductarr sign-in and request button until Conductarr's trailer field exists. Title requests use the same
submission/receipt workflows belong to their separate slices; title pages do not actor-safe chooser and identity-lifetime uncertain-submission ledger as Discover.
pretend those operations succeeded or fall back to Seerr. Signed-out and offline pages cannot submit; no Seerr fallback is used.
`TitleCaptureTest` writes the 34 approved title/person/options frames plus native `TitleCaptureTest` writes the 34 approved title/person/options frames plus native
text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved
@ -440,10 +636,133 @@ real upstream playback destination with offline fixtures. New native captures an
rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score
captures configure the Android resource locale as well as the JVM locale. captures configure the Android resource locale as well as the JVM locale.
Production Conductarr TV sign-in/provider wiring remains outside these state Conductarr transport and identity live outside the state components. A revoked
components: release Discover is signed out and release Requests is unavailable. sign-in discards the current person's authenticated projections; an outage keeps
The generated client's identity-scoped disk cache is the Conductarr cache boundary; last-known-good (LKG) data and its Saved timestamp. No Seerr fallback or offline
no Seerr fallback, live-test server, handwritten HTTP or offline request queue is added. request queue is used. Title and performer pages use the same authenticated cache.
## Conductarr production wiring
The default endpoint is `BuildConfig.CONDUCTARR_BASE_URL`:
`https://wolfskin-tv.justinvisser.org`. Override **Conductarr server URL** beside
**Wolfskin screens** in Advanced settings; blank restores the build default.
HTTPS is required except for loopback fake servers. **Wolfskin preview fixtures**
is a debug-only, off-by-default switch; it never sends HTTP mutations.
`ConductarrSession` borrows the currently selected user's existing Jellyfin access
token. It sends only `Authorization: MediaBrowser Token="<token>"`, with no cookie,
API key or second credential store. Person/server/token/endpoint changes discard
the old client and in-memory providers. LKG files are isolated by person and client
identity in app-private, backup-excluded storage. A 401 clears that identity's
cache and returns to sign-in states; Try again reconstructs and verifies the client.
Home publishes Jellyfin/LKG rows independently of Conductarr, including retry
authentication, and paints new Jellyfin data before writing its disk copy.
Production calls use 3-second connect, 5-second read and 8-second total limits.
Request recipients include authorized Conductarr profiles without saved Jellyfin
logins; that does not create selectable TV logins. Late name reads preserve the
open chooser's selected recipient and footer focus.
The launch picker publishes the current Jellyfin user and working navigation before
the saved-login database read. Saved people and Conductarr household metadata fill
that same shell later; slow activity or catalogue reads cannot hold its first usable
frame. Discover details navigate to the shared title page, including cast, performer
navigation and related titles, rather than the header-only design preview. Held
items whose detail omits ProviderIds or People get an explicit Jellyfin field read
before catalogue enrichment, without changing their playback identity. Scrolled
navigation draws an opaque band; the unscrolled hero treatment is unchanged.
Cached `NavDisplay` entries read the current shell and Discover composition locals,
not the temporary launch shell captured when the entry was first created. Otherwise
the screen can retain skeleton people, disconnected tab actions and an open picker
even after the real session is ready.
Only the current navigation entry may set the shared route or enable the outer
host's Back handler; outgoing entries can remain composed during a transition.
Nested title handlers retain their existing lifecycle-based behaviour.
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
authenticated fetcher. The app's network interceptor removes Authorization from
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps
the existing mapping or Jellyfin name rule. Returning to the foreground retries
household metadata without rebuilding the selected identity.
An Anime audience grants a library grouping, not proof of owned titles. Household
refresh enables Anime only after a one-title Jellyfin read confirms visible series
in an accessible Anime library for the signed-in user. This runs after the live
picker is published; failures retain that user's last confirmed availability.
Empty Anime Home shelves remain omitted.
When availability changes, focus follows the avatar or a surviving destination;
removing Anime never leaves an out-of-range nav selection. If Anime was the root,
both the shell and navigation stack replace it with Home while retaining pushed
title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus,
and the real navigation stack with one or two pushed pages.
| Screen seam | Production supplier |
| --- | --- |
| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins |
| Home continuation | Recommendation feed, catalogue options/discovery; Coming soon stays Discover-only |
| Discover | Feed/contributions, services, New on, Trending, genres, Coming soon, parameterized service shelves, dismissal reads/writes |
| Catalogue paging | Two-shelf read-ahead, distinct title identities, per-parameter disk pages, retry/duplicate-only tails; no page counters |
| Request sheet | Capability-limited requesters, season metadata, POST receipt, receipt lookup before any resubmit; actor comes only from the token |
| Requests | Household activity, stage precedence, actor/requester evidence and display posters; foreground/30-second refresh |
| Ratings | Ratings-only read after 350 ms card dwell; immediate hero/title/Coming-in reads; per-type six-hour/300-entry cache; episode season votes |
| Titles and people | Detail/enrichment/display, cast, related, real Jellyfin season episodes plus votes, performer/filmography, title activity and uncached receipts; shared request/hide workflows |
The pinned client exposes exactly the 22 TV operations, including ratings and
display. Conditional reads send ETags and reuse the saved body on 304. The fake
server tests use representative wire JSON, never a live Conductarr or Jellyfin.
`ConductarrSessionTest`, `ConductarrProjectionTest`, `ConductarrTitlePagesTest`,
`ConductarrSurfaceTest`, `ConductarrHomeSurfaceTest` and `ConductarrTitleSurfaceTest` cover identity/cache
isolation, 401/retry, saved offline data, actor-safe requests, ratings timing,
title playback permissions/episode votes and native real-provider surfaces.
The existing fixture screen tests remain in the normal `make test` gate.
`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts
and a same-host/different-port origin with an authenticated upstream parent,
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
operator success plus failed/empty audiences, name-rule retention and foreground healing.
`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending
while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest`
covers omitted held-film provider ids with authenticated cast/related enrichment.
`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back
and writes native cast/related captures to `walk-fixes/`.
`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band.
`FirstSignInNavigationTest` starts the full app host with no existing shell and
immediate signed-in replies, then continues as Mindel and renders her six-film
Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also
checks that Continue closes the real session picker and selecting Home does not
reopen it.
`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared
and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or
Home shelf, while a real visible series enables both.
Not done here: deploying the dedicated hostname or widening server authority;
hardware installation/playback verification; releases, signing or version changes.
Unheld trailers remain absent because the server contract has no trailer field.
Saved Jellyfin logins are not fabricated from Conductarr profiles: a person
without an upstream token remains signed out. Fixtures require explicit debug opt-in.
### Manual smoke after endpoint deployment (no build command)
Use an already authorized test build and a sandbox that accepts its selected
Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin.
1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared,
then another saved person; verify their operator/requester and independent views.
Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or
IP addresses are refused even with the correct token.
2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast
member and filmography credit; check real facts, related titles, episode votes,
stage/actor labels, card dwell ratings and continuous See all loading.
3. Make the sandbox return 503 after a successful read. Reopen: saved content and
Saved time remain, requests cannot submit, and Try again performs another read.
4. Return 401, then restore authentication. Verify ratings/request actions disappear
and this person's saved Conductarr data is cleared. Try again restores data
without changing Jellyfin login or copying a token.
5. Have the sandbox accept a Shared-on-behalf request but lose its POST response.
Use Check again: the receipt says Shared, and the sandbox records one POST.
6. Restore the blank/default URL when the dedicated TV hostname and server's
22-operation authority are deployed. This checklist is manual, not a live gate.
Title and person surfaces publish saved timestamps through the same per-person Title and person surfaces publish saved timestamps through the same per-person
`RememberedView` partition and nav Saved indicator. Their timed notices render `RememberedView` partition and nav Saved indicator. Their timed notices render
the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage` the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage`
@ -452,6 +771,48 @@ person's title/person projection. Title and performer disk copies remain isolate
by both server and authenticated user; native retry and cache-isolation regressions by both server and authenticated user; native retry and cache-isolation regressions
exercise these boundaries without a live service. exercise these boundaries without a live service.
## TV fidelity and source gates
Library/Discover page headings use the existing exact CSS line-box layout.
Request sheets reserve the inherited inline text descent around their ratings;
person rows and receipt panels therefore share the locked frame's vertical
coordinates. Settings row title/facts also retain their CSS line boxes. These
are local component fixes: shared typography tokens, Home and caption styling
do not shift.
Home ratings observations use independent six-hour, 300-entry title and
series/season caches. A season retains each observed episode's own score and
timestamp; changing a Jellyfin episode UUID does not lose the observation, and
another episode or season never borrows that score. Episodes without a complete
catalogue identity remain isolated by their Jellyfin identity.
Cold library grids publish each cumulative 200-item SDK chunk before the next
read completes. An existing complete saved copy stays visible throughout a
refresh, and only a completed read replaces it on disk. A cold later-chunk
failure keeps the already-readable titles with a retry tail, without saving an
incomplete copy. Appended titles preserve the focused title even when the
destination's sort inserts them before it. Catalogue row keys use the same
media-type/TMDB identity as their cards.
Undo restores a dismissed pick silently; Show again announces “Back in your
picks.” Title pages own their own toast so the request adapter never duplicates
it. The saved Discover retry tail is labelled More picks, not Try again twice.
People retry retains an already signed-in Conductarr client and its uncertain
submission ledger; only a signed-out identity is rebuilt. Home authorizes its
Jellyfin read by the selected person's saved Jellyfin user ID, so a lost
Conductarr operator or a renamed household profile does not hide that user's
own library. A different saved user ID still fails closed.
`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite.
It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and
`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color
constructors outside generated tokens, token font sizes below 12 sp, raw
household library labels, and page-counter/navigation wording. Its negative
cases plant each violation independently. Existing native capture checks also
verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures
the usable first chunk while the final read is still suspended.
## Toolchain and disk use ## Toolchain and disk use
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
@ -481,6 +842,41 @@ whose parent directory already exists. All callers sharing a volume must use
the same lock path on the Docker host. Do not delete the file while builds are the same lock path on the Docker host. Do not delete the file while builds are
running or waiting. The lock releases automatically when the invocation exits. running or waiting. The lock releases automatically when the invocation exits.
## Persistent debug signing
`make apk-debug` and non-release `docker/gradle.sh` invocations (including
`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout:
```text
/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore
```
Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside
the checkout when building on another host, for example:
```sh
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
```
The wrapper creates a missing key once using the build image's `keytool`: RSA
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
file mode `0600`. Existing keys are reused, never regenerated; generation errors
abort the build rather than falling back to an ephemeral container key.
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
Keep and back up this file across clean builds, disposable worktrees, container
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
are public; this key is for local debug/optimized builds, not release signing.
**One-time reinstall:** the first build with the stable key still needs one
uninstall+reinstall on the box because the signer changes once; later ones
install in place with `adb install -r`, retaining sign-ins and settings.
The initial uninstall removes app data, so sign in again once after that switch.
Deleting/replacing the persistent key or choosing a different override changes
the signer again and requires another reinstall.
## Release credentials ## Release credentials
The default escrow is The default escrow is

View file

@ -35,5 +35,36 @@ if [[ $mode == release ]]; then
exec ./gradlew "$@" exec ./gradlew "$@"
' bash "${gradle_arguments[@]}" "$@" ' bash "${gradle_arguments[@]}" "$@"
else else
debug_keystore=$(realpath -m "${WOLFSKIN_DEBUG_KEYSTORE:-/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore}")
case "$debug_keystore" in
"$root"|"$root"/*)
printf 'Debug keystore must be outside the checkout: %s\n' "$debug_keystore" >&2
exit 1
;;
esac
signing_directory=$(dirname "$debug_keystore")
mkdir -p "$signing_directory"
chmod 700 "$signing_directory"
if [[ ! -e $debug_keystore ]]; then
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
trap 'rm -rf -- "$temporary_directory"' EXIT
if ! docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \
--mount "type=bind,src=$temporary_directory,dst=/run/signing" \
"$image" keytool -genkeypair -noprompt -keystore /run/signing/debug.keystore \
-storetype JKS -storepass android -keypass android -alias androiddebugkey \
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug"; then
printf 'Failed to generate persistent debug keystore: %s\n' "$debug_keystore" >&2
exit 1
fi
chmod 600 "$temporary_directory/debug.keystore"
mv "$temporary_directory/debug.keystore" "$debug_keystore"
fi
if [[ ! -s $debug_keystore ]]; then
printf 'Missing or empty persistent debug keystore: %s\n' "$debug_keystore" >&2
exit 1
fi
chmod 600 "$debug_keystore"
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
fi fi

View file

@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
val shape = RoundedCornerShape(WolfskinTokens.Radius.card) val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Text import androidx.tv.material3.Text
import java.util.Locale import java.util.Locale
@ -31,6 +32,8 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
val mark = when (rating.mark) { val mark = when (rating.mark) {
"fresh" -> R.drawable.coming_in_rating_fresh "fresh" -> R.drawable.coming_in_rating_fresh
"tomato" -> R.drawable.coming_in_rating_fresh
"popcorn" -> R.drawable.coming_in_rating_upright
"rotten" -> R.drawable.coming_in_rating_rotten "rotten" -> R.drawable.coming_in_rating_rotten
"upright" -> R.drawable.coming_in_rating_upright "upright" -> R.drawable.coming_in_rating_upright
"spilled" -> R.drawable.coming_in_rating_spilled "spilled" -> R.drawable.coming_in_rating_spilled
@ -40,7 +43,8 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
val wide = rating.mark == "imdb" || rating.mark == "tmdb" val wide = rating.mark == "imdb" || rating.mark == "tmdb"
Image(painterResource(mark), contentDescription = rating.mark, Image(painterResource(mark), contentDescription = rating.mark,
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f) modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3), .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3)
.graphicsLayer { alpha = if (rating.mark == "tomato" || rating.mark == "popcorn") WolfskinTokens.Library.unknownCriticOpacity else 1f },
contentScale = ContentScale.Fit) contentScale = ContentScale.Fit)
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
} }

View file

@ -38,8 +38,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val state = shell.requests val state = shell.requests
val data = state.snapshot val data = state.snapshot
LaunchedEffect(state.focusedId, data.signedIn, data.status) { val ratingsReader = LocalScreenRatings.current
state.awaitFocusedRatings() LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
state.updateRatings(item.id, values.map { rating ->
val mapped = rating.homeRating()
ComingInRating(mapped.mark, mapped.score, mapped.percentage)
})
}
}
} }
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth) Column(modifier.fillMaxHeight().width(L.sheetWidth)
@ -148,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), val actor = item.actor(viewer)
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
style = ComingInTypography.caption, color = secondary) style = ComingInTypography.caption, color = secondary)
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) { FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
@ -159,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
} }
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin), if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) { horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
item.stage.readiness.forEachIndexed { index, word -> item.readiness.forEachIndexed { index, word ->
Row { Row {
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" } val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" }
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight }) if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary) Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
} }
} }
} }
if (expanded) item.summary?.let { summary ->
Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary)
}
} }
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron)) if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
} }

View file

@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/** Household acquisition stages, ordered independently from their display wording. */ /** History states distinguish acquisition work from library evidence and playback health. */
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) { enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")), Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")),
CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")),
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")), Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")), AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")), Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")), Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")), Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")),
NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")),
HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")),
Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")),
NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")),
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")), Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
} }
@ -33,13 +40,15 @@ data class ComingInRequest(
val stage: ComingInStage, val stage: ComingInStage,
val poster: Any? = null, val poster: Any? = null,
val ratings: List<ComingInRating> = emptyList(), val ratings: List<ComingInRating> = emptyList(),
val summary: String? = null,
val readiness: List<String> = stage.readiness,
) { ) {
val ratingKey = "$mediaType:$tmdbId" val ratingKey = "$mediaType:$tmdbId"
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
fun actor(viewer: ShellPerson): String = when { fun actor(viewer: ShellPerson): String? = when {
actorKey == "shared" -> "Shared" actorKey == "shared" -> "Shared"
actorKey != null && actorKey == viewer.key -> "you" actorKey != null && actorKey == viewer.key -> "you"
else -> actorName ?: "Unknown" else -> actorName?.takeIf { it.isNotBlank() }
} }
} }
@ -54,7 +63,13 @@ data class ComingInSnapshot(
val savedAt: String? = null, val savedAt: String? = null,
) { ) {
val ordered = items.sortedBy { it.stage.rank } val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
val hasUnsettledRequests: Boolean get() = items.any {
when (it.stage) {
ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false
else -> true
}
}
} }
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */ /** A bounded window and stable request id preserve focus across refreshes and title round trips. */
@ -92,6 +107,12 @@ class ComingInState {
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
} }
fun updateRatings(id: String, values: List<ComingInRating>) {
snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it })
snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) }
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
}
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */ /** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
suspend fun awaitFocusedRatings() { suspend fun awaitFocusedRatings() {
val item = focused ?: return val item = focused ?: return

View file

@ -19,7 +19,7 @@ internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle {
} }
return LibraryTitle(id = key, title = title, type = type, year = year, return LibraryTitle(id = key, title = title, type = type, year = year,
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability, ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
badge = when { badge = when {
held -> "held" held -> "held"
receipts.isEmpty() -> null receipts.isEmpty() -> null

View file

@ -13,7 +13,7 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
} }
private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title) private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title)
val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll") val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll")
.mapIndexed { index, name -> DiscoverService(index.toLong(), name, listOf(355f, 200f, 230f, 265f, 0f, 20f, 140f, 25f, 30f)[index]) } .mapIndexed { index, name -> DiscoverService(index.toLong(), name, discoverServiceHue(name)) }
fun snapshot(person: String): DiscoverSnapshot { fun snapshot(person: String): DiscoverSnapshot {
val profile = profiles.first { it.getString("key") == person } val profile = profiles.first { it.getString("key") == person }
@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
} }
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
} }
fun service(service: DiscoverService, type: String): List<DiscoverLane> { fun service(service: DiscoverService, type: String): List<DiscoverLane> {

View file

@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
val result = mutableListOf<DiscoverLane>() val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) } fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) { if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3) val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }
.filter { it.items.isNotEmpty() }.take(3).toList()
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) { for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden } val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen } val count = all.count { it.key !in seen }
@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all) if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
} }
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt()) val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed -> groups.forEach { seed ->
val items = take(seed.items, size) val items = take(seed.items, size)
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
} }
} }
val displayed = heroes.mapTo(mutableSetOf()) { it.key } val displayed = heroes.mapTo(mutableSetOf()) { it.key }
@ -29,9 +31,15 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
snapshot.lanes.forEach { lane -> snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden } val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt()) val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { val paged = lane.hasMore
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size, val tail = when {
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None) lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore -> lane.tail
paged && items.isEmpty() -> DiscoverTail.ShowMore
paged || (!lane.id.startsWith("new-") && source.size > items.size) -> DiscoverTail.SeeAll
else -> DiscoverTail.None
}
if (items.isNotEmpty() || lane.pending || tail == DiscoverTail.Retry || tail == DiscoverTail.ShowMore) {
result += lane.copy(titles = items, fullTitles = source, total = if (paged) lane.total else if (lane.id.startsWith("new-")) items.size else source.size, tail = tail)
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) } if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
} }
} }

View file

@ -26,12 +26,25 @@ data class DiscoverTitle(
/** Observed source marks keep labels separate from score values. */ /** Observed source marks keep labels separate from score values. */
data class DiscoverRating(val source: String, val score: Double, val label: String? = null) data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
/** A contribution group preserves feed order rather than assigning a visible rank. */ /** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>) data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
/** Services carry their catalogue id and a text wordmark, never third-party logos. */ /** Services carry their catalogue id and a text wordmark, never third-party logos. */
data class DiscoverService(val id: Long, val name: String, val hue: Float) data class DiscoverService(val id: Long, val name: String, val hue: Float)
/** Locked service accents are shared by fixture and live catalogue providers. */
fun discoverServiceHue(name: String): Float = when (name) {
"Netflix" -> 355f
"Prime Video", "Amazon Prime Video" -> 200f
"Disney+", "Disney Plus" -> 230f
"HBO Max", "Max" -> 265f
"Videoland" -> 20f
"Hulu" -> 140f
"NPO Start" -> 25f
"Crunchyroll" -> 30f
else -> 0f
}
enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable } enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable }
enum class DiscoverTail { None, SeeAll, Retry, ShowMore } enum class DiscoverTail { None, SeeAll, Retry, ShowMore }
@ -45,6 +58,8 @@ data class DiscoverSnapshot(
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
val savedAt: String? = null, val savedAt: String? = null,
val reusesFixtureLanes: Boolean = false, val reusesFixtureLanes: Boolean = false,
val reachable: Boolean = true,
val requestsAllowed: Boolean = true,
) )
/** Lane identity survives lazy recycling and provider refreshes. */ /** Lane identity survives lazy recycling and provider refreshes. */
@ -57,6 +72,8 @@ data class DiscoverLane(
val pick: Boolean = false, val pick: Boolean = false,
val hidden: Boolean = false, val hidden: Boolean = false,
val fullTitles: List<DiscoverTitle> = titles, val fullTitles: List<DiscoverTitle> = titles,
val pending: Boolean = false,
val hasMore: Boolean = false,
) )
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */ /** Caller identity is deliberately absent: the authenticated client supplies the actor. */

View file

@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.Layout
import androidx.tv.material3.Text import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -49,7 +50,7 @@ fun DiscoverOverlay(state: DiscoverState) {
is DiscoverOverlayState.Hidden -> { is DiscoverOverlayState.Hidden -> {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) } DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
} }
} }
@ -59,7 +60,7 @@ fun DiscoverOverlay(state: DiscoverState) {
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body),
color = C.muted, style = DiscoverType.body) color = C.muted, style = DiscoverType.body)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) } DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
} }
} }
@ -72,15 +73,10 @@ fun DiscoverOverlay(state: DiscoverState) {
@Composable @Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle) Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body) Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints -> if (state.isSignedIn) SheetRatings(sheet.title.ratings)
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
placeable.placeRelative(0, D.sheetRatingTop.roundToPx())
}
})
when (sheet.phase) { when (sheet.phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> { RequestPhase.LoadingRecipients -> {
@ -100,6 +96,28 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
} }
} }
/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */
@Composable
private fun SheetRatings(ratings: List<DiscoverRating>) {
if (ratings.isEmpty()) return
Layout(content = {
DiscoverRatings(ratings)
Text(" ", color = C.muted, style = DiscoverType.body)
}) { children, constraints ->
val row = children[0].measure(constraints.copy(minHeight = 0))
val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0))
val mark = DiscoverType.ratings.fontSize.toPx()
val baseline = (row.height + mark) / 2f
val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat())
val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent
val top = D.sheetRatingTop.roundToPx()
val bottom = D.sheetRatingBottom.roundToPx()
layout(row.width, kotlin.math.round(height).toInt() + top + bottom) {
row.placeRelative(0, top)
}
}
}
@Composable @Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
@ -131,9 +149,9 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
val phase = sheet.phase val phase = sheet.phase
val name = sheet.selectedPerson.name val name = sheet.selectedPerson.name
val actor = sheet.result?.actor ?: state.shell.person.name val actor = sheet.result?.actor
val text = when (phase) { val text = when (phase) {
RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name} · by $actor" RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name}" + actor?.let { " · by $it" }.orEmpty()
RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed." RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed."
RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice." RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice."
RequestPhase.Refused -> "$name can’t receive requests from this TV." RequestPhase.Refused -> "$name can’t receive requests from this TV."

View file

@ -1,6 +1,9 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -49,11 +52,19 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt) { val ratingsReader = LocalScreenRatings.current
LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt, state.hero?.key, state.title?.key, state.isSignedIn) {
state.plateKey = null state.plateKey = null
if (state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings)) { coroutineScope {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) if (state.isSignedIn && ratingsReader != null) launch {
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key val prominent = state.title ?: state.hero
if (prominent != null) ratingsReader.read(prominent.tmdb, prominent.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(prominent, it) }
}
if (state.title == null && state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings || ratingsReader != null)) launch {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(focusedTitle, it) }
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
}
} }
} }
if (state.title != null) { DiscoverTitleDestination(state, modifier); return } if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
@ -186,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
@Composable @Composable
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))

View file

@ -70,12 +70,16 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg) Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
} }
} else if (lane.pending) {
items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else { } else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
DiscoverPoster(state, item, active && index == column, Modifier.clickable { DiscoverPoster(state, item, active && index == column, Modifier.clickable {
@ -88,7 +92,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
} }
} }
} }
if (!services && active) { if (!services && active && !lane.pending) {
val item = lane.titles.getOrNull(column) val item = lane.titles.getOrNull(column)
Caption( Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
@ -105,7 +109,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
@Composable @Composable
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) { fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
val receipts = state.requests[title.key] val receipts = state.requests[title.key]
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) } val card = remember(title, receipts, state.ratingsFor(title), state.shell.person.key) { title.libraryCard(state) }
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true, LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current) modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
} }

View file

@ -19,10 +19,22 @@ class DiscoverState(
private val onPush: (String) -> Unit = {}, private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {}, private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() }, private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
requesters: List<ShellPerson> = shell.people,
private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null,
private val nowMillis: () -> Long = System::currentTimeMillis, private val nowMillis: () -> Long = System::currentTimeMillis,
) : ShellContentFocus { ) : ShellContentFocus {
var snapshot by mutableStateOf(initial) var snapshot by mutableStateOf(initial)
private set private set
private var requesters = requesters
fun updateRequesters(people: List<ShellPerson>) {
if (requesters == people) return
requesters = people
requestSheets.values.forEach { it.updatePeople(people) }
(overlay as? DiscoverOverlayState.Request)?.state?.let { current ->
if (requestSheets.values.none { it === current }) current.updatePeople(people)
}
}
private var savedTime = initial.savedAt ?: formatSavedTime() private var savedTime = initial.savedAt ?: formatSavedTime()
private var serviceFilter by mutableStateOf("movie") private var serviceFilter by mutableStateOf("movie")
var filter: String var filter: String
@ -48,6 +60,7 @@ class DiscoverState(
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>() val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>() private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } } private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private val ratingValues = mutableStateMapOf<String, List<DiscoverRating>>()
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle> private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
private var revision by mutableStateOf(0) private var revision by mutableStateOf(0)
private var cachedRevision = -1 private var cachedRevision = -1
@ -59,15 +72,51 @@ class DiscoverState(
private var cachedHeroes = emptyList<DiscoverTitle>() private var cachedHeroes = emptyList<DiscoverTitle>()
private val dismissalMutex = Mutex() private val dismissalMutex = Mutex()
private val dismissalVersions = mutableMapOf<String, Int>() private val dismissalVersions = mutableMapOf<String, Int>()
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
private var rootFilter = "all" private var rootFilter = "all"
fun updateSnapshot(value: DiscoverSnapshot) {
snapshot = value
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
hidden.clear()
value.hidden.forEach { hidden[it.key] = it }
pendingDismissals.forEach { (key, item) -> if (item == null) hidden.remove(key) else hidden[key] = item }
revision++
}
fun invalidateLanes() { revision++ }
fun updateLanes(value: List<DiscoverLane>) { snapshot = snapshot.copy(lanes = value); revision++ }
private suspend fun loadLane(id: String) {
if (id == "offline-recovery") { reload(); return }
val loaded = onMore?.invoke(id)
if (loaded != null) updateLanes(loaded) else reload()
}
fun updateTitle(value: DiscoverTitle) {
if (title?.key == value.key) title = value
}
fun ratingsFor(item: DiscoverTitle): List<DiscoverRating> = ratingValues[item.key] ?: item.ratings
fun updateRatings(item: DiscoverTitle, values: List<LibraryRating>) {
val ratings = values.map { DiscoverRating(it.source, it.score, it.label) }
ratingValues[item.key] = ratings
fun replace(source: List<DiscoverTitle>) = source.map { if (it.key == item.key) it.copy(ratings = ratings) else it }
snapshot = snapshot.copy(picks = replace(snapshot.picks),
lanes = snapshot.lanes.map { it.copy(titles = replace(it.titles), fullTitles = replace(it.fullTitles)) },
seeds = snapshot.seeds.map { it.copy(items = replace(it.items)) })
title?.takeIf { it.key == item.key }?.let { title = it.copy(ratings = ratings) }
revision++
}
override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val hasCatalogue: Boolean get() = when (snapshot.status) { val hasCatalogue: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false else -> false
} }
val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null && snapshot.reachable && snapshot.requestsAllowed
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue
val heroes: List<DiscoverTitle> get() { val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
@ -92,7 +141,7 @@ class DiscoverState(
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
override val cardKey: String? get() = when { override val cardKey: String? get() = when {
overlay != null || title != null -> null overlay != null || title != null -> null
@ -120,7 +169,7 @@ class DiscoverState(
fun openTitle(item: DiscoverTitle) { fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0 title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key) if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}") onPush("title/${android.net.Uri.encode(item.key)}")
} }
fun openCollection(lane: DiscoverLane) { fun openCollection(lane: DiscoverLane) {
var titles = lane.fullTitles.associateBy { it.key } var titles = lane.fullTitles.associateBy { it.key }
@ -131,7 +180,7 @@ class DiscoverState(
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings, grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = { onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
scope.launch { scope.launch {
reload() loadLane(lane.id)
if (collection !== grid) return@launch if (collection !== grid) return@launch
grid.savedAt = snapshot.savedAt grid.savedAt = snapshot.savedAt
val fresh = lanes.firstOrNull { it.id == lane.id } val fresh = lanes.firstOrNull { it.id == lane.id }
@ -148,6 +197,16 @@ class DiscoverState(
savedAt = snapshot.savedAt savedAt = snapshot.savedAt
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
} }
if (onMore != null && lane.hasMore) grid.pagination = LibraryPagination(grid) {
loadLane(lane.id)
val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable")
if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable")
titles = fresh.fullTitles.associateBy { it.key }
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
}
LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.hasMore)
}
collection = grid collection = grid
onPush("discover-see-all/${lane.id}") onPush("discover-see-all/${lane.id}")
} }
@ -178,7 +237,7 @@ class DiscoverState(
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */ /** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
override fun key(key: ShellKey): Boolean { override fun key(key: ShellKey): Boolean {
if (overlay != null) { overlayKey(key); return true } if (overlay != null) { overlayKey(key); return true }
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true } if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let { if (title == null) collection?.let {
if (key == ShellKey.Back) { collection = null; onPop(); return true } if (key == ShellKey.Back) { collection = null; onPop(); return true }
@ -223,7 +282,7 @@ class DiscoverState(
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return } if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
val item = lane.titles.getOrNull(column) val item = lane.titles.getOrNull(column)
if (item == null) { if (item == null) {
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh() if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) scope.launch { loadLane(lane.id) }
else openCollection(lane) else openCollection(lane)
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item) } else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
else openTitle(item) else openTitle(item)
@ -241,7 +300,7 @@ class DiscoverState(
ShellKey.Ok -> when (titleButton) { ShellKey.Ok -> when (titleButton) {
0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false) 0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false)
1 -> openRequest(true) 1 -> openRequest(true)
2 -> title?.let { if (it.key in hidden) restore(it) else hide(it) } 2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) }
} }
else -> Unit else -> Unit
} }
@ -249,7 +308,7 @@ class DiscoverState(
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
if (!canRequest) { refresh(); return } if (!canRequest) { refresh(); return }
val choice = RequestSheetState(item, shell.person, shell.people, other, phase) val choice = RequestSheetState(item, shell.person, requesters, other, phase)
val previous = requestSheets[item.key to choice.selectedPerson.key] val previous = requestSheets[item.key to choice.selectedPerson.key]
overlay = DiscoverOverlayState.Request( overlay = DiscoverOverlayState.Request(
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice, if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
@ -269,13 +328,13 @@ class DiscoverState(
ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1 ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else { overlay = null; title = null; onPop() } ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() }
} }
is DiscoverOverlayState.Restore -> when (key) { is DiscoverOverlayState.Restore -> when (key) {
ShellKey.Left, ShellKey.Up -> sheet.focus = 0 ShellKey.Left, ShellKey.Up -> sheet.focus = 0
ShellKey.Right, ShellKey.Down -> sheet.focus = 1 ShellKey.Right, ShellKey.Down -> sheet.focus = 1
ShellKey.Back -> overlay = null ShellKey.Back -> overlay = null
ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title) else overlay = null ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null
} }
null -> Unit null -> Unit
} }
@ -313,21 +372,25 @@ class DiscoverState(
val profile = shell.person.key val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1 val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version dismissalVersions[item.key] = version
pendingDismissals[item.key] = item
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.hide(profile, item) } val success = dismissalMutex.withLock { operations.hide(profile, item) }
if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key)
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." } if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." }
} }
} }
fun restore(item: DiscoverTitle) { fun restore(item: DiscoverTitle, showToast: Boolean) {
if (!canRequest) { refresh(); return } if (!canRequest) { refresh(); return }
hidden.remove(item.key); revision++; overlay = null; toast = "Back in your picks." hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null
updateCollectionCards() updateCollectionCards()
val profile = shell.person.key val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1 val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version dismissalVersions[item.key] = version
pendingDismissals[item.key] = null
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.restore(profile, item) } val success = dismissalMutex.withLock { operations.restore(profile, item) }
if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key)
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." } if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." }
} }
} }
@ -349,7 +412,7 @@ class DiscoverState(
val lanes = service?.let { val lanes = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes
} }
} }

View file

@ -15,12 +15,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */ /** Only transforms animate: focus never changes measured geometry. */
@ -34,12 +34,12 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
return graphicsLayer { scaleX = animated; scaleY = animated } return graphicsLayer { scaleX = animated; scaleY = animated }
} }
/** Content lifts are distinct from the white person/gear rings. */ /** Lift shadows remain specific to cards and buttons. */
enum class FocusLift { None, Card, Button } enum class FocusLift { None, Card, Button }
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */ /** Every focus ring straddles the surface edge above content, without a background gap. */
@Composable @Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier { fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
val shadow = if (lift == FocusLift.None) this else { val shadow = if (lift == FocusLift.None) this else {
@ -54,12 +54,15 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
} }
} }
return shadow.drawBehind { return shadow.drawWithCache {
if (ringAlpha > 0f) { val stroke = Stroke(width.toPx())
val outer = (gap + width).toPx() val corner = CornerRadius(radius.toPx())
val inner = gap.toPx() onDrawWithContent {
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer)) drawContent()
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner)) if (ringAlpha > 0f) {
drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha),
cornerRadius = corner, style = stroke)
}
} }
} }
} }

View file

@ -4,23 +4,45 @@ package org.justinvisser.wolfskin.design
class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) { class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) {
private data class Entry(val ratings: List<HomeRating>, val readAt: Long) private data class Entry(val ratings: List<HomeRating>, val readAt: Long)
private val entries = linkedMapOf<String, Entry>() private val entries = linkedMapOf<String, Entry>()
private val seasons = linkedMapOf<String, MutableMap<Int, Entry>>()
fun read(title: HomeTitle): List<HomeRating>? { fun read(title: HomeTitle): List<HomeRating>? {
val key = key(title) val season = seasonKey(title)
val entry = entries[key] ?: return null val bucket = season?.let { seasons[it] }
if (clock() - entry.readAt >= lifetimeMillis) { entries.remove(key); return null } val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)]
if (entry == null) return null
if (clock() - entry.readAt >= lifetimeMillis) {
if (season == null) entries.remove(key(title)) else {
bucket?.remove(title.episode)
if (bucket.isNullOrEmpty()) seasons.remove(season)
}
return null
}
return entry.ratings return entry.ratings
} }
fun save(title: HomeTitle, ratings: List<HomeRating>) { fun save(title: HomeTitle, ratings: List<HomeRating>) {
val key = key(title) val entry = Entry(ratings, clock())
entries.remove(key) val season = seasonKey(title)
entries[key] = Entry(ratings, clock()) if (season != null) {
while (entries.size > capacity) entries.remove(entries.keys.first()) val bucket = seasons.remove(season) ?: mutableMapOf()
bucket[requireNotNull(title.episode)] = entry
seasons[season] = bucket
while (seasons.size > capacity) seasons.remove(seasons.keys.first())
} else {
val key = key(title)
entries.remove(key)
entries[key] = entry
while (entries.size > capacity) entries.remove(entries.keys.first())
}
} }
private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}" private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}"
private fun seasonKey(title: HomeTitle): String? =
if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null)
"${title.tmdbId}:${title.season}" else null
companion object { companion object {
private const val capacity = 300 private const val capacity = 300
private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong() private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong()

View file

@ -41,12 +41,13 @@ fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fon
val resource = when (rating.mark) { val resource = when (rating.mark) {
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten "rotten" -> R.drawable.ic_rotten_tomatoes_rotten
"upright" -> R.drawable.ic_rt_upright "upright" -> R.drawable.ic_rt_upright
"popcorn" -> R.drawable.ic_rt_upright
"spilled" -> R.drawable.ic_rt_spilled "spilled" -> R.drawable.ic_rt_spilled
"imdb" -> R.drawable.ic_rating_imdb "imdb" -> R.drawable.ic_rating_imdb
"tmdb" -> R.drawable.ic_rating_tmdb "tmdb" -> R.drawable.ic_rating_tmdb
else -> R.drawable.ic_rotten_tomatoes_fresh else -> R.drawable.ic_rotten_tomatoes_fresh
} }
val muted = rating.mark == "tomato" val muted = rating.mark == "tomato" || rating.mark == "popcorn"
Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null) Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1) if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1)
Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false) Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false)

View file

@ -15,6 +15,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -80,20 +82,25 @@ fun HomeScreen(
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
} }
val cache = view.home.ratings val cache = view.home.ratings
val ratingsReader = LocalScreenRatings.current
val row = snapshot.rows.getOrNull(view.row) val row = snapshot.rows.getOrNull(view.row)
val title = row?.cards?.getOrNull(focus.column) val title = row?.cards?.getOrNull(focus.column)
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull() val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) } var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) }
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) } var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) { LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) { val available = snapshot.conductarr == HomeAvailability.Ready || snapshot.conductarr == HomeAvailability.Saved
heroRatings = hero.ratings coroutineScope {
cache.save(hero, heroRatings) if (hero != null && available) launch {
} val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) }
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) { if (values != null) { heroRatings = values; cache.save(hero, values) }
val saved = cache.read(title) }
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong()) if (title != null && row?.kind != HomeRowKind.Hero && !shell.navFocused && !shell.pickerVisible && available) launch {
focusedRatings = saved ?: title.ratings.also { cache.save(title, it) } val saved = cache.read(title)
if (saved == null) delay(WolfskinTokens.Motion.ratingDwell.toLong())
val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) }
if (values != null) { focusedRatings = values; cache.save(title, values) }
}
} }
} }
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut val showRatings = snapshot.conductarr != HomeAvailability.SignedOut

View file

@ -91,7 +91,7 @@ class LibraryGridState(
var inRail by mutableStateOf(false) var inRail by mutableStateOf(false)
var railLetter by mutableStateOf('#') var railLetter by mutableStateOf('#')
private var railReturn = 0 private var railReturn = 0
val columns = WolfskinTokens.Behaviour.gridColumns.toInt() val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" } val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
val hasToolbar: Boolean get() = !catalogue || mixed val hasToolbar: Boolean get() = !catalogue || mixed
val items: List<LibraryTitle> by derivedStateOf { val items: List<LibraryTitle> by derivedStateOf {

View file

@ -41,8 +41,8 @@ fun LibraryPoster(
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress val progress = title.progress
if (progress > 0f && progress < 1f) { if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) { .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
} }
} }

View file

@ -49,6 +49,7 @@ fun LibraryScreen(
val focused = grid.focusedTitle val focused = grid.focusedTitle
val count = if (grid.catalogue) items.size else grid.source.size val count = if (grid.catalogue) items.size else grid.source.size
var plateKey by remember { mutableStateOf<String?>(null) } var plateKey by remember { mutableStateOf<String?>(null) }
val ratingsReader = LocalScreenRatings.current
DisposableEffect(shell, grid, registerFocus) { DisposableEffect(shell, grid, registerFocus) {
if (registerFocus) shell.contentFocus = grid if (registerFocus) shell.contentFocus = grid
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
@ -72,23 +73,28 @@ fun LibraryScreen(
plateKey = null plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) { if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong()) if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (ratingsReader != null) {
val tmdb = focused.tmdbId?.toLongOrNull() ?: focused.id.substringAfter(':', "").toLongOrNull()
val ratings = tmdb?.let { ratingsReader.read(it, focused.type, null, null) }
if (ratings != null) grid.source = grid.source.map { if (it.id == focused.id) it.copy(ratings = ratings) else it }
}
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id) if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
plateKey = focused.id plateKey = focused.id
} }
} }
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt } LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
LaunchedEffect(grid.status) { LaunchedEffect(grid.status) {
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab) if (grid.status == LibraryStatus.Loading && !shell.navFocused) shell.focusNav(shell.currentTab)
} }
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density,
val row = index.coerceAtLeast(0) / grid.columns grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
if (grid.toolbar || row == 0 && active) { if (grid.toolbar || focusedRow == 0 && active) {
animateLibraryScroll(view, headerCount, 0f, density) animateLibraryScroll(view, headerCount, 0f, density)
} else if (row > 0) { } else if (focusedRow > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() }
animateLibraryScroll(view, headerCount, target, density) animateLibraryScroll(view, headerCount, target, density)
} }
} }
@ -122,7 +128,10 @@ fun LibraryScreen(
LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) }
LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) }
else { else {
items((items.size + grid.columns - 1) / grid.columns, key = { row -> "grid-${items[row * grid.columns].id}" }, contentType = { "grid" }) { row -> items((items.size + grid.columns - 1) / grid.columns, key = { row ->
val title = items[row * grid.columns]
"grid-${if (grid.catalogue) title.catalogueKey else title.id}"
}, contentType = { "grid" }) { row ->
Box(Modifier.fillMaxWidth().height(rowHeight)) { Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
for (column in 0 until grid.columns) { for (column in 0 until grid.columns) {
@ -168,7 +177,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
Box(Modifier.fillMaxWidth().height(L.row_lhead)) { Box(Modifier.fillMaxWidth().height(L.row_lhead)) {
Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle) Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle) if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
savedAt?.let { SavedPill(it) } savedAt?.let { SavedPill(it) }
} }
@ -177,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
@Composable @Composable
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All") val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
labels.forEachIndexed { index, label -> labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index val focused = active && grid.toolbar && grid.tool == index
@ -257,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
val current = with(density) { val current = with(density) {
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead + (if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
if (first < headerCount) WolfskinTokens.Shell.zero else if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset } + view.listState.firstVisibleItemScrollOffset
animateFrameScroll(view.listState, target - current) animateFrameScroll(view.listState, target - current)
} }

View file

@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
val density = LocalDensity.current.density val density = LocalDensity.current.density
val band = remember(scrolled) { val band = remember {
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f)) Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
} }
Box(modifier.fillMaxWidth().height(L.navBand)) { Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) { Canvas(Modifier.matchParentSize()) {
drawRect(band) if (scrolled) drawRect(C.bg) else drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
} }
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
@ -112,7 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
val avatar = boxes.last() val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top) Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
.focusProperties { canFocus = false } .focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
} }

Some files were not shown because too many files have changed in this diff Show more