From 59c2728d9c45ee912fb18fcf055490bb6d283b32 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 22:55:54 +0100 Subject: [PATCH] Wire selected Jellyfin users to Conductarr TV data --- app/build.gradle.kts | 2 + .../org/justinvisser/wolfskin/tv/DebugHome.kt | 3 +- app/src/default/res/values/strings.xml | 3 + .../wholphin/preferences/AppPreference.kt | 27 + .../wolfskin/tv/ConductarrProjectionTest.kt | 108 +++ .../wolfskin/tv/ConductarrSessionTest.kt | 85 +++ .../wolfskin/tv/ConductarrSurfaceTest.kt | 82 +++ .../wolfskin/tv/FakeConductarr.kt | 78 +++ .../wolfskin/tv/HomeSeeAllNavigationTest.kt | 2 +- .../wolfskin/tv/UpstreamContentFocusTest.kt | 1 + app/src/test/resources/conductarr/wire.json | 621 ++++++++++++++++++ .../wolfskin/tv/ConductarrCatalogue.kt | 105 +++ .../tv/ConductarrDiscoverOperations.kt | 53 +- .../wolfskin/tv/ConductarrPreference.kt | 55 ++ .../wolfskin/tv/ConductarrProjection.kt | 57 ++ .../wolfskin/tv/ConductarrProvider.kt | 117 ++++ .../wolfskin/tv/ConductarrRatings.kt | 37 ++ .../wolfskin/tv/ConductarrSession.kt | 80 +++ .../wolfskin/tv/ConductarrTitleProvider.kt | 34 + .../justinvisser/wolfskin/tv/DiscoverHost.kt | 110 +++- .../wolfskin/tv/HomeDestination.kt | 31 +- .../justinvisser/wolfskin/tv/ShellPeople.kt | 4 +- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 83 ++- .../wolfskin/tv/WolfskinLibrary.kt | 6 +- .../wolfskin/tv/read/HomeReadFacade.kt | 31 +- .../wolfskin/tv/read/LibraryReadFacade.kt | 31 +- .../wolfskin/design/ComingInSheet.kt | 11 +- .../wolfskin/design/ComingInState.kt | 6 + .../wolfskin/design/DiscoverCardProjection.kt | 2 +- .../wolfskin/design/DiscoverLaneProjection.kt | 2 +- .../wolfskin/design/DiscoverModel.kt | 3 + .../wolfskin/design/DiscoverScreen.kt | 8 + .../wolfskin/design/DiscoverShelf.kt | 8 +- .../wolfskin/design/DiscoverState.kt | 57 +- .../wolfskin/design/HomeScreen.kt | 8 +- .../wolfskin/design/LibraryScreen.kt | 6 + .../wolfskin/design/ScreenRatings.kt | 17 + .../wolfskin/design/ShellState.kt | 15 +- 38 files changed, 1879 insertions(+), 110 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt create mode 100644 app/src/test/resources/conductarr/wire.json create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30bff204..49c0c66d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,7 @@ configure { testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) + buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"") } signingConfigs { @@ -448,6 +449,7 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) testImplementation(libs.kotlinx.coroutines.test) + testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) diff --git a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt index 691f67c4..8eb4a397 100644 --- a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt +++ b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt @@ -5,4 +5,5 @@ import org.justinvisser.wolfskin.design.HomeFixture import org.justinvisser.wolfskin.design.HomeSnapshot /** 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 diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml index dd5ba542..c76d6792 100644 --- a/app/src/default/res/values/strings.xml +++ b/app/src/default/res/values/strings.xml @@ -5,4 +5,7 @@ Wolfskin Default Wolfskin Compact Wolfskin updated to %s + Conductarr server URL + Advanced override. HTTPS required; blank restores the build default. + Wolfskin preview fixtures (debug only) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index b6d9426c..5d43b394 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -660,6 +660,31 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val ConductarrUrl = + AppStringPreference( + 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( + 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 = AppDestinationPreference( title = R.string.override_user_profile_settings, @@ -1320,6 +1345,8 @@ val advancedPreferences = preferences = listOf( AppPreference.WolfskinScreens, + AppPreference.ConductarrUrl, + *if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(), AppPreference.ShowClock, AppPreference.BackdropStylePref, AppPreference.ShowLogos, diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt new file mode 100644 index 00000000..b2ea33c4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -0,0 +1,108 @@ +// 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))) + assertEquals(1, requests.openCount) + } + } + } + + @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", "episode"), episode.map { it.source }) + 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 + assertEquals("Sam Rockwell", (provider.cast(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().name) + assertEquals(28L, (provider.season(44, 1).last() as ReadResult.Value).saved.value!!.episodes!!.single().voteCount) + assertEquals("Sam Rockwell", (provider.performer(216).last() as ReadResult.Value).saved.value!!.name) + assertEquals("Sam Bell", (provider.filmography(216).last() as ReadResult.Value).saved.value!!.items!!.single().credits!!.single().character) + assertEquals("Arrival", (provider.related(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().title) + assertEquals("Moon", (provider.display(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.title) + assertEquals("mindel", (provider.activity(42, MediaType.MOVIE, "mindel").last() as ReadResult.Value).saved.value!!.viewer.key) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt new file mode 100644 index 00000000..4a1e952f --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt @@ -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) + assertNull(ratings) + 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() } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt new file mode 100644 index 00000000..608728aa --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt @@ -0,0 +1,82 @@ +// 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() + + @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 { ConductarrRatings(it.client) }) { + WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } + } + } + rule.waitUntil(10_000) { 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() + 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) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt new file mode 100644 index 00000000..81b353af --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -0,0 +1,78 @@ +// 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" + +/** A loopback server supplies wire JSON, never app projections or live service state. */ +internal class FakeConductarr : Closeable { + val server = MockWebServer() + val calls = CopyOnWriteArrayList() + 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 + + init { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + calls.add(request) + if (status != 200) return response("", status) + val path = request.url.encodedPath + 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 (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 == "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 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))) + return response(body.toString()) + } + } + server.start() + } + + val url: String get() = server.url("/").toString() + private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build() + override fun close() = server.close() +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index 47f843b2..f8322b5f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest { } 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 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() rule.setContent { val destination = navigation.backStack.last() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt index d9e88778..5c55f1fc 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -112,6 +112,7 @@ class UpstreamContentFocusTest { val state = shellState() val model = mockk(relaxed = true) every { model.session } returns WolfskinSession().also { it.state = state } + every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) every { model.libraryDestinations } returns emptyMap() // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. every { model.signedInPerson } returns "unavailable" diff --git a/app/src/test/resources/conductarr/wire.json b/app/src/test/resources/conductarr/wire.json new file mode 100644 index 00000000..2893b49a --- /dev/null +++ b/app/src/test/resources/conductarr/wire.json @@ -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 + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt new file mode 100644 index 00000000..b0b918ae --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt @@ -0,0 +1,105 @@ +// 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.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, + val sort: DiscoverSort = DiscoverSort.POPULAR, + val service: Long? = null, + val genres: Map = emptyMap(), +) + +/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ +internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List) { + private data class SourcePage(val number: Int, val last: Boolean, val titles: List, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) + private val sources = mutableMapOf() + private val visited = mutableSetOf() + private val mutex = Mutex() + + suspend fun saved(): List = mutex.withLock { + queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) } + lanes() + } + + suspend fun through(index: Int, force: Boolean = false): List = 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 = mutex.withLock { + queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.FORCE, 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, 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() } + 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 = 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) + } +} + +/** Option ids, not guessed service ids, determine every catalogue parameter. */ +internal fun catalogueQueries(options: List, service: DiscoverService? = null, type: String? = null): List { + 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) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt index 0c8a7067..0880192e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -22,44 +22,63 @@ import org.justinvisser.wolfskin.design.RequestPhase class ConductarrDiscoverOperations( private val client: ConductarrClient, private val refreshSnapshot: suspend () -> DiscoverSnapshot, + private val profile: String? = null, + private val allowedRequesters: Set? = null, + private val canRequest: Boolean = true, ) : DiscoverOperations { + private val submissions = mutableSetOf>() + private val requestMutex = kotlinx.coroutines.sync.Mutex() override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { - val title = intent.title - require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" } - val body = RequestIntentBody( - mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, - recommendationProfile = 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) - } + requestMutex.lock() + try { + val title = intent.title + if (!canRequest || allowedRequesters?.contains(intent.requester) == false || + (title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused) + val key = title.key to intent.requester + val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last() + if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed) + val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull { + it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong))) + } + if (existing != null) return existing.outcome() + if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed) + val body = RequestIntentBody( + 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 { 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) } } override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { - 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, title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) return result is MutationResult.Success || observedHidden(profile, title) == true } override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { - 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 } 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 { it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt new file mode 100644 index 00000000..f15fdef7 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -0,0 +1,55 @@ +// 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 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 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): ConductarrSession = + ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr"))) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt new file mode 100644 index 00000000..b4a819a4 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt @@ -0,0 +1,57 @@ +// 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): 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(): DiscoverTitle = DiscoverTitle( + tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, + poster = posterUrl, backdrop = backdropUrl, +) + +internal fun DiscoverSnapshot.homeRows(): List { + if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut)) + 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 -> + 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 = 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(), +) + +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 stage = when { + stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed + visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready + awaitingApproval == true -> ComingInStage.Waiting + visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady + acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading + acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching + stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking + else -> ComingInStage.Waiting + } + return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, + requester.name, actor?.requester, actor?.name, stage) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt new file mode 100644 index 00000000..1713b965 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -0,0 +1,117 @@ +// 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 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() + private val services = mutableMapOf() + fun discover(profile: String, force: Boolean = false): Flow = 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).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 picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } + val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate -> + candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } } + }.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) } + 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, 0f) }, + 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 = catalogue?.through(index).orEmpty() + suspend fun more(lane: String): List = catalogue?.more(lane).orEmpty() + suspend fun readAhead(lane: String?): List { + 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 { + val key = "${service.id}:$type" + val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) } + return if (more != null) loader.more(more) else loader.through(through) + } + + fun requests(): Flow = 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 items = coroutineScope { + feed?.items.orEmpty().map { item -> async { + val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV + val art = reads.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) ComingInStatus.Unavailable else ComingInStatus.Ready, + partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && feed != null)) + } + } + + suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles()) + suspend fun audiences(): List = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() +} + +/** 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 read(call: ReadCall, 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())) } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt new file mode 100644 index 00000000..6e15b7b0 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +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?): List? { + val media = if (type == "movie") MediaType.MOVIE else MediaType.TV + val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media)).lastOrNull()) { + is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type) + is ReadResult.Failure -> 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), ReadTrigger.IF_STALE).lastOrNull() + 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() } + val score = vote?.voteAverage?.takeIf { it.isFinite() && it > 0 && it <= 10 && (vote.voteCount ?: 0) > 0 } ?: return ratings + return ratings.filterNot { it.source == "tmdb" } + LibraryRating("episode", score) + } +} + +private fun List.screenRatings(type: String): List { + val observed = filter { it.state == Rating.State.OBSERVED && it.score?.isFinite() == true } + val tomatoes = listOf(Rating.Source.RT_CRITICS to "rtCritics", Rating.Source.RT_AUDIENCE to "rtAudience").mapNotNull { (source, mark) -> + observed.firstOrNull { it.source == source && it.score!! in 0.0..100.0 }?.let { LibraryRating(mark, it.score!!, it.status) } + } + val imdb = observed.firstOrNull { type == "movie" && it.source == Rating.Source.IMDB && it.score!! > 0 && it.score!! <= 10 } + val tmdb = observed.firstOrNull { it.source == Rating.Source.TMDB && it.score!! > 0 && it.score!! <= 10 && (it.votes ?: 0) > 0 } + val community = imdb?.let { LibraryRating("imdb", it.score!!) } ?: tmdb?.let { LibraryRating("tmdb", it.score!!) } + return tomatoes + listOfNotNull(community) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt new file mode 100644 index 00000000..e0ace3ac --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.io.Closeable +import java.security.MessageDigest +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 + +/** 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) : Closeable { + private val mutableIdentity = MutableStateFlow(null) + val identity: StateFlow = mutableIdentity.asStateFlow() + private var selection: ByteArray? = null + @Volatile private var generation = 0L + + @Synchronized + 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()) + if (!retry && selection?.contentEquals(fingerprint) == true) return + generation++ + val currentGeneration = generation + mutableIdentity.value?.client?.close() + mutableIdentity.value = null + selection = fingerprint + if (token.isNullOrBlank()) return + val client = try { + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), onEvent = { event -> + if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) { + mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) } + } + }) + } catch (_: IllegalArgumentException) { return } + mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client)) + } + + /** 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 -> + if (mutableIdentity.value?.client !== selected.client) return@collect + when (result) { + is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) } + is ReadResult.Failure -> mutableIdentity.update { + if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error) + else it?.copy(error = result.error) + } + } + } + } + + override fun close() { + generation++ + mutableIdentity.value?.client?.close() + mutableIdentity.value = null + selection = null + } +} + +/** 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") +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt new file mode 100644 index 00000000..439bca3d --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt @@ -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> + fun cast(tmdb: Long, media: MediaType): Flow> + fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow> + fun season(tmdb: Long, number: Int): Flow> + fun performer(id: Long): Flow> + fun filmography(id: Long, page: Int = 1): Flow> + fun activity(tmdb: Long, media: MediaType, viewer: String): Flow> + fun requests(tmdb: Long, media: MediaType): Flow> + fun enrichment(tmdb: Long, media: MediaType): Flow> + fun display(tmdb: Long, media: MediaType): Flow> +} + +/** 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) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index b107bd97..7db7870c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -2,54 +2,105 @@ package org.justinvisser.wolfskin.tv import android.content.Context -import androidx.compose.runtime.Composable -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.runtime.* import androidx.compose.ui.platform.LocalContext import com.github.damontecres.wholphin.BuildConfig import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.design.DiscoverFixture -import org.justinvisser.wolfskin.design.DiscoverOperations -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 +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* internal val LocalDiscoverState = compositionLocalOf { 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 internal fun rememberDiscoverState( shell: ShellState, push: (String) -> Unit, pop: () -> Unit, loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } }, + identity: ConductarrIdentity? = null, + fixtures: Boolean = BuildConfig.DEBUG, + retrySignIn: suspend () -> Unit = {}, ): DiscoverState { val context = LocalContext.current 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)) } - LaunchedEffect(shell, shell.person.key) { - if (BuildConfig.DEBUG) { + val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf>() } + val refresh: suspend () -> DiscoverSnapshot = { + if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull() ?: 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 { ConductarrDiscoverOperations(it.client, refresh, + profile = it.operator!!.recommendationProfile, + allowedRequesters = it.operator.capabilities.requesters.orEmpty().toSet(), canRequest = it.operator.capabilities.requestMedia) } + ?: 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 = if (fixtures) shell.people else shell.people.filter { it.key in identity?.operator?.capabilities?.requesters.orEmpty() }, + 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 + } + var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf(null) } + LaunchedEffect(state, fixtures) { + if (fixtures) { val fixture = loadFixture(context) 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) - state = loaded - if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) + fixtureState = loaded + 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) } } } - LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) } - return state + LaunchedEffect(state.service?.id, state.filter, 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) 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.tail == DiscoverTail.SeeAll && column >= focused.titles.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) + state.invalidateLanes() + } + } + } + LaunchedEffect(state.title?.key, state) { + val title = state.title ?: return@LaunchedEffect + if (!fixtures && identity?.signedIn == true) { + val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV + identity.provider.detail(title.tmdb, media).collect { result -> + val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value } + if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy( + runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(), + seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size)) + } + } + } + val result = if (fixtures) fixtureState ?: state else state + LaunchedEffect(shell.route, result) { result.enterRoute(shell.route) } + 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 { @@ -57,7 +108,6 @@ private fun readDiscoverFixture(context: Context): DiscoverFixture { 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 { override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor) override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) @@ -66,10 +116,10 @@ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, pr 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 check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) override suspend fun hide(profile: String, title: DiscoverTitle) = false override suspend fun restore(profile: String, title: DiscoverTitle) = false - override suspend fun refresh() = DiscoverSnapshot() + override suspend fun refresh() = retry() } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 91b75b04..9a9c04ac 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -37,25 +37,35 @@ class WolfskinHomeViewModel @Inject constructor( private val cache: HomeLibraryCache, private val navigation: NavigationManager, private val seeAll: WolfskinSeeAllLists, + private val conductarr: ConductarrSession, ) : ViewModel() { + val conductarrState get() = conductarr.identity var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) private set var toast by mutableStateOf(null) private set + private var catalogueJob: kotlinx.coroutines.Job? = null suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + catalogueJob?.cancel() withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } + if (retry && conductarr.identity.value?.signedIn != true) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + conductarr.authenticate() + } val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut)) - if (person != user.name.orEmpty().lowercase()) { + if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) { snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) return } val scope = "${server.id}:${user.id}" val saved = withContext(Dispatchers.IO) { cache.read(scope) } + var libraryCopy = saved?.first ?: HomeLibrary() if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation)) try { val library = withContext(Dispatchers.IO) { home.read(user.id) } val savedAt = System.currentTimeMillis() + libraryCopy = library withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) } snapshot = HomeSnapshot(buildHomeRows(library, continuation)) } catch (exception: Exception) { @@ -64,6 +74,19 @@ class WolfskinHomeViewModel @Inject constructor( else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) if (retry) showToast("Still can’t reach it. The last saved view stays on screen.") } + val identity = conductarr.identity.value + if (identity?.signedIn == true) catalogueJob = viewModelScope.launch { + identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue -> + if (conductarr.identity.value?.client !== identity.client) return@collect + val status = when (catalogue.status) { + DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut + DiscoverStatus.Unavailable -> HomeAvailability.Unavailable + else -> if (catalogue.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready + } + snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogue.homeRows(), libraryAvailable = snapshot.jellyfinAvailable), + status, snapshot.jellyfinAvailable, catalogue.savedAt ?: snapshot.savedAt) + } + } } fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { @@ -123,9 +146,11 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( /** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */ @Composable 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.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) } 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 { message -> Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding)) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt index 2b7562df..5984cb5a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt @@ -8,7 +8,7 @@ import org.justinvisser.wolfskin.design.ShellPerson /** Prototype identities are packaged exclusively in debug assets, never release resources. */ fun readShellPeople(context: Context, saved: List): List { - if (BuildConfig.DEBUG) { + if (ConductarrPreference.fixtures) { val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) } val profiles = json.getJSONArray("profiles") return List(profiles.length()) { index -> @@ -18,6 +18,6 @@ fun readShellPeople(context: Context, saved: List): List 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()) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 533cdc4e..d927b035 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor( private val backdrop: BackdropService, val libraries: LibraryReadFacade, val seeAll: WolfskinSeeAllLists, + val conductarr: ConductarrSession, ) : ViewModel() { val backdropFlow: StateFlow = backdrop.backdropFlow private val requestsSnapshot by lazy { readShellRequests(context) } @@ -80,17 +81,28 @@ class WolfskinShellViewModel @Inject constructor( private set var signedInPerson by mutableStateOf("") private set + private var identityJob: kotlinx.coroutines.Job? = null + private var fixtureMode: Boolean? = null suspend fun load(server: JellyfinServer, user: JellyfinUser) { WolfskinScreenPreference.initialize(context) - signedInPerson = user.name.orEmpty().lowercase() - if (session.state == null || session.serverId != server.id.toString()) { + ConductarrPreference.initialize(context) + identityJob?.cancel() + libraries.audiences = null + if (ConductarrPreference.fixtures) conductarr.close() + else 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() + if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { 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() + fixtureMode = ConductarrPreference.fixtures session.state = ShellState(people, signedInPerson) { session.onAction(it) } } session.onAction = { action -> handleAction(action, server, user) } + identityJob = viewModelScope.launch { + if (!ConductarrPreference.fixtures) loadHousehold(user) + } try { libraryDestinations = libraries.readDestinations(user.id) } catch (exception: Exception) { @@ -99,13 +111,44 @@ 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 + libraries.audiences = audiences.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } + 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 = audiences.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) }) + } + session.state?.updatePeople(updated) + signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson + } + + suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + loadHousehold(user) + } + fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } - /** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ + /** Requests are person-scoped; a failed refresh retains the provider's saved observations. */ fun refreshRequests() { viewModelScope.launch { - val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } - session.state?.requests?.update(snapshot) + if (ConductarrPreference.fixtures) { + session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot }) + } else { + val identity = conductarr.identity.value + if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) + else identity.provider.requests().collect { snapshot -> + if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot) + } + } } } @@ -115,18 +158,23 @@ class WolfskinShellViewModel @Inject constructor( ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) - ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } - ShellAction.RetryRequests -> refreshRequests() + ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } + ShellAction.RetryRequests -> viewModelScope.launch { + if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) + refreshRequests() + } is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}")) } } private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { navigation.replace(Destination.Home()) - if (person.key == user.name.orEmpty().lowercase()) return + if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return + conductarr.close() + libraries.audiences = null viewModelScope.launch { val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) } + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } } if (saved == null || saved.isProtected) { signedInPerson = "" @@ -163,7 +211,14 @@ fun WolfskinApp( modifier: Modifier = Modifier, viewModel: WolfskinShellViewModel = hiltViewModel(), ) { - 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) } + 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) { ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } } @@ -172,10 +227,16 @@ fun WolfskinApp( state, { route -> navigationManager.navigateTo(Destination.Wolfskin(route)) }, navigationManager::goBack, + identity = identity, + fixtures = ConductarrPreference.fixtures, + retrySignIn = { + viewModel.retryConductarr(server, user) + }, ) RequestsRefresh(state, viewModel::refreshRequests) 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) { val current = navigationManager.backStack.last() if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 7d75c019..3f51a1ba 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -52,7 +52,7 @@ fun WolfskinLibraryContent( ) { val view = shell.rememberedView(route) val fixture by produceState(null, context) { - if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) { + if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } } } @@ -74,7 +74,7 @@ fun WolfskinLibraryContent( return } LaunchedEffect(grid, grid.refreshRevision, fixture) { - if (route in LIBRARY_DESTINATIONS && (!BuildConfig.DEBUG || fixture != null)) { + if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { val wasLoading = grid.status == LibraryStatus.Loading if (fixture != null) { grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } @@ -89,7 +89,7 @@ fun WolfskinLibraryContent( if (wasLoading && 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") diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 1696930a..47bf457d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -21,25 +21,29 @@ import org.justinvisser.wolfskin.design.HomeLibrary import org.justinvisser.wolfskin.design.HomeTitle /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ -class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) { +class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { 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 next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title) + val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items + val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items + val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> + try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } + catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } + } + val resume = resumeItems.map { title(it, series) } + val next = nextItems.map { title(it, series) } val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val destinations = linkedMapOf>() val newest = mutableListOf() val totals = linkedMapOf() for (library in libraries) { - val key = when { - "anime" in library.name.orEmpty().lowercase() -> "anime" - library.collectionType == CollectionType.MOVIES -> "movies" - library.collectionType == CollectionType.TVSHOWS -> "shows" - else -> continue - } + val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: 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 - 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 - 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) } @@ -60,7 +64,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val }?.id } - private fun title(item: BaseItemDto): HomeTitle { + private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { val wrapped = BaseItem(item) return HomeTitle( id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, @@ -69,7 +73,8 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720), seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, 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(), ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index fbb037b9..7b12c908 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -16,18 +16,27 @@ import java.util.UUID import javax.inject.Inject /** 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) { + var audiences: List? = 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 { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() return buildMap { libraries.forEach { library -> - val name = library.name.orEmpty().lowercase() - val key = when { - "anime" in name -> "anime" - library.collectionType == CollectionType.MOVIES -> "movies" - library.collectionType == CollectionType.TVSHOWS -> "shows" - else -> null - } + val key = destination(library.name.orEmpty(), library.collectionType) if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) } } @@ -36,13 +45,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { /** A destination spans every matching accessible library; containers never become titles. */ suspend fun readLibrary(userId: UUID, destination: String): List { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> - val anime = library.name.orEmpty().contains("anime", ignoreCase = true) - when (destination) { - "anime" -> anime - "movies" -> !anime && library.collectionType == CollectionType.MOVIES - "shows" -> !anime && library.collectionType == CollectionType.TVSHOWS - else -> false - } + destination(library.name.orEmpty(), library.collectionType) == destination } val titles = linkedMapOf() for (library in libraries) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 87c358a2..691541e1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -38,8 +38,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { val state = shell.requests val data = state.snapshot + val ratingsReader = LocalScreenRatings.current LaunchedEffect(state.focusedId, data.signedIn, data.status) { - state.awaitFocusedRatings() + if (ratingsReader == null) state.awaitFocusedRatings() + else if (data.signedIn) state.focused?.let { item -> + ratingsReader.read(item.tmdbId, item.mediaType, null, null)?.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")) Column(modifier.fillMaxHeight().width(L.sheetWidth) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt index 86d6c5c3..42d4c93e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -85,6 +85,12 @@ class ComingInState { ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings } + fun updateRatings(id: String, values: List) { + 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. */ suspend fun awaitFocusedRatings() { val item = focused ?: return diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt index a638c6e6..488d90d3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt @@ -19,7 +19,7 @@ internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle { } return LibraryTitle(id = key, title = title, type = type, year = year, 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 { held -> "held" receipts.isEmpty() -> null diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt index bda06cdc..0c587c58 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -29,7 +29,7 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List 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()) - if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { + if (items.isNotEmpty() || lane.pending || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size, 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) if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt index 3fc3b296..080ba943 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt @@ -45,6 +45,8 @@ data class DiscoverSnapshot( val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, val savedAt: String? = null, val reusesFixtureLanes: Boolean = false, + val reachable: Boolean = true, + val requestsAllowed: Boolean = true, ) /** Lane identity survives lazy recycling and provider refreshes. */ @@ -57,6 +59,7 @@ data class DiscoverLane( val pick: Boolean = false, val hidden: Boolean = false, val fullTitles: List = titles, + val pending: Boolean = false, ) /** Caller identity is deliberately absent: the authenticated client supplies the actor. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 68ac7436..1c2525a9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -48,13 +48,21 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } 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 ratingsReader = LocalScreenRatings.current LaunchedEffect(focusedTitle?.key, state.contentFocused) { state.plateKey = null if (state.contentFocused && focusedTitle != null) { 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)?.let { state.updateRatings(focusedTitle, it) } state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key } } + LaunchedEffect(state.hero?.key, state.title?.key, state.isSignedIn) { + if (state.isSignedIn && ratingsReader != null) { + val title = state.title ?: state.hero + if (title != null) ratingsReader.read(title.tmdb, title.type, null, null)?.let { state.updateRatings(title, it) } + } + } if (state.title != null) { DiscoverTitleDestination(state, modifier); return } state.collection?.let { LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 8883d432..4492a69c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -76,6 +76,10 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { .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) } + } else if (lane.pending) { + items(slots, key = { "pending:$it" }) { + Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card))) + } } else { itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> 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) Caption( 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 fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) { 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, modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 23b11148..05f98b44 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -19,6 +19,8 @@ class DiscoverState( private val onPush: (String) -> Unit = {}, private val onPop: () -> Unit = {}, private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, + private val requesters: List = shell.people, + private val onMore: (suspend (String) -> List?)? = null, ) : ShellContentFocus { var snapshot by mutableStateOf(initial) private set @@ -46,6 +48,7 @@ class DiscoverState( val requests = mutableStateMapOf>() private val requestSheets = mutableMapOf, RequestSheetState>() private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } + private val ratingValues = mutableStateMapOf>() private lateinit var collectionCards: LinkedHashMap private var revision by mutableStateOf(0) private var cachedRevision = -1 @@ -59,11 +62,44 @@ class DiscoverState( private val dismissalVersions = mutableMapOf() private var rootFilter = "all" + fun updateSnapshot(value: DiscoverSnapshot) { + snapshot = value + hidden.clear() + value.hidden.forEach { hidden[it.key] = it } + revision++ + } + + fun invalidateLanes() { revision++ } + fun updateLanes(value: List) { snapshot = snapshot.copy(lanes = value); revision++ } + + private suspend fun loadLane(id: String) { + val loaded = onMore?.invoke(id) + if (loaded != null) updateLanes(loaded) else { snapshot = operations.refresh(); revision++ } + } + + fun updateTitle(value: DiscoverTitle) { + if (title?.key == value.key) title = value + } + + fun ratingsFor(item: DiscoverTitle): List = ratingValues[item.key] ?: item.ratings + + fun updateRatings(item: DiscoverTitle, values: List) { + val ratings = values.map { DiscoverRating(it.source, it.score, it.label) } + ratingValues[item.key] = ratings + fun replace(source: List) = 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++ + } + val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn val canRequest: Boolean get() = when (snapshot.status) { DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true else -> false } + val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest val heroes: List get() { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { @@ -88,7 +124,7 @@ class DiscoverState( val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) 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 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 override val cardKey: String? get() = when { overlay != null || title != null -> null @@ -127,7 +163,7 @@ class DiscoverState( 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 = { scope.launch { - snapshot = operations.refresh(); revision++ + loadLane(lane.id) if (collection !== grid) return@launch val fresh = lanes.firstOrNull { it.id == lane.id } if (canRequest && fresh != null) { @@ -142,6 +178,16 @@ class DiscoverState( }).apply { tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } } + if (onMore != null && lane.tail != DiscoverTail.None) 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(titles.size).apply { + titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } + } + LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.tail != DiscoverTail.None) + } collection = grid onPush("discover-see-all/${lane.id}") } @@ -217,7 +263,7 @@ class DiscoverState( if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return } val item = lane.titles.getOrNull(column) 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 if (lane.hidden) overlay = DiscoverOverlayState.Restore(item) else openTitle(item) @@ -243,7 +289,8 @@ class DiscoverState( fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { if (!canRequest) { refresh(); return } - val choice = RequestSheetState(item, shell.person, shell.people, other, phase) + if (!requestsEnabled) { toast = "Can’t request right now. Try again."; return } + val choice = RequestSheetState(item, shell.person, requesters, other, phase) val previous = requestSheets[item.key to choice.selectedPerson.key] overlay = DiscoverOverlayState.Request( if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice, @@ -276,7 +323,7 @@ class DiscoverState( } private fun send(sheet: RequestSheetState, check: Boolean) { - if (!canRequest || (!check && !sheet.canSubmit)) return + if (!requestsEnabled || (!check && !sheet.canSubmit)) return if (!check) { val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key] if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 2d0a646c..edb0b636 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -80,6 +80,7 @@ fun HomeScreen( view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) } val cache = view.home.ratings + val ratingsReader = LocalScreenRatings.current val row = snapshot.rows.getOrNull(view.row) val title = row?.cards?.getOrNull(focus.column) val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull() @@ -87,13 +88,14 @@ fun HomeScreen( 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) { if (hero != null && snapshot.conductarr == HomeAvailability.Ready) { - heroRatings = hero.ratings - cache.save(hero, heroRatings) + val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) } + if (values != null) { heroRatings = values; cache.save(hero, values) } } if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) { val saved = cache.read(title) if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong()) - focusedRatings = saved ?: title.ratings.also { cache.save(title, it) } + val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode)?.map(LibraryRating::homeRating) } + if (values != null) { focusedRatings = values; cache.save(title, values) } } } val showRatings = snapshot.conductarr != HomeAvailability.SignedOut diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 42cede9a..c8eb77fd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -49,6 +49,7 @@ fun LibraryScreen( val focused = grid.focusedTitle val count = if (grid.catalogue) items.size else grid.source.size var plateKey by remember { mutableStateOf(null) } + val ratingsReader = LocalScreenRatings.current DisposableEffect(shell, grid, registerFocus) { if (registerFocus) shell.contentFocus = grid onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } @@ -72,6 +73,11 @@ fun LibraryScreen( plateKey = null 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 (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) plateKey = focused.id } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt new file mode 100644 index 00000000..65e8ebe1 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.compositionLocalOf + +/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */ +fun interface ScreenRatings { + suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List? +} + +val LocalScreenRatings = compositionLocalOf { null } + +/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */ +fun LibraryRating.homeRating(): HomeRating = HomeRating( + mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } }, + score = score, percentage = source.startsWith("rt"), episode = source == "episode", +) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 58aa4c95..819d0936 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue /** Household identity data contains no credentials or server objects. */ -data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean) +data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean, val jellyfinUserId: String? = null) /** The shell owns keys; the integration owns navigation and authenticated user changes. */ sealed interface ShellAction { @@ -42,10 +42,21 @@ class RememberedView { /** Focus and Back transitions are independent of drawing and server availability. */ class ShellState( - val people: List, + people: List, initialPerson: String, private val onAction: (ShellAction) -> Unit = {}, ) { + var people by mutableStateOf(people) + private set + + /** Refresh household labels without discarding this person's navigation memory. */ + fun updatePeople(value: List) { + val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } + ?: value.firstOrNull { it.key == person.key } + people = value + if (selected != null) person = selected + pickerIndex = people.indexOf(person).coerceAtLeast(0) + } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set var route by mutableStateOf("home")