Wire selected Jellyfin users to Conductarr TV data
This commit is contained in:
parent
f53defad90
commit
59c2728d9c
38 changed files with 1879 additions and 110 deletions
|
|
@ -82,6 +82,7 @@ configure<ApplicationExtension> {
|
||||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||||
|
|
||||||
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString())
|
||||||
|
buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -448,6 +449,7 @@ dependencies {
|
||||||
testImplementation(libs.mockk.android)
|
testImplementation(libs.mockk.android)
|
||||||
testImplementation(libs.mockk.agent)
|
testImplementation(libs.mockk.agent)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
||||||
testImplementation(libs.androidx.core.testing)
|
testImplementation(libs.androidx.core.testing)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ import org.justinvisser.wolfskin.design.HomeFixture
|
||||||
import org.justinvisser.wolfskin.design.HomeSnapshot
|
import org.justinvisser.wolfskin.design.HomeSnapshot
|
||||||
|
|
||||||
/** Only debug builds may read the locked prototype household projection. */
|
/** Only debug builds may read the locked prototype household projection. */
|
||||||
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)
|
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? =
|
||||||
|
if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,7 @@
|
||||||
<string name="display_preset_default">Wolfskin Default</string>
|
<string name="display_preset_default">Wolfskin Default</string>
|
||||||
<string name="display_preset_compact">Wolfskin Compact</string>
|
<string name="display_preset_compact">Wolfskin Compact</string>
|
||||||
<string name="updated_toast">Wolfskin updated to %s</string>
|
<string name="updated_toast">Wolfskin updated to %s</string>
|
||||||
|
<string name="wolfskin_conductarr_url">Conductarr server URL</string>
|
||||||
|
<string name="wolfskin_conductarr_url_summary">Advanced override. HTTPS required; blank restores the build default.</string>
|
||||||
|
<string name="wolfskin_fixtures">Wolfskin preview fixtures (debug only)</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,31 @@ sealed interface AppPreference<Pref, T> {
|
||||||
summaryOff = R.string.disabled,
|
summaryOff = R.string.disabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ConductarrUrl =
|
||||||
|
AppStringPreference<AppPreferences>(
|
||||||
|
title = R.string.wolfskin_conductarr_url,
|
||||||
|
defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL,
|
||||||
|
getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value)
|
||||||
|
prefs
|
||||||
|
},
|
||||||
|
summary = R.string.wolfskin_conductarr_url_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
val WolfskinFixtures =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.wolfskin_fixtures,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value)
|
||||||
|
prefs
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val UserProfileSettings =
|
val UserProfileSettings =
|
||||||
AppDestinationPreference<AppPreferences>(
|
AppDestinationPreference<AppPreferences>(
|
||||||
title = R.string.override_user_profile_settings,
|
title = R.string.override_user_profile_settings,
|
||||||
|
|
@ -1320,6 +1345,8 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.WolfskinScreens,
|
AppPreference.WolfskinScreens,
|
||||||
|
AppPreference.ConductarrUrl,
|
||||||
|
*if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(),
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
AppPreference.BackdropStylePref,
|
AppPreference.BackdropStylePref,
|
||||||
AppPreference.ShowLogos,
|
AppPreference.ShowLogos,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() {
|
||||||
|
FakeConductarr().use { fake ->
|
||||||
|
ConductarrSession(MemoryCacheStore()).use { session ->
|
||||||
|
session.select("server:shared", SHARED_TOKEN, fake.url)
|
||||||
|
runBlocking { session.authenticate() }
|
||||||
|
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply {
|
||||||
|
pickerVisible = false
|
||||||
|
enterDestination("discover")
|
||||||
|
}
|
||||||
|
lateinit var current: DiscoverState
|
||||||
|
val art = ImageBitmap(1, 1)
|
||||||
|
rule.setContent {
|
||||||
|
val identity by session.identity.collectAsState()
|
||||||
|
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false)
|
||||||
|
current = state
|
||||||
|
CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RecordedRequest>()
|
||||||
|
val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject
|
||||||
|
@Volatile var status = 200
|
||||||
|
@Volatile var postStatus = 200
|
||||||
|
@Volatile var existingRequests = false
|
||||||
|
@Volatile var notModified = false
|
||||||
|
@Volatile var ratingsStatus = 200
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
|
||||||
}
|
}
|
||||||
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
|
||||||
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
|
||||||
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
|
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||||
val facade = mockk<LibraryReadFacade>()
|
val facade = mockk<LibraryReadFacade>()
|
||||||
rule.setContent {
|
rule.setContent {
|
||||||
val destination = navigation.backStack.last()
|
val destination = navigation.backStack.last()
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ class UpstreamContentFocusTest {
|
||||||
val state = shellState()
|
val state = shellState()
|
||||||
val model = mockk<WolfskinShellViewModel>(relaxed = true)
|
val model = mockk<WolfskinShellViewModel>(relaxed = true)
|
||||||
every { model.session } returns WolfskinSession().also { it.state = state }
|
every { model.session } returns WolfskinSession().also { it.state = state }
|
||||||
|
every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
|
||||||
every { model.libraryDestinations } returns emptyMap()
|
every { model.libraryDestinations } returns emptyMap()
|
||||||
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
|
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
|
||||||
every { model.signedInPerson } returns "unavailable"
|
every { model.signedInPerson } returns "unavailable"
|
||||||
|
|
|
||||||
621
app/src/test/resources/conductarr/wire.json
Normal file
621
app/src/test/resources/conductarr/wire.json
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
{
|
||||||
|
"operator": {
|
||||||
|
"capabilities": {
|
||||||
|
"administer": false,
|
||||||
|
"manage_library": false,
|
||||||
|
"manage_memberships": false,
|
||||||
|
"recommendation_profiles": [
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"request_media": true,
|
||||||
|
"requesters": [
|
||||||
|
"shared",
|
||||||
|
"mindel"
|
||||||
|
],
|
||||||
|
"watch_users": [
|
||||||
|
"shared"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "Shared",
|
||||||
|
"recommendation_profile": "shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household",
|
||||||
|
"subject": "jellyfin:shared",
|
||||||
|
"watch_user": "shared"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"key": "shared",
|
||||||
|
"name": "Shared"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mindel",
|
||||||
|
"name": "Mindel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_profile": "shared"
|
||||||
|
},
|
||||||
|
"audiences": {
|
||||||
|
"audiences": [
|
||||||
|
{
|
||||||
|
"is_anime": true,
|
||||||
|
"libraries": [
|
||||||
|
"Shared Anime"
|
||||||
|
],
|
||||||
|
"media_type": "tv",
|
||||||
|
"owner": "shared",
|
||||||
|
"users": [
|
||||||
|
"shared",
|
||||||
|
"mindel"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"feed": {
|
||||||
|
"profile": {
|
||||||
|
"key": "shared",
|
||||||
|
"name": "Shared"
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"coverage": {
|
||||||
|
"complete": true,
|
||||||
|
"excluded": 0,
|
||||||
|
"matched": 1,
|
||||||
|
"total": 1,
|
||||||
|
"unresolved": 0
|
||||||
|
},
|
||||||
|
"facts": [],
|
||||||
|
"genre_id": 0,
|
||||||
|
"media_type": "all",
|
||||||
|
"service_id": 0,
|
||||||
|
"unresolved": []
|
||||||
|
},
|
||||||
|
"state": "current",
|
||||||
|
"outcome": "ranked",
|
||||||
|
"materialization": "current",
|
||||||
|
"movies": [
|
||||||
|
{
|
||||||
|
"backdrop_path": "/moon-wide.jpg",
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"added": false,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Solaris",
|
||||||
|
"tmdb_id": 33,
|
||||||
|
"watched": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_path": "/moon.jpg",
|
||||||
|
"score": 4.2,
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"tvdb_id": 0,
|
||||||
|
"year": 2009
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shows": [],
|
||||||
|
"expires_at": "2099-11-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Comedy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"name": "Animation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"observations": [],
|
||||||
|
"region": "NL",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "Netflix"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "observed"
|
||||||
|
},
|
||||||
|
"discover": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"tmdb_id": 43,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Arrival",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"observations": [],
|
||||||
|
"page": 1,
|
||||||
|
"query": "",
|
||||||
|
"source": "general",
|
||||||
|
"state": "observed",
|
||||||
|
"total_pages": 2,
|
||||||
|
"total_results": 24
|
||||||
|
},
|
||||||
|
"dismissals": {
|
||||||
|
"profile": "shared",
|
||||||
|
"dismissals": [
|
||||||
|
{
|
||||||
|
"hidden_at": "2026-11-05T12:00:00Z",
|
||||||
|
"hidden_by": "shared",
|
||||||
|
"media_type": "movie",
|
||||||
|
"poster_path": "/hidden.jpg",
|
||||||
|
"title": "Hidden film",
|
||||||
|
"tmdb_id": 99,
|
||||||
|
"year": 2017
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ratings": {
|
||||||
|
"ratings": [
|
||||||
|
{
|
||||||
|
"source": "rt_critics",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 90,
|
||||||
|
"status": "Certified Fresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rt_audience",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 89,
|
||||||
|
"status": "Upright"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "imdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.8,
|
||||||
|
"votes": 300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.6,
|
||||||
|
"votes": 4000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"genres": [
|
||||||
|
"Comedy"
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"genre_state": "observed",
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Comedy"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"provider_state": "observed",
|
||||||
|
"providers": [],
|
||||||
|
"region": "NL",
|
||||||
|
"tmdb_id": 42
|
||||||
|
},
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"observations": [],
|
||||||
|
"ratings": [
|
||||||
|
{
|
||||||
|
"source": "rt_critics",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 90,
|
||||||
|
"status": "Certified Fresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rt_audience",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 89,
|
||||||
|
"status": "Upright"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "imdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.8,
|
||||||
|
"votes": 300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.6,
|
||||||
|
"votes": 4000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runtime": 97,
|
||||||
|
"seasons": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"name": "Season 1",
|
||||||
|
"overview": "A missing child.",
|
||||||
|
"episode_count": 10,
|
||||||
|
"recorded_episodes": 0,
|
||||||
|
"coverage": "none",
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "observed",
|
||||||
|
"title": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Moon",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enrichment": {
|
||||||
|
"genres": [
|
||||||
|
"Comedy"
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"genre_state": "observed",
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"name": "Comedy"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media_type": "movie",
|
||||||
|
"provider_state": "observed",
|
||||||
|
"providers": [],
|
||||||
|
"region": "NL",
|
||||||
|
"tmdb_id": 42
|
||||||
|
},
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"observations": [],
|
||||||
|
"ratings": [
|
||||||
|
{
|
||||||
|
"source": "rt_critics",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 90,
|
||||||
|
"status": "Certified Fresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "rt_audience",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 89,
|
||||||
|
"status": "Upright"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "imdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.8,
|
||||||
|
"votes": 300000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmdb",
|
||||||
|
"state": "observed",
|
||||||
|
"score": 7.6,
|
||||||
|
"votes": 4000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runtime": 97,
|
||||||
|
"seasons": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"name": "Season 1",
|
||||||
|
"overview": "A missing child.",
|
||||||
|
"episode_count": 10,
|
||||||
|
"recorded_episodes": 0,
|
||||||
|
"coverage": "none",
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "observed",
|
||||||
|
"title": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Moon",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"media_type": "movie",
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42
|
||||||
|
},
|
||||||
|
"cast": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"source": "seerr",
|
||||||
|
"paging": "unpaged",
|
||||||
|
"credit_context": "movie-character",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"performer_id": 216,
|
||||||
|
"name": "Sam Rockwell",
|
||||||
|
"character": "Sam Bell",
|
||||||
|
"credit_id": "cast-216",
|
||||||
|
"order": 0,
|
||||||
|
"profile_url": "https://images.example.invalid/sam.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"season": {
|
||||||
|
"tmdb_id": 44,
|
||||||
|
"media_type": "tv",
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"season": {
|
||||||
|
"number": 1,
|
||||||
|
"name": "Season 1",
|
||||||
|
"overview": "A missing child.",
|
||||||
|
"episode_count": 10,
|
||||||
|
"recorded_episodes": 0,
|
||||||
|
"coverage": "none",
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked"
|
||||||
|
},
|
||||||
|
"episodes": [
|
||||||
|
{
|
||||||
|
"number": 1,
|
||||||
|
"name": "Secrets",
|
||||||
|
"overview": "A child disappears.",
|
||||||
|
"recorded": "missing",
|
||||||
|
"vote_average": 8.1,
|
||||||
|
"vote_count": 28
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"observations": []
|
||||||
|
},
|
||||||
|
"performer": {
|
||||||
|
"performer_id": 216,
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"source": "seerr",
|
||||||
|
"name": "Sam Rockwell",
|
||||||
|
"biography": "Actor.",
|
||||||
|
"also_known_as": []
|
||||||
|
},
|
||||||
|
"filmography": {
|
||||||
|
"performer_id": 216,
|
||||||
|
"metadata_state": "observed",
|
||||||
|
"source": "seerr",
|
||||||
|
"source_paging": "unpaged",
|
||||||
|
"completeness": "returned-non-adult-cast",
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"title": "Moon",
|
||||||
|
"overview": "An astronaut reaches the end of his assignment.",
|
||||||
|
"poster_url": "https://images.example.invalid/moon.jpg",
|
||||||
|
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
|
||||||
|
"release_date": "2009-06-12",
|
||||||
|
"held": "unheld",
|
||||||
|
"libraries": [],
|
||||||
|
"request_state": "none",
|
||||||
|
"seerr_state": "untracked",
|
||||||
|
"genre_ids": [
|
||||||
|
35
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"credits": [
|
||||||
|
{
|
||||||
|
"credit_id": "cast-216",
|
||||||
|
"character": "Sam Bell"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visibility_state": "unknown",
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"observations": []
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"actor": {
|
||||||
|
"name": "Shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household"
|
||||||
|
},
|
||||||
|
"filed_by_you": true,
|
||||||
|
"id": "request:7",
|
||||||
|
"media_type": "movie",
|
||||||
|
"on_behalf": true,
|
||||||
|
"requester": {
|
||||||
|
"key": "mindel",
|
||||||
|
"name": "Mindel"
|
||||||
|
},
|
||||||
|
"seasons": [],
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"name": "request",
|
||||||
|
"source": "conductarr",
|
||||||
|
"state": "done",
|
||||||
|
"summary": "Request accepted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acquisition",
|
||||||
|
"source": "sonarr",
|
||||||
|
"state": "in_progress",
|
||||||
|
"summary": "Downloading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "visibility",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not yet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "playback",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not checked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"blocking_stage": "acquisition"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requester": "all",
|
||||||
|
"sources": [],
|
||||||
|
"truncated": false
|
||||||
|
},
|
||||||
|
"readiness": {
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"media_type": "movie",
|
||||||
|
"viewer": {
|
||||||
|
"key": "mindel",
|
||||||
|
"name": "Mindel"
|
||||||
|
},
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"name": "request",
|
||||||
|
"source": "conductarr",
|
||||||
|
"state": "done",
|
||||||
|
"summary": "Request accepted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acquisition",
|
||||||
|
"source": "sonarr",
|
||||||
|
"state": "in_progress",
|
||||||
|
"summary": "Downloading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "visibility",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not yet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "playback",
|
||||||
|
"source": "jellyfin",
|
||||||
|
"state": "none",
|
||||||
|
"summary": "Not checked"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"seerr": {
|
||||||
|
"state": "observed",
|
||||||
|
"requests": []
|
||||||
|
},
|
||||||
|
"coverage": [],
|
||||||
|
"open_link": {
|
||||||
|
"state": "missing"
|
||||||
|
},
|
||||||
|
"sources": []
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"actor": {
|
||||||
|
"name": "Shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household"
|
||||||
|
},
|
||||||
|
"attempts": 1,
|
||||||
|
"coverage": [],
|
||||||
|
"created_at": "2026-11-05T12:00:00Z",
|
||||||
|
"id": 7,
|
||||||
|
"media_type": "movie",
|
||||||
|
"membership": {
|
||||||
|
"status": "added"
|
||||||
|
},
|
||||||
|
"on_behalf": true,
|
||||||
|
"requester": "mindel",
|
||||||
|
"requester_username": "Mindel",
|
||||||
|
"seasons": null,
|
||||||
|
"seerr": {
|
||||||
|
"outcome": "requested",
|
||||||
|
"request_id": 9
|
||||||
|
},
|
||||||
|
"source": "recommendation",
|
||||||
|
"state": "settled",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"updated_at": "2026-11-05T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"receipt": {
|
||||||
|
"actor": {
|
||||||
|
"name": "Shared",
|
||||||
|
"requester": "shared",
|
||||||
|
"role": "household"
|
||||||
|
},
|
||||||
|
"attempts": 1,
|
||||||
|
"coverage": [],
|
||||||
|
"created_at": "2026-11-05T12:00:00Z",
|
||||||
|
"id": 7,
|
||||||
|
"media_type": "movie",
|
||||||
|
"membership": {
|
||||||
|
"status": "added"
|
||||||
|
},
|
||||||
|
"on_behalf": true,
|
||||||
|
"requester": "mindel",
|
||||||
|
"requester_username": "Mindel",
|
||||||
|
"seasons": null,
|
||||||
|
"seerr": {
|
||||||
|
"outcome": "requested",
|
||||||
|
"request_id": 9
|
||||||
|
},
|
||||||
|
"source": "recommendation",
|
||||||
|
"state": "settled",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"updated_at": "2026-11-05T12:00:00Z"
|
||||||
|
},
|
||||||
|
"hide": {
|
||||||
|
"created": true,
|
||||||
|
"dismissal": {
|
||||||
|
"hidden_at": "2026-11-05T12:00:00Z",
|
||||||
|
"hidden_by": "shared",
|
||||||
|
"media_type": "movie",
|
||||||
|
"poster_path": "/hidden.jpg",
|
||||||
|
"title": "Moon",
|
||||||
|
"tmdb_id": 42,
|
||||||
|
"year": 2017
|
||||||
|
},
|
||||||
|
"materialization": "current",
|
||||||
|
"profile": "shared"
|
||||||
|
},
|
||||||
|
"restore": {
|
||||||
|
"media_type": "movie",
|
||||||
|
"profile": "shared",
|
||||||
|
"restored": true,
|
||||||
|
"tmdb_id": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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<MediaType>,
|
||||||
|
val sort: DiscoverSort = DiscoverSort.POPULAR,
|
||||||
|
val service: Long? = null,
|
||||||
|
val genres: Map<MediaType, Long> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */
|
||||||
|
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>) {
|
||||||
|
private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean)
|
||||||
|
private val sources = mutableMapOf<String, SourcePage>()
|
||||||
|
private val visited = mutableSetOf<String>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun saved(): List<DiscoverLane> = mutex.withLock {
|
||||||
|
queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) }
|
||||||
|
lanes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun through(index: Int, force: Boolean = false): List<DiscoverLane> = mutex.withLock {
|
||||||
|
queries.take((index + 1).coerceIn(0, queries.size)).forEach { query ->
|
||||||
|
if (force || query.id !in visited) {
|
||||||
|
load(query, if (force) ReadTrigger.FORCE else ReadTrigger.IF_STALE, false)
|
||||||
|
visited.add(query.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lanes()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun more(id: String): List<DiscoverLane> = mutex.withLock {
|
||||||
|
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.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<DiscoverLane> = queries.map { query ->
|
||||||
|
val pages = query.media.mapNotNull { sources["${query.id}:${it.name}"] }
|
||||||
|
val items = pages.flatMap { it.titles }.distinctBy { it.key }
|
||||||
|
val pending = pages.isEmpty() && query.id !in visited
|
||||||
|
DiscoverLane(query.id, query.name, items, total = pages.sumOf { it.total }, tail = when {
|
||||||
|
pages.any { it.failed } -> DiscoverTail.Retry
|
||||||
|
pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore
|
||||||
|
pages.any { !it.last } -> DiscoverTail.SeeAll
|
||||||
|
else -> DiscoverTail.None
|
||||||
|
}, pending = pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option ids, not guessed service ids, determine every catalogue parameter. */
|
||||||
|
internal fun catalogueQueries(options: List<Options>, service: DiscoverService? = null, type: String? = null): List<CatalogueLaneQuery> {
|
||||||
|
val media = if (type == null) MediaType.entries else listOf(if (type == "movie") MediaType.MOVIE else MediaType.TV)
|
||||||
|
val noun = if (type == "movie") "movies" else "series"
|
||||||
|
val genres = options.flatMap { option -> option.genres.orEmpty().map { it.name to (MediaType.valueOf(option.mediaType.name) to it.id) } }
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
if (service != null) return listOf(
|
||||||
|
CatalogueLaneQuery("sv-pop", "Popular $noun", media, service = service.id),
|
||||||
|
CatalogueLaneQuery("sv-new", "New $noun", media, DiscoverSort.NEW, service.id),
|
||||||
|
CatalogueLaneQuery("sv-top", "Top rated $noun", media, DiscoverSort.TOP, service.id),
|
||||||
|
) + genres.entries.filter { entry -> entry.value.any { it.first in media } }.take(3).map { (name, ids) ->
|
||||||
|
CatalogueLaneQuery("sv-g-$name", "$name $noun", media.filter { item -> ids.any { it.first == item } }, service = service.id, genres = ids.toMap())
|
||||||
|
}
|
||||||
|
val services = options.flatMap { it.services.orEmpty() }.distinctBy { it.id }
|
||||||
|
return services.map { CatalogueLaneQuery("new-${it.id}", "New on ${it.name}", media, DiscoverSort.NEW, it.id) } +
|
||||||
|
CatalogueLaneQuery("trending", "Trending", media) +
|
||||||
|
listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").mapNotNull { name ->
|
||||||
|
genres[name]?.let { ids -> CatalogueLaneQuery("genre-$name", name,
|
||||||
|
ids.map { it.first }.distinct(), genres = ids.toMap()) }
|
||||||
|
} + CatalogueLaneQuery("soon", "Coming soon", media, DiscoverSort.UPCOMING)
|
||||||
|
}
|
||||||
|
|
@ -22,44 +22,63 @@ import org.justinvisser.wolfskin.design.RequestPhase
|
||||||
class ConductarrDiscoverOperations(
|
class ConductarrDiscoverOperations(
|
||||||
private val client: ConductarrClient,
|
private val client: ConductarrClient,
|
||||||
private val refreshSnapshot: suspend () -> DiscoverSnapshot,
|
private val refreshSnapshot: suspend () -> DiscoverSnapshot,
|
||||||
|
private val profile: String? = null,
|
||||||
|
private val allowedRequesters: Set<String>? = null,
|
||||||
|
private val canRequest: Boolean = true,
|
||||||
) : DiscoverOperations {
|
) : DiscoverOperations {
|
||||||
|
private val submissions = mutableSetOf<Pair<String, String>>()
|
||||||
|
private val requestMutex = kotlinx.coroutines.sync.Mutex()
|
||||||
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult {
|
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult {
|
||||||
val title = intent.title
|
requestMutex.lock()
|
||||||
require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" }
|
try {
|
||||||
val body = RequestIntentBody(
|
val title = intent.title
|
||||||
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
|
if (!canRequest || allowedRequesters?.contains(intent.requester) == false ||
|
||||||
recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester,
|
(title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused)
|
||||||
seasons = title.seasons?.map(Int::toLong),
|
val key = title.key to intent.requester
|
||||||
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
|
val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()
|
||||||
tmdbId = title.tmdb,
|
if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
)
|
val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull {
|
||||||
return when (val result = client.mutate(TvRoutes.request(body))) {
|
it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong)))
|
||||||
is MutationResult.Success -> result.value.outcome()
|
}
|
||||||
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed)
|
if (existing != null) return existing.outcome()
|
||||||
}
|
if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
|
val body = RequestIntentBody(
|
||||||
|
mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV,
|
||||||
|
recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester,
|
||||||
|
seasons = title.seasons?.map(Int::toLong),
|
||||||
|
source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE,
|
||||||
|
tmdbId = title.tmdb,
|
||||||
|
)
|
||||||
|
return when (val result = client.mutate(TvRoutes.request(body))) {
|
||||||
|
is MutationResult.Success -> result.value.outcome()
|
||||||
|
is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed)
|
||||||
|
}
|
||||||
|
} finally { requestMutex.unlock() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
|
override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult {
|
||||||
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
||||||
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
|
is ReadResult.Value -> result.saved.value?.requests?.firstOrNull {
|
||||||
|
it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong)))
|
||||||
|
}?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
|
is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean {
|
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean {
|
||||||
val result = client.mutate(TvRoutes.hide(profile,
|
val result = client.mutate(TvRoutes.hide(this.profile ?: profile,
|
||||||
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
|
RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV,
|
||||||
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong())))
|
title = title.title, tmdbId = title.tmdb, year = title.year?.toLong())))
|
||||||
return result is MutationResult.Success || observedHidden(profile, title) == true
|
return result is MutationResult.Success || observedHidden(profile, title) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean {
|
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean {
|
||||||
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
|
return result is MutationResult.Success || observedHidden(profile, title) == false
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? {
|
private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? {
|
||||||
return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) {
|
||||||
is ReadResult.Value -> result.saved.value?.dismissals?.any {
|
is ReadResult.Value -> result.saved.value?.dismissals?.any {
|
||||||
it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name
|
it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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")))
|
||||||
|
}
|
||||||
|
|
@ -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<Long, String>): DiscoverTitle = DiscoverTitle(
|
||||||
|
tmdbId, mediaType.name.lowercase(), title, year.toInt(), genreIds.orEmpty().mapNotNull(genres::get), overview,
|
||||||
|
catalogueImage(posterPath), catalogueImage(backdropPath), score = score,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun Title.discoverTitle(): DiscoverTitle = DiscoverTitle(
|
||||||
|
tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview,
|
||||||
|
poster = posterUrl, backdrop = backdropUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -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<Options>()
|
||||||
|
private val services = mutableMapOf<String, ConductarrCatalogue>()
|
||||||
|
fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow {
|
||||||
|
val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY)
|
||||||
|
if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved)
|
||||||
|
emit(discoverSnapshot(profile, if (force) ReadTrigger.FORCE else ReadTrigger.OPEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun discoverSnapshot(profile: String, trigger: ReadTrigger): DiscoverSnapshot = coroutineScope {
|
||||||
|
val reads = SnapshotReads(client, trigger)
|
||||||
|
val feedJob = async { reads.read(TvRoutes.feed(profile)) }
|
||||||
|
val hiddenJob = async { reads.read(TvRoutes.dismissals(profile)) }
|
||||||
|
val optionsJobs = MediaType.entries.map { media -> async { reads.read(TvRoutes.options(media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.FOREGROUND) } }
|
||||||
|
val feed = feedJob.await()
|
||||||
|
val options = optionsJobs.mapNotNull { it.await() }
|
||||||
|
this@ConductarrProvider.options = options
|
||||||
|
val queries = catalogueQueries(options)
|
||||||
|
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries).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<DiscoverLane> = catalogue?.through(index).orEmpty()
|
||||||
|
suspend fun more(lane: String): List<DiscoverLane> = catalogue?.more(lane).orEmpty()
|
||||||
|
suspend fun readAhead(lane: String?): List<DiscoverLane> {
|
||||||
|
val loader = catalogue ?: return emptyList()
|
||||||
|
val index = loader.queries.indexOfFirst { it.id == lane }
|
||||||
|
return loader.through(index + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> {
|
||||||
|
val key = "${service.id}:$type"
|
||||||
|
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) }
|
||||||
|
return if (more != null) loader.more(more) else loader.through(through)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requests(): Flow<ComingInSnapshot> = flow {
|
||||||
|
for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.FOREGROUND)) {
|
||||||
|
val reads = SnapshotReads(client, trigger)
|
||||||
|
val feed = reads.read(TvRoutes.activity())
|
||||||
|
if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue
|
||||||
|
val 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<LibraryAudience> = 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 <T> read(call: ReadCall<T>, event: ReadTrigger = trigger): T? {
|
||||||
|
return when (val result = client.read(call, event).lastOrNull()) {
|
||||||
|
is ReadResult.Value -> result.saved.value
|
||||||
|
is ReadResult.Failure -> {
|
||||||
|
failed = true
|
||||||
|
if (result.error == ClientError.NeedsSignIn) needsSignIn = true
|
||||||
|
result.saved?.let { saved -> synchronized(this) { oldestSaved = minOf(oldestSaved ?: Long.MAX_VALUE, saved.savedAtMillis) } }
|
||||||
|
result.saved?.value
|
||||||
|
}
|
||||||
|
null -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savedTime(): String? = oldestSaved?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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<LibraryRating>? {
|
||||||
|
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<Rating>.screenRatings(type: String): List<LibraryRating> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -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<ConductarrIdentity?>(null)
|
||||||
|
val identity: StateFlow<ConductarrIdentity?> = 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")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
|
import org.justinvisser.wolfskin.conductarr.model.*
|
||||||
|
|
||||||
|
/** Titles can adopt these reads without importing transport or owning another login. */
|
||||||
|
interface ConductarrTitleProvider {
|
||||||
|
fun detail(tmdb: Long, media: MediaType): Flow<ReadResult<Detail>>
|
||||||
|
fun cast(tmdb: Long, media: MediaType): Flow<ReadResult<TitleCast>>
|
||||||
|
fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow<ReadResult<Page>>
|
||||||
|
fun season(tmdb: Long, number: Int): Flow<ReadResult<SeasonDetail>>
|
||||||
|
fun performer(id: Long): Flow<ReadResult<PerformerDetail>>
|
||||||
|
fun filmography(id: Long, page: Int = 1): Flow<ReadResult<FilmographyPage>>
|
||||||
|
fun activity(tmdb: Long, media: MediaType, viewer: String): Flow<ReadResult<ViewerReadiness>>
|
||||||
|
fun requests(tmdb: Long, media: MediaType): Flow<ReadResult<TitleRequestsBody>>
|
||||||
|
fun enrichment(tmdb: Long, media: MediaType): Flow<ReadResult<Enrichment>>
|
||||||
|
fun display(tmdb: Long, media: MediaType): Flow<ReadResult<Display>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */
|
||||||
|
internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider {
|
||||||
|
override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN)
|
||||||
|
override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN)
|
||||||
|
override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN)
|
||||||
|
override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN)
|
||||||
|
override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN)
|
||||||
|
override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE)
|
||||||
|
override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN)
|
||||||
|
}
|
||||||
|
|
@ -2,54 +2,105 @@
|
||||||
package org.justinvisser.wolfskin.tv
|
package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.lastOrNull
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.justinvisser.wolfskin.design.DiscoverFixture
|
import org.justinvisser.wolfskin.conductarr.*
|
||||||
import org.justinvisser.wolfskin.design.DiscoverOperations
|
import org.justinvisser.wolfskin.design.*
|
||||||
import org.justinvisser.wolfskin.design.DiscoverRequest
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverRequestResult
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverSnapshot
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverState
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverTitle
|
|
||||||
import org.justinvisser.wolfskin.design.DiscoverStatus
|
|
||||||
import org.justinvisser.wolfskin.design.RequestPhase
|
|
||||||
import org.justinvisser.wolfskin.design.ShellState
|
|
||||||
|
|
||||||
internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Discover requires the Wolfskin shell") }
|
internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Discover requires the Wolfskin shell") }
|
||||||
|
|
||||||
/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */
|
/** Production uses the selected login; explicit fixtures remain deterministic and network-free. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberDiscoverState(
|
internal fun rememberDiscoverState(
|
||||||
shell: ShellState,
|
shell: ShellState,
|
||||||
push: (String) -> Unit,
|
push: (String) -> Unit,
|
||||||
pop: () -> Unit,
|
pop: () -> Unit,
|
||||||
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
|
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
|
||||||
|
identity: ConductarrIdentity? = null,
|
||||||
|
fixtures: Boolean = BuildConfig.DEBUG,
|
||||||
|
retrySignIn: suspend () -> Unit = {},
|
||||||
): DiscoverState {
|
): DiscoverState {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) }
|
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
|
||||||
LaunchedEffect(shell, shell.person.key) {
|
val refresh: suspend () -> DiscoverSnapshot = {
|
||||||
if (BuildConfig.DEBUG) {
|
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<DiscoverState?>(null) }
|
||||||
|
LaunchedEffect(state, fixtures) {
|
||||||
|
if (fixtures) {
|
||||||
val fixture = loadFixture(context)
|
val fixture = loadFixture(context)
|
||||||
val snapshot = fixture.snapshot(shell.person.key)
|
val snapshot = fixture.snapshot(shell.person.key)
|
||||||
val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)
|
|
||||||
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
|
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
|
||||||
state = loaded
|
fixtureState = loaded
|
||||||
if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
||||||
|
} else if (identity?.signedIn == true) {
|
||||||
|
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) }
|
LaunchedEffect(state.service?.id, state.filter, state) {
|
||||||
return state
|
val service = state.service ?: return@LaunchedEffect
|
||||||
|
if (!fixtures && identity?.signedIn == true) {
|
||||||
|
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter)
|
||||||
|
state.invalidateLanes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) {
|
||||||
|
if (!fixtures && identity?.signedIn == true) 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 {
|
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" }
|
return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixture writes are in-memory only and cannot reach a Conductarr client. */
|
|
||||||
private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
|
private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
|
||||||
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor)
|
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor)
|
||||||
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
|
|
@ -66,10 +116,10 @@ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, pr
|
||||||
override suspend fun refresh() = snapshot
|
override suspend fun refresh() = snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
private object SignedOutDiscoverOperations : DiscoverOperations {
|
private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations {
|
||||||
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused)
|
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused)
|
||||||
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||||
override suspend fun hide(profile: String, title: DiscoverTitle) = false
|
override suspend fun hide(profile: String, title: DiscoverTitle) = false
|
||||||
override suspend fun restore(profile: String, title: DiscoverTitle) = false
|
override suspend fun restore(profile: String, title: DiscoverTitle) = false
|
||||||
override suspend fun refresh() = DiscoverSnapshot()
|
override suspend fun refresh() = retry()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,25 +37,35 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
private val cache: HomeLibraryCache,
|
private val cache: HomeLibraryCache,
|
||||||
private val navigation: NavigationManager,
|
private val navigation: NavigationManager,
|
||||||
private val seeAll: WolfskinSeeAllLists,
|
private val seeAll: WolfskinSeeAllLists,
|
||||||
|
private val conductarr: ConductarrSession,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val conductarrState get() = conductarr.identity
|
||||||
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
||||||
private set
|
private set
|
||||||
var toast by mutableStateOf<String?>(null)
|
var toast by mutableStateOf<String?>(null)
|
||||||
private set
|
private set
|
||||||
|
private var catalogueJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||||
|
catalogueJob?.cancel()
|
||||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
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))
|
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)
|
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val scope = "${server.id}:${user.id}"
|
val scope = "${server.id}:${user.id}"
|
||||||
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
|
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
|
||||||
|
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||||
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
||||||
try {
|
try {
|
||||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||||
val savedAt = System.currentTimeMillis()
|
val savedAt = System.currentTimeMillis()
|
||||||
|
libraryCopy = library
|
||||||
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
||||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -64,6 +74,19 @@ class WolfskinHomeViewModel @Inject constructor(
|
||||||
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||||
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
|
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) {
|
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. */
|
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||||
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
|
val identity by model.conductarrState.collectAsState()
|
||||||
|
LaunchedEffect(shell.person.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) }
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot
|
||||||
|
HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||||
model.toast?.let { message ->
|
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))
|
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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import org.justinvisser.wolfskin.design.ShellPerson
|
||||||
|
|
||||||
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
|
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
|
||||||
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
|
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
|
||||||
if (BuildConfig.DEBUG) {
|
if (ConductarrPreference.fixtures) {
|
||||||
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
|
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
|
||||||
val profiles = json.getJSONArray("profiles")
|
val profiles = json.getJSONArray("profiles")
|
||||||
return List(profiles.length()) { index ->
|
return List(profiles.length()) { index ->
|
||||||
|
|
@ -18,6 +18,6 @@ fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPers
|
||||||
}
|
}
|
||||||
return saved.map { user ->
|
return saved.map { user ->
|
||||||
val name = user.name.orEmpty()
|
val name = user.name.orEmpty()
|
||||||
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false)
|
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
private val backdrop: BackdropService,
|
private val backdrop: BackdropService,
|
||||||
val libraries: LibraryReadFacade,
|
val libraries: LibraryReadFacade,
|
||||||
val seeAll: WolfskinSeeAllLists,
|
val seeAll: WolfskinSeeAllLists,
|
||||||
|
val conductarr: ConductarrSession,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||||
private val requestsSnapshot by lazy { readShellRequests(context) }
|
private val requestsSnapshot by lazy { readShellRequests(context) }
|
||||||
|
|
@ -80,17 +81,28 @@ class WolfskinShellViewModel @Inject constructor(
|
||||||
private set
|
private set
|
||||||
var signedInPerson by mutableStateOf("")
|
var signedInPerson by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
|
private var identityJob: kotlinx.coroutines.Job? = null
|
||||||
|
private var fixtureMode: Boolean? = null
|
||||||
|
|
||||||
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
|
||||||
WolfskinScreenPreference.initialize(context)
|
WolfskinScreenPreference.initialize(context)
|
||||||
signedInPerson = user.name.orEmpty().lowercase()
|
ConductarrPreference.initialize(context)
|
||||||
if (session.state == null || session.serverId != server.id.toString()) {
|
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 saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
|
||||||
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
|
||||||
session.serverId = server.id.toString()
|
session.serverId = server.id.toString()
|
||||||
|
fixtureMode = ConductarrPreference.fixtures
|
||||||
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
|
||||||
}
|
}
|
||||||
session.onAction = { action -> handleAction(action, server, user) }
|
session.onAction = { action -> handleAction(action, server, user) }
|
||||||
|
identityJob = viewModelScope.launch {
|
||||||
|
if (!ConductarrPreference.fixtures) loadHousehold(user)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
libraryDestinations = libraries.readDestinations(user.id)
|
libraryDestinations = libraries.readDestinations(user.id)
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
|
|
@ -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() } }
|
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() {
|
fun refreshRequests() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
|
if (ConductarrPreference.fixtures) {
|
||||||
session.state?.requests?.update(snapshot)
|
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()
|
ShellAction.Pop -> navigation.goBack()
|
||||||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||||
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
|
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||||
ShellAction.RetryRequests -> refreshRequests()
|
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}"))
|
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
|
||||||
navigation.replace(Destination.Home())
|
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 {
|
viewModelScope.launch {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
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) {
|
if (saved == null || saved.isProtected) {
|
||||||
signedInPerson = ""
|
signedInPerson = ""
|
||||||
|
|
@ -163,7 +211,14 @@ fun WolfskinApp(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WolfskinShellViewModel = hiltViewModel(),
|
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) {
|
val loading = remember(user.id) {
|
||||||
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
|
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
|
||||||
}
|
}
|
||||||
|
|
@ -172,10 +227,16 @@ fun WolfskinApp(
|
||||||
state,
|
state,
|
||||||
{ route -> navigationManager.navigateTo(Destination.Wolfskin(route)) },
|
{ route -> navigationManager.navigateTo(Destination.Wolfskin(route)) },
|
||||||
navigationManager::goBack,
|
navigationManager::goBack,
|
||||||
|
identity = identity,
|
||||||
|
fixtures = ConductarrPreference.fixtures,
|
||||||
|
retrySignIn = {
|
||||||
|
viewModel.retryConductarr(server, user)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
RequestsRefresh(state, viewModel::refreshRequests)
|
RequestsRefresh(state, viewModel::refreshRequests)
|
||||||
WolfskinTheme {
|
WolfskinTheme {
|
||||||
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) {
|
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
|
||||||
|
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
val current = navigationManager.backStack.last()
|
val current = navigationManager.backStack.last()
|
||||||
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ fun WolfskinLibraryContent(
|
||||||
) {
|
) {
|
||||||
val view = shell.rememberedView(route)
|
val view = shell.rememberedView(route)
|
||||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
val fixture by produceState<LibraryFixture?>(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/") }
|
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +74,7 @@ fun WolfskinLibraryContent(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LaunchedEffect(grid, grid.refreshRevision, fixture) {
|
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
|
val wasLoading = grid.status == LibraryStatus.Loading
|
||||||
if (fixture != null) {
|
if (fixture != null) {
|
||||||
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
|
||||||
|
|
@ -89,7 +89,7 @@ fun WolfskinLibraryContent(
|
||||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
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")
|
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")
|
||||||
|
|
|
||||||
|
|
@ -21,25 +21,29 @@ import org.justinvisser.wolfskin.design.HomeLibrary
|
||||||
import org.justinvisser.wolfskin.design.HomeTitle
|
import org.justinvisser.wolfskin.design.HomeTitle
|
||||||
|
|
||||||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) {
|
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||||
suspend fun read(userId: UUID): HomeLibrary {
|
suspend fun read(userId: UUID): HomeLibrary {
|
||||||
val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title)
|
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||||
val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title)
|
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||||
|
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||||
|
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
|
||||||
|
catch (exception: Exception) {
|
||||||
|
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val resume = resumeItems.map { title(it, series) }
|
||||||
|
val next = nextItems.map { title(it, series) }
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||||
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
val destinations = linkedMapOf<String, MutableList<HomeTitle>>()
|
||||||
val newest = mutableListOf<HomeTitle>()
|
val newest = mutableListOf<HomeTitle>()
|
||||||
val totals = linkedMapOf<String, Int>()
|
val totals = linkedMapOf<String, Int>()
|
||||||
for (library in libraries) {
|
for (library in libraries) {
|
||||||
val key = when {
|
val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue
|
||||||
"anime" in library.name.orEmpty().lowercase() -> "anime"
|
|
||||||
library.collectionType == CollectionType.MOVIES -> "movies"
|
|
||||||
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
|
||||||
else -> continue
|
|
||||||
}
|
|
||||||
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
|
val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content
|
||||||
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title))
|
destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) })
|
||||||
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
totals[key] = (totals[key] ?: 0) + response.totalRecordCount
|
||||||
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title))
|
newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) })
|
||||||
}
|
}
|
||||||
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals)
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +64,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
}?.id
|
}?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun title(item: BaseItemDto): HomeTitle {
|
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||||
val wrapped = BaseItem(item)
|
val wrapped = BaseItem(item)
|
||||||
return HomeTitle(
|
return HomeTitle(
|
||||||
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
||||||
|
|
@ -69,7 +73,8 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
||||||
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
|
backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720),
|
||||||
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
||||||
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
||||||
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
|
||||||
|
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,27 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
||||||
|
@javax.inject.Singleton
|
||||||
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
|
var audiences: List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>? = null
|
||||||
|
|
||||||
|
fun destination(name: String, collection: CollectionType?): String? {
|
||||||
|
audiences?.let { known ->
|
||||||
|
val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null
|
||||||
|
return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows"
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
name.contains("anime", ignoreCase = true) -> "anime"
|
||||||
|
collection == CollectionType.MOVIES -> "movies"
|
||||||
|
collection == CollectionType.TVSHOWS -> "shows"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
suspend fun readDestinations(userId: UUID): Map<String, Destination> {
|
suspend fun readDestinations(userId: UUID): Map<String, Destination> {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
||||||
return buildMap {
|
return buildMap {
|
||||||
libraries.forEach { library ->
|
libraries.forEach { library ->
|
||||||
val name = library.name.orEmpty().lowercase()
|
val key = destination(library.name.orEmpty(), library.collectionType)
|
||||||
val key = when {
|
|
||||||
"anime" in name -> "anime"
|
|
||||||
library.collectionType == CollectionType.MOVIES -> "movies"
|
|
||||||
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
|
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,13 +45,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||||
/** A destination spans every matching accessible library; containers never become titles. */
|
/** A destination spans every matching accessible library; containers never become titles. */
|
||||||
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> {
|
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> {
|
||||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
|
||||||
val anime = library.name.orEmpty().contains("anime", ignoreCase = true)
|
destination(library.name.orEmpty(), library.collectionType) == destination
|
||||||
when (destination) {
|
|
||||||
"anime" -> anime
|
|
||||||
"movies" -> !anime && library.collectionType == CollectionType.MOVIES
|
|
||||||
"shows" -> !anime && library.collectionType == CollectionType.TVSHOWS
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val titles = linkedMapOf<UUID, LibraryTitle>()
|
val titles = linkedMapOf<UUID, LibraryTitle>()
|
||||||
for (library in libraries) {
|
for (library in libraries) {
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
|
||||||
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
||||||
val state = shell.requests
|
val state = shell.requests
|
||||||
val data = state.snapshot
|
val data = state.snapshot
|
||||||
|
val ratingsReader = LocalScreenRatings.current
|
||||||
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
|
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"))
|
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
|
||||||
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
Column(modifier.fillMaxHeight().width(L.sheetWidth)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,12 @@ class ComingInState {
|
||||||
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateRatings(id: String, values: List<ComingInRating>) {
|
||||||
|
snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it })
|
||||||
|
snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) }
|
||||||
|
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
||||||
|
}
|
||||||
|
|
||||||
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
|
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
|
||||||
suspend fun awaitFocusedRatings() {
|
suspend fun awaitFocusedRatings() {
|
||||||
val item = focused ?: return
|
val item = focused ?: return
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle {
|
||||||
}
|
}
|
||||||
return LibraryTitle(id = key, title = title, type = type, year = year,
|
return LibraryTitle(id = key, title = title, type = type, year = year,
|
||||||
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
|
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
|
||||||
ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
|
ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
|
||||||
badge = when {
|
badge = when {
|
||||||
held -> "held"
|
held -> "held"
|
||||||
receipts.isEmpty() -> null
|
receipts.isEmpty() -> null
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
||||||
snapshot.lanes.forEach { lane ->
|
snapshot.lanes.forEach { lane ->
|
||||||
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
|
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
|
||||||
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
|
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
|
||||||
if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
|
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,
|
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)
|
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) }
|
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ data class DiscoverSnapshot(
|
||||||
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
|
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
|
||||||
val savedAt: String? = null,
|
val savedAt: String? = null,
|
||||||
val reusesFixtureLanes: Boolean = false,
|
val reusesFixtureLanes: Boolean = false,
|
||||||
|
val reachable: Boolean = true,
|
||||||
|
val requestsAllowed: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Lane identity survives lazy recycling and provider refreshes. */
|
/** Lane identity survives lazy recycling and provider refreshes. */
|
||||||
|
|
@ -57,6 +59,7 @@ data class DiscoverLane(
|
||||||
val pick: Boolean = false,
|
val pick: Boolean = false,
|
||||||
val hidden: Boolean = false,
|
val hidden: Boolean = false,
|
||||||
val fullTitles: List<DiscoverTitle> = titles,
|
val fullTitles: List<DiscoverTitle> = titles,
|
||||||
|
val pending: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
|
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
|
||||||
|
|
|
||||||
|
|
@ -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 } }
|
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||||
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
||||||
|
val ratingsReader = LocalScreenRatings.current
|
||||||
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
|
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
|
||||||
state.plateKey = null
|
state.plateKey = null
|
||||||
if (state.contentFocused && focusedTitle != null) {
|
if (state.contentFocused && focusedTitle != null) {
|
||||||
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
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
|
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 }
|
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
|
||||||
state.collection?.let {
|
state.collection?.let {
|
||||||
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
|
||||||
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
|
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
|
||||||
}
|
}
|
||||||
|
} else if (lane.pending) {
|
||||||
|
items(slots, key = { "pending:$it" }) {
|
||||||
|
Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
||||||
DiscoverPoster(state, item, active && index == column, Modifier.clickable {
|
DiscoverPoster(state, item, active && index == column, Modifier.clickable {
|
||||||
|
|
@ -88,7 +92,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!services && active) {
|
if (!services && active && !lane.pending) {
|
||||||
val item = lane.titles.getOrNull(column)
|
val item = lane.titles.getOrNull(column)
|
||||||
Caption(
|
Caption(
|
||||||
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
|
||||||
|
|
@ -105,7 +109,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
@Composable
|
@Composable
|
||||||
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
|
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
|
||||||
val receipts = state.requests[title.key]
|
val receipts = state.requests[title.key]
|
||||||
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
|
val card = remember(title, receipts, state.ratingsFor(title), state.shell.person.key) { title.libraryCard(state) }
|
||||||
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
|
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
|
||||||
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
|
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ class DiscoverState(
|
||||||
private val onPush: (String) -> Unit = {},
|
private val onPush: (String) -> Unit = {},
|
||||||
private val onPop: () -> Unit = {},
|
private val onPop: () -> Unit = {},
|
||||||
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
|
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
|
||||||
|
private val requesters: List<ShellPerson> = shell.people,
|
||||||
|
private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null,
|
||||||
) : ShellContentFocus {
|
) : ShellContentFocus {
|
||||||
var snapshot by mutableStateOf(initial)
|
var snapshot by mutableStateOf(initial)
|
||||||
private set
|
private set
|
||||||
|
|
@ -46,6 +48,7 @@ class DiscoverState(
|
||||||
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
|
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
|
||||||
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
|
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
|
||||||
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
|
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
|
||||||
|
private val ratingValues = mutableStateMapOf<String, List<DiscoverRating>>()
|
||||||
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
|
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
|
||||||
private var revision by mutableStateOf(0)
|
private var revision by mutableStateOf(0)
|
||||||
private var cachedRevision = -1
|
private var cachedRevision = -1
|
||||||
|
|
@ -59,11 +62,44 @@ class DiscoverState(
|
||||||
private val dismissalVersions = mutableMapOf<String, Int>()
|
private val dismissalVersions = mutableMapOf<String, Int>()
|
||||||
private var rootFilter = "all"
|
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<DiscoverLane>) { 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<DiscoverRating> = ratingValues[item.key] ?: item.ratings
|
||||||
|
|
||||||
|
fun updateRatings(item: DiscoverTitle, values: List<LibraryRating>) {
|
||||||
|
val ratings = values.map { DiscoverRating(it.source, it.score, it.label) }
|
||||||
|
ratingValues[item.key] = ratings
|
||||||
|
fun replace(source: List<DiscoverTitle>) = source.map { if (it.key == item.key) it.copy(ratings = ratings) else it }
|
||||||
|
snapshot = snapshot.copy(picks = replace(snapshot.picks),
|
||||||
|
lanes = snapshot.lanes.map { it.copy(titles = replace(it.titles), fullTitles = replace(it.fullTitles)) },
|
||||||
|
seeds = snapshot.seeds.map { it.copy(items = replace(it.items)) })
|
||||||
|
title?.takeIf { it.key == item.key }?.let { title = it.copy(ratings = ratings) }
|
||||||
|
revision++
|
||||||
|
}
|
||||||
|
|
||||||
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
|
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
|
||||||
val canRequest: Boolean get() = when (snapshot.status) {
|
val canRequest: Boolean get() = when (snapshot.status) {
|
||||||
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
|
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed
|
||||||
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
|
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
|
||||||
val heroes: List<DiscoverTitle> get() {
|
val heroes: List<DiscoverTitle> get() {
|
||||||
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
|
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
|
||||||
|
|
@ -88,7 +124,7 @@ class DiscoverState(
|
||||||
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
|
||||||
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
|
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
|
||||||
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
|
||||||
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
|
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
|
||||||
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
|
||||||
override val cardKey: String? get() = when {
|
override val cardKey: String? get() = when {
|
||||||
overlay != null || title != null -> null
|
overlay != null || title != null -> null
|
||||||
|
|
@ -127,7 +163,7 @@ class DiscoverState(
|
||||||
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
|
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
|
||||||
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
|
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
snapshot = operations.refresh(); revision++
|
loadLane(lane.id)
|
||||||
if (collection !== grid) return@launch
|
if (collection !== grid) return@launch
|
||||||
val fresh = lanes.firstOrNull { it.id == lane.id }
|
val fresh = lanes.firstOrNull { it.id == lane.id }
|
||||||
if (canRequest && fresh != null) {
|
if (canRequest && fresh != null) {
|
||||||
|
|
@ -142,6 +178,16 @@ class DiscoverState(
|
||||||
}).apply {
|
}).apply {
|
||||||
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
|
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
|
||||||
}
|
}
|
||||||
|
if (onMore != null && lane.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<String, LibraryTitle>(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
|
collection = grid
|
||||||
onPush("discover-see-all/${lane.id}")
|
onPush("discover-see-all/${lane.id}")
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +263,7 @@ class DiscoverState(
|
||||||
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
|
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
|
||||||
val item = lane.titles.getOrNull(column)
|
val item = lane.titles.getOrNull(column)
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh()
|
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) scope.launch { loadLane(lane.id) }
|
||||||
else openCollection(lane)
|
else openCollection(lane)
|
||||||
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
|
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
|
||||||
else openTitle(item)
|
else openTitle(item)
|
||||||
|
|
@ -243,7 +289,8 @@ class DiscoverState(
|
||||||
|
|
||||||
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
|
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
|
||||||
if (!canRequest) { refresh(); return }
|
if (!canRequest) { refresh(); return }
|
||||||
val choice = RequestSheetState(item, shell.person, shell.people, other, phase)
|
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]
|
val previous = requestSheets[item.key to choice.selectedPerson.key]
|
||||||
overlay = DiscoverOverlayState.Request(
|
overlay = DiscoverOverlayState.Request(
|
||||||
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
|
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
|
||||||
|
|
@ -276,7 +323,7 @@ class DiscoverState(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun send(sheet: RequestSheetState, check: Boolean) {
|
private fun send(sheet: RequestSheetState, check: Boolean) {
|
||||||
if (!canRequest || (!check && !sheet.canSubmit)) return
|
if (!requestsEnabled || (!check && !sheet.canSubmit)) return
|
||||||
if (!check) {
|
if (!check) {
|
||||||
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
|
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
|
||||||
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
|
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ fun HomeScreen(
|
||||||
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
|
||||||
}
|
}
|
||||||
val cache = view.home.ratings
|
val cache = view.home.ratings
|
||||||
|
val ratingsReader = LocalScreenRatings.current
|
||||||
val row = snapshot.rows.getOrNull(view.row)
|
val row = snapshot.rows.getOrNull(view.row)
|
||||||
val title = row?.cards?.getOrNull(focus.column)
|
val title = row?.cards?.getOrNull(focus.column)
|
||||||
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
|
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
|
||||||
|
|
@ -87,13 +88,14 @@ fun HomeScreen(
|
||||||
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
|
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
|
||||||
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
|
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
|
||||||
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
|
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
|
||||||
heroRatings = hero.ratings
|
val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) }
|
||||||
cache.save(hero, heroRatings)
|
if (values != null) { heroRatings = values; cache.save(hero, values) }
|
||||||
}
|
}
|
||||||
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
|
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
|
||||||
val saved = cache.read(title)
|
val saved = cache.read(title)
|
||||||
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
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
|
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ fun LibraryScreen(
|
||||||
val focused = grid.focusedTitle
|
val focused = grid.focusedTitle
|
||||||
val count = if (grid.catalogue) items.size else grid.source.size
|
val count = if (grid.catalogue) items.size else grid.source.size
|
||||||
var plateKey by remember { mutableStateOf<String?>(null) }
|
var plateKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
val ratingsReader = LocalScreenRatings.current
|
||||||
DisposableEffect(shell, grid, registerFocus) {
|
DisposableEffect(shell, grid, registerFocus) {
|
||||||
if (registerFocus) shell.contentFocus = grid
|
if (registerFocus) shell.contentFocus = grid
|
||||||
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
|
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
|
||||||
|
|
@ -72,6 +73,11 @@ fun LibraryScreen(
|
||||||
plateKey = null
|
plateKey = null
|
||||||
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
|
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
|
||||||
if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||||
|
if (ratingsReader != null) {
|
||||||
|
val tmdb = focused.tmdbId?.toLongOrNull() ?: focused.id.substringAfter(':', "").toLongOrNull()
|
||||||
|
val ratings = tmdb?.let { ratingsReader.read(it, focused.type, null, null) }
|
||||||
|
if (ratings != null) grid.source = grid.source.map { if (it.id == focused.id) it.copy(ratings = ratings) else it }
|
||||||
|
}
|
||||||
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
|
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
|
||||||
plateKey = focused.id
|
plateKey = focused.id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<LibraryRating>?
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalScreenRatings = compositionLocalOf<ScreenRatings?> { 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",
|
||||||
|
)
|
||||||
|
|
@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
/** Household identity data contains no credentials or server objects. */
|
/** 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. */
|
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
|
||||||
sealed interface ShellAction {
|
sealed interface ShellAction {
|
||||||
|
|
@ -42,10 +42,21 @@ class RememberedView {
|
||||||
|
|
||||||
/** Focus and Back transitions are independent of drawing and server availability. */
|
/** Focus and Back transitions are independent of drawing and server availability. */
|
||||||
class ShellState(
|
class ShellState(
|
||||||
val people: List<ShellPerson>,
|
people: List<ShellPerson>,
|
||||||
initialPerson: String,
|
initialPerson: String,
|
||||||
private val onAction: (ShellAction) -> Unit = {},
|
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<ShellPerson>) {
|
||||||
|
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())
|
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
|
||||||
private set
|
private set
|
||||||
var route by mutableStateOf("home")
|
var route by mutableStateOf("home")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue