Wire selected Jellyfin users to Conductarr TV data

This commit is contained in:
justinzeus 2026-11-05 22:55:54 +01:00
parent f53defad90
commit 59c2728d9c
38 changed files with 1879 additions and 110 deletions

View file

@ -5,4 +5,5 @@ import org.justinvisser.wolfskin.design.HomeFixture
import org.justinvisser.wolfskin.design.HomeSnapshot
/** Only debug builds may read the locked prototype household projection. */
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person)
fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? =
if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null

View file

@ -5,4 +5,7 @@
<string name="display_preset_default">Wolfskin Default</string>
<string name="display_preset_compact">Wolfskin Compact</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>

View file

@ -660,6 +660,31 @@ sealed interface AppPreference<Pref, T> {
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 =
AppDestinationPreference<AppPreferences>(
title = R.string.override_user_profile_settings,
@ -1320,6 +1345,8 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.WolfskinScreens,
AppPreference.ConductarrUrl,
*if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(),
AppPreference.ShowClock,
AppPreference.BackdropStylePref,
AppPreference.ShowLogos,

View file

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

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import java.nio.file.Files
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.design.*
/** Real sockets and reopened disk stores prove identity and authentication boundaries. */
class ConductarrSessionTest {
@Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking {
val directory = Files.createTempDirectory("tv-session-").toFile()
try {
FakeConductarr().use { fake ->
ConductarrSession(FileCacheStore(directory)).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
session.authenticate()
val first = session.identity.value!!
assertEquals("shared", first.operator!!.requester)
assertEquals("Moon", first.provider.discover("shared").last().picks.single().title)
session.select("server:mindel", MINDEL_TOKEN, fake.url)
val second = session.identity.value!!
assertNotSame(first.client, second.client)
assertFalse(second.signedIn)
assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty())
session.authenticate()
assertEquals("Mindel", session.identity.value!!.operator!!.name)
val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" }
assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"])
session.select("server:other", SHARED_TOKEN, fake.url)
assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty())
session.select("server:shared", SHARED_TOKEN, fake.url)
assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester)
session.select("server:shared", null, fake.url)
assertNull(session.identity.value)
}
fake.calls.forEach { request ->
assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\""))
listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) }
assertFalse(request.url.toString().contains(SHARED_TOKEN))
}
}
assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) })
} finally { directory.deleteRecursively() }
}
@Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking {
val directory = Files.createTempDirectory("tv-offline-").toFile()
try {
FakeConductarr().use { fake ->
ConductarrSession(FileCacheStore(directory)).use { original ->
original.select("server:shared", SHARED_TOKEN, fake.url)
original.authenticate()
original.identity.value!!.provider.discover("shared").toList()
}
ConductarrSession(FileCacheStore(directory)).use { reopened ->
reopened.select("server:shared", SHARED_TOKEN, fake.url)
fake.status = 503
reopened.authenticate()
assertTrue(reopened.identity.value!!.signedIn)
val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList()
assertEquals("Moon", snapshots.first().picks.single().title)
assertEquals("Moon", snapshots.last().picks.single().title)
assertNotNull(snapshots.last().savedAt)
fake.status = 200
fake.ratingsStatus = 401
val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null)
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() }
}
}

View file

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

View file

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

View file

@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
}
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val facade = mockk<LibraryReadFacade>()
rule.setContent {
val destination = navigation.backStack.last()

View file

@ -112,6 +112,7 @@ class UpstreamContentFocusTest {
val state = shellState()
val model = mockk<WolfskinShellViewModel>(relaxed = true)
every { model.session } returns WolfskinSession().also { it.state = state }
every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
every { model.libraryDestinations } returns emptyMap()
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
every { model.signedInPerson } returns "unavailable"

View file

@ -0,0 +1,621 @@
{
"operator": {
"capabilities": {
"administer": false,
"manage_library": false,
"manage_memberships": false,
"recommendation_profiles": [
"shared"
],
"request_media": true,
"requesters": [
"shared",
"mindel"
],
"watch_users": [
"shared"
]
},
"name": "Shared",
"recommendation_profile": "shared",
"requester": "shared",
"role": "household",
"subject": "jellyfin:shared",
"watch_user": "shared"
},
"profiles": {
"profiles": [
{
"key": "shared",
"name": "Shared"
},
{
"key": "mindel",
"name": "Mindel"
}
],
"default_profile": "shared"
},
"audiences": {
"audiences": [
{
"is_anime": true,
"libraries": [
"Shared Anime"
],
"media_type": "tv",
"owner": "shared",
"users": [
"shared",
"mindel"
]
}
]
},
"feed": {
"profile": {
"key": "shared",
"name": "Shared"
},
"projection": {
"coverage": {
"complete": true,
"excluded": 0,
"matched": 1,
"total": 1,
"unresolved": 0
},
"facts": [],
"genre_id": 0,
"media_type": "all",
"service_id": 0,
"unresolved": []
},
"state": "current",
"outcome": "ranked",
"materialization": "current",
"movies": [
{
"backdrop_path": "/moon-wide.jpg",
"contributions": [
{
"added": false,
"media_type": "movie",
"title": "Solaris",
"tmdb_id": 33,
"watched": true
}
],
"genre_ids": [
35
],
"media_type": "movie",
"overview": "An astronaut reaches the end of his assignment.",
"poster_path": "/moon.jpg",
"score": 4.2,
"title": "Moon",
"tmdb_id": 42,
"tvdb_id": 0,
"year": 2009
}
],
"shows": [],
"expires_at": "2099-11-05T12:00:00Z"
},
"options": {
"genres": [
{
"id": 35,
"name": "Comedy"
},
{
"id": 16,
"name": "Animation"
}
],
"media_type": "movie",
"observations": [],
"region": "NL",
"services": [
{
"id": 8,
"name": "Netflix"
}
],
"state": "observed"
},
"discover": {
"items": [
{
"tmdb_id": 43,
"media_type": "movie",
"title": "Arrival",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
}
],
"media_type": "movie",
"observations": [],
"page": 1,
"query": "",
"source": "general",
"state": "observed",
"total_pages": 2,
"total_results": 24
},
"dismissals": {
"profile": "shared",
"dismissals": [
{
"hidden_at": "2026-11-05T12:00:00Z",
"hidden_by": "shared",
"media_type": "movie",
"poster_path": "/hidden.jpg",
"title": "Hidden film",
"tmdb_id": 99,
"year": 2017
}
]
},
"ratings": {
"ratings": [
{
"source": "rt_critics",
"state": "observed",
"score": 90,
"status": "Certified Fresh"
},
{
"source": "rt_audience",
"state": "observed",
"score": 89,
"status": "Upright"
},
{
"source": "imdb",
"state": "observed",
"score": 7.8,
"votes": 300000
},
{
"source": "tmdb",
"state": "observed",
"score": 7.6,
"votes": 4000
}
]
},
"detail": {
"genres": [
"Comedy"
],
"metadata": {
"genre_state": "observed",
"genres": [
{
"id": 35,
"name": "Comedy"
}
],
"media_type": "movie",
"provider_state": "observed",
"providers": [],
"region": "NL",
"tmdb_id": 42
},
"metadata_state": "observed",
"observations": [],
"ratings": [
{
"source": "rt_critics",
"state": "observed",
"score": 90,
"status": "Certified Fresh"
},
{
"source": "rt_audience",
"state": "observed",
"score": 89,
"status": "Upright"
},
{
"source": "imdb",
"state": "observed",
"score": 7.8,
"votes": 300000
},
{
"source": "tmdb",
"state": "observed",
"score": 7.6,
"votes": 4000
}
],
"runtime": 97,
"seasons": [
{
"number": 1,
"name": "Season 1",
"overview": "A missing child.",
"episode_count": 10,
"recorded_episodes": 0,
"coverage": "none",
"request_state": "none",
"seerr_state": "untracked"
}
],
"state": "observed",
"title": {
"tmdb_id": 42,
"media_type": "movie",
"title": "Moon",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
}
},
"enrichment": {
"genres": [
"Comedy"
],
"metadata": {
"genre_state": "observed",
"genres": [
{
"id": 35,
"name": "Comedy"
}
],
"media_type": "movie",
"provider_state": "observed",
"providers": [],
"region": "NL",
"tmdb_id": 42
},
"metadata_state": "observed",
"observations": [],
"ratings": [
{
"source": "rt_critics",
"state": "observed",
"score": 90,
"status": "Certified Fresh"
},
{
"source": "rt_audience",
"state": "observed",
"score": 89,
"status": "Upright"
},
{
"source": "imdb",
"state": "observed",
"score": 7.8,
"votes": 300000
},
{
"source": "tmdb",
"state": "observed",
"score": 7.6,
"votes": 4000
}
],
"runtime": 97,
"seasons": [
{
"number": 1,
"name": "Season 1",
"overview": "A missing child.",
"episode_count": 10,
"recorded_episodes": 0,
"coverage": "none",
"request_state": "none",
"seerr_state": "untracked"
}
],
"state": "observed",
"title": {
"tmdb_id": 42,
"media_type": "movie",
"title": "Moon",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
}
},
"display": {
"media_type": "movie",
"metadata_state": "observed",
"poster_url": "https://images.example.invalid/moon.jpg",
"title": "Moon",
"tmdb_id": 42
},
"cast": {
"tmdb_id": 42,
"media_type": "movie",
"metadata_state": "observed",
"source": "seerr",
"paging": "unpaged",
"credit_context": "movie-character",
"items": [
{
"performer_id": 216,
"name": "Sam Rockwell",
"character": "Sam Bell",
"credit_id": "cast-216",
"order": 0,
"profile_url": "https://images.example.invalid/sam.jpg"
}
]
},
"season": {
"tmdb_id": 44,
"media_type": "tv",
"metadata_state": "observed",
"season": {
"number": 1,
"name": "Season 1",
"overview": "A missing child.",
"episode_count": 10,
"recorded_episodes": 0,
"coverage": "none",
"request_state": "none",
"seerr_state": "untracked"
},
"episodes": [
{
"number": 1,
"name": "Secrets",
"overview": "A child disappears.",
"recorded": "missing",
"vote_average": 8.1,
"vote_count": 28
}
],
"observations": []
},
"performer": {
"performer_id": 216,
"metadata_state": "observed",
"source": "seerr",
"name": "Sam Rockwell",
"biography": "Actor.",
"also_known_as": []
},
"filmography": {
"performer_id": 216,
"metadata_state": "observed",
"source": "seerr",
"source_paging": "unpaged",
"completeness": "returned-non-adult-cast",
"page": 1,
"page_size": 20,
"items": [
{
"title": {
"tmdb_id": 42,
"media_type": "movie",
"title": "Moon",
"overview": "An astronaut reaches the end of his assignment.",
"poster_url": "https://images.example.invalid/moon.jpg",
"backdrop_url": "https://images.example.invalid/moon-wide.jpg",
"release_date": "2009-06-12",
"held": "unheld",
"libraries": [],
"request_state": "none",
"seerr_state": "untracked",
"genre_ids": [
35
]
},
"credits": [
{
"credit_id": "cast-216",
"character": "Sam Bell"
}
],
"visibility_state": "unknown",
"users": []
}
],
"observations": []
},
"activity": {
"items": [
{
"actor": {
"name": "Shared",
"requester": "shared",
"role": "household"
},
"filed_by_you": true,
"id": "request:7",
"media_type": "movie",
"on_behalf": true,
"requester": {
"key": "mindel",
"name": "Mindel"
},
"seasons": [],
"stages": [
{
"name": "request",
"source": "conductarr",
"state": "done",
"summary": "Request accepted"
},
{
"name": "acquisition",
"source": "sonarr",
"state": "in_progress",
"summary": "Downloading"
},
{
"name": "visibility",
"source": "jellyfin",
"state": "none",
"summary": "Not yet"
},
{
"name": "playback",
"source": "jellyfin",
"state": "none",
"summary": "Not checked"
}
],
"title": "Moon",
"tmdb_id": 42,
"blocking_stage": "acquisition"
}
],
"requester": "all",
"sources": [],
"truncated": false
},
"readiness": {
"tmdb_id": 42,
"media_type": "movie",
"viewer": {
"key": "mindel",
"name": "Mindel"
},
"stages": [
{
"name": "request",
"source": "conductarr",
"state": "done",
"summary": "Request accepted"
},
{
"name": "acquisition",
"source": "sonarr",
"state": "in_progress",
"summary": "Downloading"
},
{
"name": "visibility",
"source": "jellyfin",
"state": "none",
"summary": "Not yet"
},
{
"name": "playback",
"source": "jellyfin",
"state": "none",
"summary": "Not checked"
}
],
"seerr": {
"state": "observed",
"requests": []
},
"coverage": [],
"open_link": {
"state": "missing"
},
"sources": []
},
"requests": {
"requests": [
{
"actor": {
"name": "Shared",
"requester": "shared",
"role": "household"
},
"attempts": 1,
"coverage": [],
"created_at": "2026-11-05T12:00:00Z",
"id": 7,
"media_type": "movie",
"membership": {
"status": "added"
},
"on_behalf": true,
"requester": "mindel",
"requester_username": "Mindel",
"seasons": null,
"seerr": {
"outcome": "requested",
"request_id": 9
},
"source": "recommendation",
"state": "settled",
"tmdb_id": 42,
"updated_at": "2026-11-05T12:00:00Z"
}
]
},
"receipt": {
"actor": {
"name": "Shared",
"requester": "shared",
"role": "household"
},
"attempts": 1,
"coverage": [],
"created_at": "2026-11-05T12:00:00Z",
"id": 7,
"media_type": "movie",
"membership": {
"status": "added"
},
"on_behalf": true,
"requester": "mindel",
"requester_username": "Mindel",
"seasons": null,
"seerr": {
"outcome": "requested",
"request_id": 9
},
"source": "recommendation",
"state": "settled",
"tmdb_id": 42,
"updated_at": "2026-11-05T12:00:00Z"
},
"hide": {
"created": true,
"dismissal": {
"hidden_at": "2026-11-05T12:00:00Z",
"hidden_by": "shared",
"media_type": "movie",
"poster_path": "/hidden.jpg",
"title": "Moon",
"tmdb_id": 42,
"year": 2017
},
"materialization": "current",
"profile": "shared"
},
"restore": {
"media_type": "movie",
"profile": "shared",
"restored": true,
"tmdb_id": 42
}
}

View file

@ -0,0 +1,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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.flow.Flow
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.conductarr.model.*
/** Titles can adopt these reads without importing transport or owning another login. */
interface ConductarrTitleProvider {
fun detail(tmdb: Long, media: MediaType): Flow<ReadResult<Detail>>
fun cast(tmdb: Long, media: MediaType): Flow<ReadResult<TitleCast>>
fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow<ReadResult<Page>>
fun season(tmdb: Long, number: Int): Flow<ReadResult<SeasonDetail>>
fun performer(id: Long): Flow<ReadResult<PerformerDetail>>
fun filmography(id: Long, page: Int = 1): Flow<ReadResult<FilmographyPage>>
fun activity(tmdb: Long, media: MediaType, viewer: String): Flow<ReadResult<ViewerReadiness>>
fun requests(tmdb: Long, media: MediaType): Flow<ReadResult<TitleRequestsBody>>
fun enrichment(tmdb: Long, media: MediaType): Flow<ReadResult<Enrichment>>
fun display(tmdb: Long, media: MediaType): Flow<ReadResult<Display>>
}
/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */
internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider {
override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN)
override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN)
override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN)
override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN)
override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN)
override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN)
override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN)
override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE)
override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN)
override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN)
}

View file

@ -2,54 +2,105 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.github.damontecres.wholphin.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.DiscoverFixture
import org.justinvisser.wolfskin.design.DiscoverOperations
import org.justinvisser.wolfskin.design.DiscoverRequest
import org.justinvisser.wolfskin.design.DiscoverRequestResult
import org.justinvisser.wolfskin.design.DiscoverSnapshot
import org.justinvisser.wolfskin.design.DiscoverState
import org.justinvisser.wolfskin.design.DiscoverTitle
import org.justinvisser.wolfskin.design.DiscoverStatus
import org.justinvisser.wolfskin.design.RequestPhase
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.design.*
internal val LocalDiscoverState = compositionLocalOf<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
internal fun rememberDiscoverState(
shell: ShellState,
push: (String) -> Unit,
pop: () -> Unit,
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
identity: ConductarrIdentity? = null,
fixtures: Boolean = BuildConfig.DEBUG,
retrySignIn: suspend () -> Unit = {},
): DiscoverState {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) }
LaunchedEffect(shell, shell.person.key) {
if (BuildConfig.DEBUG) {
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
val refresh: suspend () -> DiscoverSnapshot = {
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull() ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
else { retrySignIn(); DiscoverSnapshot() }
}
val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) {
val operations = identity?.takeIf { it.signedIn }?.let { ConductarrDiscoverOperations(it.client, refresh,
profile = it.operator!!.recommendationProfile,
allowedRequesters = it.operator.capabilities.requesters.orEmpty().toSet(), canRequest = it.operator.capabilities.requestMedia) }
?: SignedOutDiscoverOperations(refresh)
lateinit var loaded: DiscoverState
loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop,
{ service, type -> servicePages["${service.id}:$type"].orEmpty() },
requesters = if (fixtures) shell.people else shell.people.filter { it.key in identity?.operator?.capabilities?.requesters.orEmpty() },
onMore = if (fixtures || identity?.signedIn != true) null else { lane ->
val service = loaded.service
if (service == null) identity.provider.more(lane)
else {
servicePages["${service.id}:${loaded.filter}"] = identity.provider.service(service, loaded.filter, more = lane)
loaded.snapshot.lanes
}
})
loaded
}
var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf<DiscoverState?>(null) }
LaunchedEffect(state, fixtures) {
if (fixtures) {
val fixture = loadFixture(context)
val snapshot = fixture.snapshot(shell.person.key)
val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
state = loaded
if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
fixtureState = loaded
if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
} else if (identity?.signedIn == true) {
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it) }
}
}
LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) }
return state
LaunchedEffect(state.service?.id, state.filter, state) {
val service = state.service ?: return@LaunchedEffect
if (!fixtures && identity?.signedIn == true) {
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter)
state.invalidateLanes()
}
}
LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) {
if (!fixtures && identity?.signedIn == true) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) ->
val service = state.service
if (service == null) {
state.updateLanes(identity.provider.readAhead(lane))
val focused = state.focusedLane
if (focused != null && focused.tail == DiscoverTail.SeeAll && column >= focused.titles.size - WolfskinTokens.Behaviour.nextPageCards) {
state.updateLanes(identity.provider.more(focused.id))
}
} else {
val index = state.lanes.indexOfFirst { it.id == lane }
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2)
state.invalidateLanes()
}
}
}
LaunchedEffect(state.title?.key, state) {
val title = state.title ?: return@LaunchedEffect
if (!fixtures && identity?.signedIn == true) {
val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV
identity.provider.detail(title.tmdb, media).collect { result ->
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size))
}
}
}
val result = if (fixtures) fixtureState ?: state else state
LaunchedEffect(shell.route, result) { result.enterRoute(shell.route) }
LaunchedEffect(result.snapshot.savedAt, shell.route) { if (shell.route.startsWith("discover")) shell.view.savedAt = result.snapshot.savedAt }
return result
}
private fun readDiscoverFixture(context: Context): DiscoverFixture {
@ -57,7 +108,6 @@ private fun readDiscoverFixture(context: Context): DiscoverFixture {
return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }
}
/** Fixture writes are in-memory only and cannot reach a Conductarr client. */
private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations {
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor)
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
@ -66,10 +116,10 @@ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, pr
override suspend fun refresh() = snapshot
}
private object SignedOutDiscoverOperations : DiscoverOperations {
private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations {
override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused)
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
override suspend fun hide(profile: String, title: DiscoverTitle) = false
override suspend fun restore(profile: String, title: DiscoverTitle) = false
override suspend fun refresh() = DiscoverSnapshot()
override suspend fun refresh() = retry()
}

View file

@ -37,25 +37,35 @@ class WolfskinHomeViewModel @Inject constructor(
private val cache: HomeLibraryCache,
private val navigation: NavigationManager,
private val seeAll: WolfskinSeeAllLists,
private val conductarr: ConductarrSession,
) : ViewModel() {
val conductarrState get() = conductarr.identity
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
private set
var toast by mutableStateOf<String?>(null)
private set
private var catalogueJob: kotlinx.coroutines.Job? = null
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
catalogueJob?.cancel()
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
if (retry && conductarr.identity.value?.signedIn != true) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
conductarr.authenticate()
}
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
if (person != user.name.orEmpty().lowercase()) {
if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
return
}
val scope = "${server.id}:${user.id}"
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
var libraryCopy = saved?.first ?: HomeLibrary()
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
try {
val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis()
libraryCopy = library
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
} catch (exception: Exception) {
@ -64,6 +74,19 @@ class WolfskinHomeViewModel @Inject constructor(
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
}
val identity = conductarr.identity.value
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
if (conductarr.identity.value?.client !== identity.client) return@collect
val status = when (catalogue.status) {
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
else -> if (catalogue.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
}
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogue.homeRows(), libraryAvailable = snapshot.jellyfinAvailable),
status, snapshot.jellyfinAvailable, catalogue.savedAt ?: snapshot.savedAt)
}
}
}
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
@ -123,9 +146,11 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */
@Composable
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
val identity by model.conductarrState.collectAsState()
LaunchedEffect(shell.person.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) }
Box(modifier) {
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot
HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
model.toast?.let { message ->
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
}

View file

@ -8,7 +8,7 @@ import org.justinvisser.wolfskin.design.ShellPerson
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
if (BuildConfig.DEBUG) {
if (ConductarrPreference.fixtures) {
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
val profiles = json.getJSONArray("profiles")
return List(profiles.length()) { index ->
@ -18,6 +18,6 @@ fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPers
}
return saved.map { user ->
val name = user.name.orEmpty()
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false)
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString())
}
}

View file

@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor(
private val backdrop: BackdropService,
val libraries: LibraryReadFacade,
val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession,
) : ViewModel() {
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
private val requestsSnapshot by lazy { readShellRequests(context) }
@ -80,17 +81,28 @@ class WolfskinShellViewModel @Inject constructor(
private set
var signedInPerson by mutableStateOf("")
private set
private var identityJob: kotlinx.coroutines.Job? = null
private var fixtureMode: Boolean? = null
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
WolfskinScreenPreference.initialize(context)
signedInPerson = user.name.orEmpty().lowercase()
if (session.state == null || session.serverId != server.id.toString()) {
ConductarrPreference.initialize(context)
identityJob?.cancel()
libraries.audiences = null
if (ConductarrPreference.fixtures) conductarr.close()
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl)
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
session.serverId = server.id.toString()
fixtureMode = ConductarrPreference.fixtures
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
}
session.onAction = { action -> handleAction(action, server, user) }
identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user)
}
try {
libraryDestinations = libraries.readDestinations(user.id)
} catch (exception: Exception) {
@ -99,13 +111,44 @@ class WolfskinShellViewModel @Inject constructor(
}
}
private suspend fun loadHousehold(user: JellyfinUser) {
conductarr.authenticate()
val identity = conductarr.identity.value ?: return
if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return
val profiles = identity.provider.profiles()?.profiles.orEmpty()
val audiences = identity.provider.audiences()
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
libraries.audiences = audiences.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
val updated = session.state?.people.orEmpty().map { person ->
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
val current = person.jellyfinUserId == user.id.toString()
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
hasAnime = audiences.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) })
}
session.state?.updatePeople(updated)
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
}
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
loadHousehold(user)
}
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
/** Requests are person-scoped; a failed refresh retains the provider's saved observations. */
fun refreshRequests() {
viewModelScope.launch {
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
session.state?.requests?.update(snapshot)
if (ConductarrPreference.fixtures) {
session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot })
} else {
val identity = conductarr.identity.value
if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
else identity.provider.requests().collect { snapshot ->
if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot)
}
}
}
}
@ -115,18 +158,23 @@ class WolfskinShellViewModel @Inject constructor(
ShellAction.Pop -> navigation.goBack()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user)
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
ShellAction.RetryRequests -> refreshRequests()
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
ShellAction.RetryRequests -> viewModelScope.launch {
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
refreshRequests()
}
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
}
}
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
navigation.replace(Destination.Home())
if (person.key == user.name.orEmpty().lowercase()) return
if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return
conductarr.close()
libraries.audiences = null
viewModelScope.launch {
val saved = withContext(Dispatchers.IO) {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) }
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
}
if (saved == null || saved.isProtected) {
signedInPerson = ""
@ -163,7 +211,14 @@ fun WolfskinApp(
modifier: Modifier = Modifier,
viewModel: WolfskinShellViewModel = hiltViewModel(),
) {
LaunchedEffect(server.id, user.id) { viewModel.load(server, user) }
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) {
if (!ConductarrPreference.fixtures) {
if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
else viewModel.refreshRequests()
}
}
val loading = remember(user.id) {
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
}
@ -172,10 +227,16 @@ fun WolfskinApp(
state,
{ route -> navigationManager.navigateTo(Destination.Wolfskin(route)) },
navigationManager::goBack,
identity = identity,
fixtures = ConductarrPreference.fixtures,
retrySignIn = {
viewModel.retryConductarr(server, user)
},
)
RequestsRefresh(state, viewModel::refreshRequests)
WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
Box(modifier) {
val current = navigationManager.backStack.last()
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {

View file

@ -52,7 +52,7 @@ fun WolfskinLibraryContent(
) {
val view = shell.rememberedView(route)
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/") }
}
}
@ -74,7 +74,7 @@ fun WolfskinLibraryContent(
return
}
LaunchedEffect(grid, grid.refreshRevision, fixture) {
if (route in LIBRARY_DESTINATIONS && (!BuildConfig.DEBUG || fixture != null)) {
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
val wasLoading = grid.status == LibraryStatus.Loading
if (fixture != null) {
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
@ -89,7 +89,7 @@ fun WolfskinLibraryContent(
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
}
}
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view)
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view)
}
val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime")

View file

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

View file

@ -16,18 +16,27 @@ import java.util.UUID
import javax.inject.Inject
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
@javax.inject.Singleton
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
var audiences: List<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> {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
return buildMap {
libraries.forEach { library ->
val name = library.name.orEmpty().lowercase()
val key = when {
"anime" in name -> "anime"
library.collectionType == CollectionType.MOVIES -> "movies"
library.collectionType == CollectionType.TVSHOWS -> "shows"
else -> null
}
val key = destination(library.name.orEmpty(), library.collectionType)
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
}
}
@ -36,13 +45,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
/** A destination spans every matching accessible library; containers never become titles. */
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
val anime = library.name.orEmpty().contains("anime", ignoreCase = true)
when (destination) {
"anime" -> anime
"movies" -> !anime && library.collectionType == CollectionType.MOVIES
"shows" -> !anime && library.collectionType == CollectionType.TVSHOWS
else -> false
}
destination(library.name.orEmpty(), library.collectionType) == destination
}
val titles = linkedMapOf<UUID, LibraryTitle>()
for (library in libraries) {