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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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(
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue