From f53defad908b3139c271aca603854851dc8e8309 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 22:55:25 +0100 Subject: [PATCH 1/5] Update Conductarr TV contracts and ratings reads --- conductarr-client/README.md | 16 +- conductarr-client/openapi/commit.sha | 2 +- conductarr-client/openapi/openapi.json | 468 +++++++++++++++++- .../wolfskin/conductarr/CachePolicy.kt | 2 +- .../wolfskin/conductarr/TvRoutes.kt | 8 +- .../wolfskin/conductarr/model/Display.kt | 24 + .../wolfskin/conductarr/model/Episode.kt | 2 + .../wolfskin/conductarr/model/TitleRatings.kt | 11 + .../wolfskin/conductarr/CacheTest.kt | 1 + .../wolfskin/conductarr/ClientTest.kt | 2 +- .../wolfskin/conductarr/ContractTest.kt | 16 +- .../wolfskin/conductarr/ModelContracts.kt | 2 + .../src/test/resources/model-contracts.json | 82 +++ 13 files changed, 600 insertions(+), 36 deletions(-) create mode 100644 conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt create mode 100644 conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt diff --git a/conductarr-client/README.md b/conductarr-client/README.md index 573929a2..a589b36d 100644 --- a/conductarr-client/README.md +++ b/conductarr-client/README.md @@ -1,10 +1,10 @@ # Conductarr TV client -A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app depends on this module; no screen, navigation, login flow or production service is changed. +A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app supplies the currently selected Jellyfin login through its person-scoped `ConductarrSession`. ## Pinned contract and generator decision -`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `481492d3a8a0651a89901de548cd511218c0562e`. `openapi/commit.sha` records the source. It was read with `git show :openapi.json` from the Conductarr clone, not downloaded from a running service. +`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `7e8f3f0a1c9a88d67b2a9518c1161db84ade531a`. `openapi/commit.sha` records the source. It was read with `git show :openapi.json` from the Conductarr clone, not downloaded from a running service. The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation. @@ -18,8 +18,8 @@ Thus the generator does **not** handle every construct in this pinned document. `ContractTest` checks: -- All 20 TV method/path pairs against the pinned paths, including response and mutation body schemas. -- All 68 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. +- All 22 TV method/path pairs against the pinned paths, including response and mutation body schemas. +- All 70 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. - A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority. When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema. @@ -28,7 +28,7 @@ When updating the pin, update the models and reviewed schema snapshot together. Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token=""`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved. -`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. +`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/ratings/display/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. `client.read(call, trigger)` is a cold `Flow>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists. @@ -49,9 +49,9 @@ Cache keys partition by normalized server URL, credential digest, endpoint, path | Feed | Server `expires_at`; conditional ETag revalidation | | Catalogue options | `FOREGROUND` refreshes | | Discovery pages | 30 minutes per parameterized lane/page | -| Enrichment ratings | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | -| Title, cast, related, performer, filmography, dismissals, readiness | `OPEN` refreshes | -| Seasons | `OPEN` on season focus; retained per season, 300 entries | +| Ratings-only read | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | +| Title, cast, related, enrichment, display, performer, filmography, dismissals, readiness | `OPEN` refreshes | +| Seasons | 6 hours for episode scores; `OPEN` refreshes on title-season focus; retained per season, 300 entries | | Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` | | Title requests and request mutations | No cache | diff --git a/conductarr-client/openapi/commit.sha b/conductarr-client/openapi/commit.sha index 67a3f347..9944c1d5 100644 --- a/conductarr-client/openapi/commit.sha +++ b/conductarr-client/openapi/commit.sha @@ -1 +1 @@ -481492d3a8a0651a89901de548cd511218c0562e +7e8f3f0a1c9a88d67b2a9518c1161db84ade531a diff --git a/conductarr-client/openapi/openapi.json b/conductarr-client/openapi/openapi.json index 6a467051..5486db7c 100644 --- a/conductarr-client/openapi/openapi.json +++ b/conductarr-client/openapi/openapi.json @@ -1097,6 +1097,16 @@ "unknown" ], "type": "string" + }, + "vote_average": { + "description": "Provider episode score on a 0–10 scale; absence is not zero.", + "format": "double", + "type": "number" + }, + "vote_count": { + "description": "Provider episode vote count; explicit zero is retained.", + "format": "int64", + "type": "integer" } }, "required": [ @@ -7058,6 +7068,33 @@ ], "type": "object" }, + "TitleRatings": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleRatings.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "ratings" + ], + "type": "object" + }, "TitleRequestsBody": { "additionalProperties": false, "properties": { @@ -8299,7 +8336,7 @@ "paths": { "/api/v1/activity": { "get": { - "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-activity", "parameters": [ { @@ -8311,6 +8348,14 @@ "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", "type": "string" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -8322,7 +8367,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -8375,7 +8439,7 @@ }, "/api/v1/activity/titles/{tmdb}/{type}": { "get": { - "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-activity-title", "parameters": [ { @@ -8409,6 +8473,14 @@ "description": "Household member to read readiness for. Defaults to the signed-in operator", "type": "string" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -8420,7 +8492,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -9516,7 +9607,7 @@ }, "/catalogue/discover": { "get": { - "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-discover", "parameters": [ { @@ -9584,6 +9675,14 @@ ], "type": "string" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -9595,7 +9694,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -9648,7 +9766,7 @@ }, "/catalogue/options": { "get": { - "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-options", "parameters": [ { @@ -9663,6 +9781,14 @@ ], "type": "string" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -9674,7 +9800,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -9727,7 +9872,7 @@ }, "/catalogue/performers/{performer}": { "get": { - "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-performer", "parameters": [ { @@ -9739,6 +9884,14 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -9750,7 +9903,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -9803,7 +9975,7 @@ }, "/catalogue/performers/{performer}/filmography": { "get": { - "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-filmography", "parameters": [ { @@ -9839,6 +10011,14 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -9850,7 +10030,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -10006,7 +10205,7 @@ }, "/catalogue/titles/{tmdb}/{type}": { "get": { - "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-title", "parameters": [ { @@ -10030,6 +10229,14 @@ ], "type": "string" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -10041,7 +10248,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -10094,7 +10320,7 @@ }, "/catalogue/titles/{tmdb}/{type}/cast": { "get": { - "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-cast", "parameters": [ { @@ -10118,6 +10344,14 @@ ], "type": "string" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -10129,7 +10363,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -10444,9 +10697,97 @@ "summary": "Retained title genres and NL streaming offers" } }, + "/catalogue/titles/{tmdb}/{type}/ratings": { + "get": { + "description": "Authenticated ratings-only display read with the same source observations as enrichment. Server cache: 6 hours for positive results, 1 minute for missing results, at most 300 typed title identities; concurrent identical reads share a fetch. Provider failures are not cached. Expired entries are never served. Reads no Arr, files, mirrors, memberships or watch state and never authorizes a title action. Enrichment and committed title reads remain fresh. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "operationId": "get-catalogue-ratings", + "parameters": [ + { + "in": "path", + "name": "tmdb", + "required": true, + "schema": { + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + { + "in": "path", + "name": "type", + "required": true, + "schema": { + "enum": [ + "tv", + "movie" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TitleRatings" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Authentik sign-in required" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Operator or target is not permitted" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Identity verification or operation unavailable" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authentikSession": [] + } + ], + "summary": "Cached title source ratings" + } + }, "/catalogue/titles/{tmdb}/{type}/related": { "get": { - "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-related", "parameters": [ { @@ -10482,6 +10823,14 @@ "minimum": 1, "type": "integer" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -10493,7 +10842,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -10546,7 +10914,7 @@ }, "/catalogue/tv/{tmdb}/seasons/{season}": { "get": { - "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-catalogue-season", "parameters": [ { @@ -10568,6 +10936,14 @@ "minimum": 0, "type": "integer" } + }, + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } } ], "responses": { @@ -10579,7 +10955,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { @@ -11726,8 +12121,18 @@ }, "/curation/library-audiences": { "get": { - "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", + "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", "operationId": "get-library-audiences", + "parameters": [ + { + "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -11737,7 +12142,26 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", + "headers": { + "ETag": { + "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", + "schema": { + "type": "string" + } + } + } }, "401": { "content": { diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt index 5674638c..59e0d163 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt @@ -13,7 +13,7 @@ enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity: DISCOVERY(30 * 60 * 1000L), RATINGS(6 * 60 * 60 * 1000L, 300), ON_OPEN(Long.MAX_VALUE), - SEASON(Long.MAX_VALUE, 300), + SEASON(6 * 60 * 60 * 1000L, 300), ACTIVITY(30 * 1000L), NO_CACHE(0), ; diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt index f567fee7..e2f3bd03 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt @@ -52,6 +52,8 @@ object TvRoutes { private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast") private val related = RouteContract("GET", "${title.path}/related", "Page") private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment") + private val ratings = RouteContract("GET", "${title.path}/ratings", "TitleRatings") + private val display = RouteContract("GET", "${title.path}/display", "Display") private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail") private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail") private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage") @@ -63,7 +65,7 @@ object TvRoutes { val contracts: List = listOf( operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast, - related, enrichment, season, performer, filmography, activity, readiness, requests, request, audiences, + related, enrichment, ratings, display, season, performer, filmography, activity, readiness, requests, request, audiences, ) fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH) @@ -97,7 +99,9 @@ object TvRoutes { fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) + fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) + fun ratings(tmdb: Long, media: MediaType) = ReadCall(ratings, TitleRatings.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) + fun display(tmdb: Long, media: MediaType) = ReadCall(display, Display.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall( related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN, ) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt new file mode 100644 index 00000000..2837856e --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Display wire contract from the pinned Conductarr schema. */ +@Serializable +data class Display( + @SerialName("media_type") val mediaType: MediaType, + @SerialName("metadata_state") val metadataState: MetadataState, + @SerialName("poster_url") val posterUrl: String? = null, + @SerialName("title") val title: String? = null, + @SerialName("tmdb_id") val tmdbId: Long, +) { + @Serializable + enum class MediaType { @SerialName("movie") MOVIE, @SerialName("tv") TV } + @Serializable + enum class MetadataState { + @SerialName("observed") OBSERVED, + @SerialName("missing") MISSING, + @SerialName("unavailable") UNAVAILABLE, + } +} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt index 212a0b87..494d0094 100644 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt @@ -12,6 +12,8 @@ data class Episode( @SerialName("number") val number: Long, @SerialName("overview") val overview: String, @SerialName("recorded") val recorded: Recorded, + @SerialName("vote_average") val voteAverage: Double? = null, + @SerialName("vote_count") val voteCount: Long? = null, ) { /** Wire values for recorded. */ @Serializable diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt new file mode 100644 index 00000000..fa754de0 --- /dev/null +++ b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.conductarr.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** TitleRatings wire contract from the pinned Conductarr schema. */ +@Serializable +data class TitleRatings( + @SerialName("ratings") val ratings: List?, +) diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt index e5a18435..4e51ba98 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt @@ -13,6 +13,7 @@ class CacheTest { Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L), Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L), Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L), + Triple(CachePolicy.SEASON, ReadTrigger.IF_STALE, 21_600_000L), Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L), ) for ((policy, trigger, lifetime) in rows) { diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt index 3d2143a4..937543a9 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt @@ -121,7 +121,7 @@ class ClientTest { @Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking { val client = client() - val call = TvRoutes.enrichment(42, MediaType.MOVIE) + val call = TvRoutes.ratings(42, MediaType.MOVIE) server.enqueue(response("missing", 404)) assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) now += 6 * 60 * 60 * 1000L - 1 diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt index b93be09c..fcebb09f 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt @@ -29,7 +29,7 @@ class ContractTest { for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name]) } - @Test fun `all twenty TV operations exist with their typed request and response contracts`() { + @Test fun `all twenty two TV operations exist with their typed request and response contracts`() { val expected = setOf( "GET /api/v1/operator", "GET /api/v1/recommendations/profiles", "GET /api/v1/recommendations/profiles/{profile}/feed", @@ -39,6 +39,7 @@ class ContractTest { "GET /catalogue/options", "GET /catalogue/discover", "GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast", "GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment", + "GET /catalogue/titles/{tmdb}/{type}/ratings", "GET /catalogue/titles/{tmdb}/{type}/display", "GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}", "GET /catalogue/performers/{performer}/filmography", "GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}", @@ -47,6 +48,19 @@ class ContractTest { ) assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet()) assertEquals(expected.size, TvRoutes.contracts.size) + val operations = setOf( + "get-cockpit-operator", "get-recommendation-profiles", "get-library-audiences", + "get-recommendation-feed", "get-catalogue-options", "get-catalogue-discover", + "get-recommendation-dismissals", "post-recommendation-dismissal", "delete-recommendation-dismissal", + "get-catalogue-title", "get-catalogue-cast", "get-catalogue-related", "get-catalogue-season", + "get-catalogue-enrichment", "get-catalogue-display", "get-activity-title", "get-title-requests", + "get-catalogue-performer", "get-catalogue-filmography", "post-request-intent", "get-activity", + "get-catalogue-ratings", + ) + assertEquals(operations, TvRoutes.contracts.map { route -> + document.getValue("paths").jsonObject.getValue(route.path).jsonObject + .getValue(route.method.lowercase()).jsonObject.getValue("operationId").jsonPrimitive.content + }.toSet()) val paths = document["paths"]!!.jsonObject for (route in TvRoutes.contracts) { val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt index ea1272ef..a7384768 100644 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt +++ b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt @@ -13,6 +13,7 @@ internal val modelSerializers: List> = listOf( CastMember.serializer(), Detail.serializer(), Discovery.serializer(), + Display.serializer(), Enrichment.serializer(), Episode.serializer(), EpisodeFileChip.serializer(), @@ -72,6 +73,7 @@ internal val modelSerializers: List> = listOf( TitleCast.serializer(), TitleMetadata.serializer(), TitleRequestsBody.serializer(), + TitleRatings.serializer(), TitleVersion.serializer(), ViewerReadiness.serializer(), ) diff --git a/conductarr-client/src/test/resources/model-contracts.json b/conductarr-client/src/test/resources/model-contracts.json index 659c72cb..41a0b411 100644 --- a/conductarr-client/src/test/resources/model-contracts.json +++ b/conductarr-client/src/test/resources/model-contracts.json @@ -444,6 +444,16 @@ "unknown" ], "type": "string" + }, + "vote_average": { + "description": "Provider episode score on a 0\u201310 scale; absence is not zero.", + "format": "double", + "type": "number" + }, + "vote_count": { + "description": "Provider episode vote count; explicit zero is retained.", + "format": "int64", + "type": "integer" } }, "required": [ @@ -3169,5 +3179,77 @@ "sources" ], "type": "object" + }, + "Display": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/Display.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "media_type": { + "enum": [ + "movie", + "tv" + ], + "type": "string" + }, + "metadata_state": { + "enum": [ + "observed", + "missing", + "unavailable" + ], + "type": "string" + }, + "poster_url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tmdb_id": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "tmdb_id", + "media_type", + "metadata_state" + ], + "type": "object" + }, + "TitleRatings": { + "additionalProperties": false, + "properties": { + "$schema": { + "description": "A URL to the JSON Schema for this object.", + "examples": [ + "https://example.com/schemas/TitleRatings.json" + ], + "format": "uri", + "readOnly": true, + "type": "string" + }, + "ratings": { + "items": { + "$ref": "#/components/schemas/Rating" + }, + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "ratings" + ], + "type": "object" } } -- 2.49.1 From 59c2728d9c45ee912fb18fcf055490bb6d283b32 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 22:55:54 +0100 Subject: [PATCH 2/5] Wire selected Jellyfin users to Conductarr TV data --- app/build.gradle.kts | 2 + .../org/justinvisser/wolfskin/tv/DebugHome.kt | 3 +- app/src/default/res/values/strings.xml | 3 + .../wholphin/preferences/AppPreference.kt | 27 + .../wolfskin/tv/ConductarrProjectionTest.kt | 108 +++ .../wolfskin/tv/ConductarrSessionTest.kt | 85 +++ .../wolfskin/tv/ConductarrSurfaceTest.kt | 82 +++ .../wolfskin/tv/FakeConductarr.kt | 78 +++ .../wolfskin/tv/HomeSeeAllNavigationTest.kt | 2 +- .../wolfskin/tv/UpstreamContentFocusTest.kt | 1 + app/src/test/resources/conductarr/wire.json | 621 ++++++++++++++++++ .../wolfskin/tv/ConductarrCatalogue.kt | 105 +++ .../tv/ConductarrDiscoverOperations.kt | 53 +- .../wolfskin/tv/ConductarrPreference.kt | 55 ++ .../wolfskin/tv/ConductarrProjection.kt | 57 ++ .../wolfskin/tv/ConductarrProvider.kt | 117 ++++ .../wolfskin/tv/ConductarrRatings.kt | 37 ++ .../wolfskin/tv/ConductarrSession.kt | 80 +++ .../wolfskin/tv/ConductarrTitleProvider.kt | 34 + .../justinvisser/wolfskin/tv/DiscoverHost.kt | 110 +++- .../wolfskin/tv/HomeDestination.kt | 31 +- .../justinvisser/wolfskin/tv/ShellPeople.kt | 4 +- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 83 ++- .../wolfskin/tv/WolfskinLibrary.kt | 6 +- .../wolfskin/tv/read/HomeReadFacade.kt | 31 +- .../wolfskin/tv/read/LibraryReadFacade.kt | 31 +- .../wolfskin/design/ComingInSheet.kt | 11 +- .../wolfskin/design/ComingInState.kt | 6 + .../wolfskin/design/DiscoverCardProjection.kt | 2 +- .../wolfskin/design/DiscoverLaneProjection.kt | 2 +- .../wolfskin/design/DiscoverModel.kt | 3 + .../wolfskin/design/DiscoverScreen.kt | 8 + .../wolfskin/design/DiscoverShelf.kt | 8 +- .../wolfskin/design/DiscoverState.kt | 57 +- .../wolfskin/design/HomeScreen.kt | 8 +- .../wolfskin/design/LibraryScreen.kt | 6 + .../wolfskin/design/ScreenRatings.kt | 17 + .../wolfskin/design/ShellState.kt | 15 +- 38 files changed, 1879 insertions(+), 110 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt create mode 100644 app/src/test/resources/conductarr/wire.json create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30bff204..49c0c66d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,7 @@ configure { testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) + buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"") } signingConfigs { @@ -448,6 +449,7 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) testImplementation(libs.kotlinx.coroutines.test) + testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) diff --git a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt index 691f67c4..8eb4a397 100644 --- a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt +++ b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt @@ -5,4 +5,5 @@ import org.justinvisser.wolfskin.design.HomeFixture import org.justinvisser.wolfskin.design.HomeSnapshot /** Only debug builds may read the locked prototype household projection. */ -fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = HomeFixture(context).snapshot(person) +fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = + if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml index dd5ba542..c76d6792 100644 --- a/app/src/default/res/values/strings.xml +++ b/app/src/default/res/values/strings.xml @@ -5,4 +5,7 @@ Wolfskin Default Wolfskin Compact Wolfskin updated to %s + Conductarr server URL + Advanced override. HTTPS required; blank restores the build default. + Wolfskin preview fixtures (debug only) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index b6d9426c..5d43b394 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -660,6 +660,31 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val ConductarrUrl = + AppStringPreference( + title = R.string.wolfskin_conductarr_url, + defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL, + getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl }, + setter = { prefs, value -> + org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value) + prefs + }, + summary = R.string.wolfskin_conductarr_url_summary, + ) + + val WolfskinFixtures = + AppSwitchPreference( + title = R.string.wolfskin_fixtures, + defaultValue = false, + getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures }, + setter = { prefs, value -> + org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value) + prefs + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val UserProfileSettings = AppDestinationPreference( title = R.string.override_user_profile_settings, @@ -1320,6 +1345,8 @@ val advancedPreferences = preferences = listOf( AppPreference.WolfskinScreens, + AppPreference.ConductarrUrl, + *if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(), AppPreference.ShowClock, AppPreference.BackdropStylePref, AppPreference.ShowLogos, diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt new file mode 100644 index 00000000..b2ea33c4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* + +/** Consumer-visible mappings exercise realistic wire responses over a local HTTP server. */ +class ConductarrProjectionTest { + @Test fun householdFeedAndCatalogueBecomePersonalRowsWithoutRawLibraryNamesOrHomeComingSoon() = runBlocking { + FakeConductarr().use { fake -> + ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> + val provider = ConductarrProvider(client) + assertEquals("Shared", provider.profiles()!!.profiles!!.first().name) + assertEquals(listOf("Shared Anime"), provider.audiences().single().libraries) + val snapshot = provider.discover("shared").last() + assertEquals(DiscoverStatus.Ready, snapshot.status) + assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster) + assertEquals("Solaris", snapshot.seeds.single().title) + assertEquals(listOf(42L), snapshot.seeds.single().items.map { it.tmdb }) + assertEquals(listOf("New on Netflix", "Trending"), snapshot.lanes.take(2).map { it.title }) + assertTrue(snapshot.lanes.first { it.id == "genre-Comedy" }.pending) + assertEquals(99L, snapshot.hidden.single().tmdb) + assertEquals(8L, snapshot.services.single().id) + val rows = snapshot.homeRows() + assertEquals("Moon", rows.first { it.id == "pk-movie" }.cards.single().title) + assertFalse(rows.any { "Coming soon" in it.title || "Shared Anime" in it.title }) + assertTrue(fake.calls.filter { it.url.encodedPath == "/catalogue/discover" }.all { it.url.queryParameter("genre_id") == null }) + val expanded = provider.lanesThrough(99) + assertFalse(expanded.any { it.pending }) + assertTrue(fake.calls.any { it.url.queryParameter("genre_id") == "35" }) + assertEquals("Coming soon", expanded.last().title) + val service = provider.service(snapshot.services.single(), "tv") + assertEquals(listOf("Popular series", "New series", "Top rated series"), service.take(3).map { it.title }) + assertTrue(fake.calls.filter { it.url.queryParameter("service_id") == "8" }.any { it.url.queryParameter("media_type") == "tv" }) + val requests = provider.requests().last() + val request = requests.items.single() + assertEquals(ComingInStage.Downloading, request.stage) + assertEquals("Mindel", request.requesterName) + assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true))) + assertEquals(1, requests.openCount) + } + } + } + + @Test fun uncertainRequestIsLookedUpRatherThanSentTwiceAndSharedRemainsTheAuthenticatedActor() = runBlocking { + FakeConductarr().use { fake -> + ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> + val operations = ConductarrDiscoverOperations(client, { DiscoverSnapshot() }, profile = "shared", allowedRequesters = setOf("shared", "mindel")) + val intent = DiscoverRequest(DiscoverTitle(42, "movie", "Moon"), "mindel", "untrusted-ui-profile") + fake.postStatus = 503 + assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) + assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) + assertEquals(1, fake.calls.count { it.method == "POST" }) + val post = fake.calls.single { it.method == "POST" } + val body = post.body!!.utf8() + assertTrue(body.contains("\"requester\":\"mindel\"")) + assertTrue(body.contains("\"recommendation_profile\":\"shared\"")) + assertFalse(body.contains("actor")) + assertEquals(listOf("GET", "POST", "GET"), fake.calls.map { it.method }) + fake.existingRequests = true + val receipt = operations.check(intent) + assertEquals(RequestPhase.Receipt, receipt.phase) + assertEquals("Shared", receipt.actor) + assertEquals("mindel", receipt.requester) + assertEquals(RequestPhase.Refused, operations.request(intent.copy(requester = "outsider")).phase) + assertEquals(RequestPhase.Refused, operations.request(intent.copy(title = DiscoverTitle(44, "tv", "Dark"))).phase) + assertEquals(1, fake.calls.count { it.method == "POST" }) + assertTrue(operations.hide("untrusted-ui-profile", intent.title)) + assertTrue(operations.restore("untrusted-ui-profile", intent.title)) + assertTrue(fake.calls.takeLast(2).all { "/profiles/shared/dismissals" in it.url.encodedPath }) + } + } + } + + @Test fun episodeVotesReplaceSeriesScoreAndConditionalTitleReadsKeepTheOriginalBody() = runBlocking { + FakeConductarr().use { fake -> + ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> + val ratings = ConductarrRatings(client) + assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source }) + val episode = ratings.read(44, "episode", 1, 1)!! + assertEquals(listOf("rtCritics", "rtAudience", "episode"), episode.map { it.source }) + assertEquals(8.1, episode.last().score, 0.0) + ratings.read(44, "episode", 1, 2) + assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") }) + assertFalse(fake.calls.any { it.url.encodedPath.endsWith("/enrichment") }) + val provider: ConductarrTitleProvider = ConductarrProvider(client) + val title = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved + fake.notModified = true + val revalidated = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved + assertEquals(title.value, revalidated.value) + assertEquals(title.savedAtMillis, revalidated.savedAtMillis) + assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"]) + fake.notModified = false + assertEquals("Sam Rockwell", (provider.cast(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().name) + assertEquals(28L, (provider.season(44, 1).last() as ReadResult.Value).saved.value!!.episodes!!.single().voteCount) + assertEquals("Sam Rockwell", (provider.performer(216).last() as ReadResult.Value).saved.value!!.name) + assertEquals("Sam Bell", (provider.filmography(216).last() as ReadResult.Value).saved.value!!.items!!.single().credits!!.single().character) + assertEquals("Arrival", (provider.related(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.items!!.single().title) + assertEquals("Moon", (provider.display(42, MediaType.MOVIE).last() as ReadResult.Value).saved.value!!.title) + assertEquals("mindel", (provider.activity(42, MediaType.MOVIE, "mindel").last() as ReadResult.Value).saved.value!!.viewer.key) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt new file mode 100644 index 00000000..4a1e952f --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.nio.file.Files +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* + +/** Real sockets and reopened disk stores prove identity and authentication boundaries. */ +class ConductarrSessionTest { + @Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking { + val directory = Files.createTempDirectory("tv-session-").toFile() + try { + FakeConductarr().use { fake -> + ConductarrSession(FileCacheStore(directory)).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + session.authenticate() + val first = session.identity.value!! + assertEquals("shared", first.operator!!.requester) + assertEquals("Moon", first.provider.discover("shared").last().picks.single().title) + session.select("server:mindel", MINDEL_TOKEN, fake.url) + val second = session.identity.value!! + assertNotSame(first.client, second.client) + assertFalse(second.signedIn) + assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + session.authenticate() + assertEquals("Mindel", session.identity.value!!.operator!!.name) + val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" } + assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"]) + session.select("server:other", SHARED_TOKEN, fake.url) + assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) + session.select("server:shared", SHARED_TOKEN, fake.url) + assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester) + session.select("server:shared", null, fake.url) + assertNull(session.identity.value) + } + fake.calls.forEach { request -> + assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\"")) + listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) } + assertFalse(request.url.toString().contains(SHARED_TOKEN)) + } + } + assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) }) + } finally { directory.deleteRecursively() } + } + + @Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking { + val directory = Files.createTempDirectory("tv-offline-").toFile() + try { + FakeConductarr().use { fake -> + ConductarrSession(FileCacheStore(directory)).use { original -> + original.select("server:shared", SHARED_TOKEN, fake.url) + original.authenticate() + original.identity.value!!.provider.discover("shared").toList() + } + ConductarrSession(FileCacheStore(directory)).use { reopened -> + reopened.select("server:shared", SHARED_TOKEN, fake.url) + fake.status = 503 + reopened.authenticate() + assertTrue(reopened.identity.value!!.signedIn) + val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList() + assertEquals("Moon", snapshots.first().picks.single().title) + assertEquals("Moon", snapshots.last().picks.single().title) + assertNotNull(snapshots.last().savedAt) + fake.status = 200 + fake.ratingsStatus = 401 + val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null) + assertNull(ratings) + assertFalse(reopened.identity.value!!.signedIn) + assertNull(reopened.identity.value!!.operator) + assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error) + assertEquals(DiscoverStatus.NeedsSignIn, reopened.identity.value!!.provider.discover("shared").last().status) + fake.ratingsStatus = 200 + reopened.select("server:shared", SHARED_TOKEN, fake.url, retry = true) + reopened.authenticate() + assertTrue(reopened.identity.value!!.signedIn) + } + } + } finally { directory.deleteRecursively() } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt new file mode 100644 index 00000000..608728aa --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The shipped host reads a local server and draws real data, dwell ratings and 401 recovery. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ConductarrSurfaceTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply { + pickerVisible = false + enterDestination("discover") + } + lateinit var current: DiscoverState + val art = ImageBitmap(1, 1) + rule.setContent { + val identity by session.identity.collectAsState() + val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false) + current = state + CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { ConductarrRatings(it.client) }) { + WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } + } + } + rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.Ready } + rule.onNodeWithText("Moon").assertIsDisplayed() + rule.mainClock.autoAdvance = false + rule.runOnIdle { + val lane = current.lanes.indexOfFirst { it.titles.any { title -> title.tmdb == 43L } } + current.focus(current.firstShelf + lane, 0) + } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.mainClock.advanceTimeBy(100) + org.junit.Assert.assertFalse(fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }) + rule.mainClock.advanceTimeBy(500) + rule.waitUntil(10_000) { + rule.mainClock.advanceTimeByFrame() + fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" } + } + rule.waitForIdle() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "conductarr-real-provider.png").absolutePath) + fake.ratingsStatus = 401 + runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } + rule.mainClock.autoAdvance = true + rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.NeedsSignIn } + rule.onNodeWithText("Discover needs sign-in").assertIsDisplayed() + org.junit.Assert.assertFalse(current.canRequest) + rule.onRoot().captureRoboImage(File(directory, "conductarr-401.png").absolutePath) + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt new file mode 100644 index 00000000..81b353af --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.io.Closeable +import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.serialization.json.* +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest + +internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef" +internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210" + +/** A loopback server supplies wire JSON, never app projections or live service state. */ +internal class FakeConductarr : Closeable { + val server = MockWebServer() + val calls = CopyOnWriteArrayList() + val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject + @Volatile var status = 200 + @Volatile var postStatus = 200 + @Volatile var existingRequests = false + @Volatile var notModified = false + @Volatile var ratingsStatus = 200 + + init { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + calls.add(request) + if (status != 200) return response("", status) + val path = request.url.encodedPath + if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus) + if (request.method == "POST") return response(wire.getValue("hide").toString()) + if (request.method == "DELETE") return response(wire.getValue("restore").toString()) + val key = when { + path == "/api/v1/operator" -> "operator" + path == "/api/v1/recommendations/profiles" -> "profiles" + path == "/curation/library-audiences" -> "audiences" + path.endsWith("/feed") -> "feed" + path.endsWith("/dismissals") -> "dismissals" + path == "/catalogue/options" -> "options" + path == "/catalogue/discover" -> "discover" + path.endsWith("/ratings") -> "ratings" + path.endsWith("/cast") -> "cast" + path.endsWith("/related") -> "discover" + path.endsWith("/enrichment") -> "enrichment" + path.endsWith("/display") -> "display" + path.contains("/seasons/") -> "season" + path.endsWith("/filmography") -> "filmography" + path.startsWith("/catalogue/performers/") -> "performer" + path.startsWith("/catalogue/titles/") -> "detail" + path.startsWith("/api/v1/activity/titles/") -> "readiness" + path.startsWith("/api/v1/requests/titles/") -> "requests" + path == "/api/v1/activity" -> "activity" + else -> return response("", 404) + } + if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) + if (notModified && request.headers["If-None-Match"] != null) return response("", 304) + if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") + var body = wire.getValue(key).jsonObject + if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { + body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) + } + if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") { + body = JsonObject(body + ("media_type" to JsonPrimitive("tv"))) + if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark"))))))) + } + if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2))) + return response(body.toString()) + } + } + server.start() + } + + val url: String get() = server.url("/").toString() + private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build() + override fun close() = server.close() +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index 47f843b2..f8322b5f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest { } val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists) + val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) val facade = mockk() rule.setContent { val destination = navigation.backStack.last() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt index d9e88778..5c55f1fc 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -112,6 +112,7 @@ class UpstreamContentFocusTest { val state = shellState() val model = mockk(relaxed = true) every { model.session } returns WolfskinSession().also { it.state = state } + every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) every { model.libraryDestinations } returns emptyMap() // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. every { model.signedInPerson } returns "unavailable" diff --git a/app/src/test/resources/conductarr/wire.json b/app/src/test/resources/conductarr/wire.json new file mode 100644 index 00000000..2893b49a --- /dev/null +++ b/app/src/test/resources/conductarr/wire.json @@ -0,0 +1,621 @@ +{ + "operator": { + "capabilities": { + "administer": false, + "manage_library": false, + "manage_memberships": false, + "recommendation_profiles": [ + "shared" + ], + "request_media": true, + "requesters": [ + "shared", + "mindel" + ], + "watch_users": [ + "shared" + ] + }, + "name": "Shared", + "recommendation_profile": "shared", + "requester": "shared", + "role": "household", + "subject": "jellyfin:shared", + "watch_user": "shared" + }, + "profiles": { + "profiles": [ + { + "key": "shared", + "name": "Shared" + }, + { + "key": "mindel", + "name": "Mindel" + } + ], + "default_profile": "shared" + }, + "audiences": { + "audiences": [ + { + "is_anime": true, + "libraries": [ + "Shared Anime" + ], + "media_type": "tv", + "owner": "shared", + "users": [ + "shared", + "mindel" + ] + } + ] + }, + "feed": { + "profile": { + "key": "shared", + "name": "Shared" + }, + "projection": { + "coverage": { + "complete": true, + "excluded": 0, + "matched": 1, + "total": 1, + "unresolved": 0 + }, + "facts": [], + "genre_id": 0, + "media_type": "all", + "service_id": 0, + "unresolved": [] + }, + "state": "current", + "outcome": "ranked", + "materialization": "current", + "movies": [ + { + "backdrop_path": "/moon-wide.jpg", + "contributions": [ + { + "added": false, + "media_type": "movie", + "title": "Solaris", + "tmdb_id": 33, + "watched": true + } + ], + "genre_ids": [ + 35 + ], + "media_type": "movie", + "overview": "An astronaut reaches the end of his assignment.", + "poster_path": "/moon.jpg", + "score": 4.2, + "title": "Moon", + "tmdb_id": 42, + "tvdb_id": 0, + "year": 2009 + } + ], + "shows": [], + "expires_at": "2099-11-05T12:00:00Z" + }, + "options": { + "genres": [ + { + "id": 35, + "name": "Comedy" + }, + { + "id": 16, + "name": "Animation" + } + ], + "media_type": "movie", + "observations": [], + "region": "NL", + "services": [ + { + "id": 8, + "name": "Netflix" + } + ], + "state": "observed" + }, + "discover": { + "items": [ + { + "tmdb_id": 43, + "media_type": "movie", + "title": "Arrival", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + } + ], + "media_type": "movie", + "observations": [], + "page": 1, + "query": "", + "source": "general", + "state": "observed", + "total_pages": 2, + "total_results": 24 + }, + "dismissals": { + "profile": "shared", + "dismissals": [ + { + "hidden_at": "2026-11-05T12:00:00Z", + "hidden_by": "shared", + "media_type": "movie", + "poster_path": "/hidden.jpg", + "title": "Hidden film", + "tmdb_id": 99, + "year": 2017 + } + ] + }, + "ratings": { + "ratings": [ + { + "source": "rt_critics", + "state": "observed", + "score": 90, + "status": "Certified Fresh" + }, + { + "source": "rt_audience", + "state": "observed", + "score": 89, + "status": "Upright" + }, + { + "source": "imdb", + "state": "observed", + "score": 7.8, + "votes": 300000 + }, + { + "source": "tmdb", + "state": "observed", + "score": 7.6, + "votes": 4000 + } + ] + }, + "detail": { + "genres": [ + "Comedy" + ], + "metadata": { + "genre_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "media_type": "movie", + "provider_state": "observed", + "providers": [], + "region": "NL", + "tmdb_id": 42 + }, + "metadata_state": "observed", + "observations": [], + "ratings": [ + { + "source": "rt_critics", + "state": "observed", + "score": 90, + "status": "Certified Fresh" + }, + { + "source": "rt_audience", + "state": "observed", + "score": 89, + "status": "Upright" + }, + { + "source": "imdb", + "state": "observed", + "score": 7.8, + "votes": 300000 + }, + { + "source": "tmdb", + "state": "observed", + "score": 7.6, + "votes": 4000 + } + ], + "runtime": 97, + "seasons": [ + { + "number": 1, + "name": "Season 1", + "overview": "A missing child.", + "episode_count": 10, + "recorded_episodes": 0, + "coverage": "none", + "request_state": "none", + "seerr_state": "untracked" + } + ], + "state": "observed", + "title": { + "tmdb_id": 42, + "media_type": "movie", + "title": "Moon", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + } + }, + "enrichment": { + "genres": [ + "Comedy" + ], + "metadata": { + "genre_state": "observed", + "genres": [ + { + "id": 35, + "name": "Comedy" + } + ], + "media_type": "movie", + "provider_state": "observed", + "providers": [], + "region": "NL", + "tmdb_id": 42 + }, + "metadata_state": "observed", + "observations": [], + "ratings": [ + { + "source": "rt_critics", + "state": "observed", + "score": 90, + "status": "Certified Fresh" + }, + { + "source": "rt_audience", + "state": "observed", + "score": 89, + "status": "Upright" + }, + { + "source": "imdb", + "state": "observed", + "score": 7.8, + "votes": 300000 + }, + { + "source": "tmdb", + "state": "observed", + "score": 7.6, + "votes": 4000 + } + ], + "runtime": 97, + "seasons": [ + { + "number": 1, + "name": "Season 1", + "overview": "A missing child.", + "episode_count": 10, + "recorded_episodes": 0, + "coverage": "none", + "request_state": "none", + "seerr_state": "untracked" + } + ], + "state": "observed", + "title": { + "tmdb_id": 42, + "media_type": "movie", + "title": "Moon", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + } + }, + "display": { + "media_type": "movie", + "metadata_state": "observed", + "poster_url": "https://images.example.invalid/moon.jpg", + "title": "Moon", + "tmdb_id": 42 + }, + "cast": { + "tmdb_id": 42, + "media_type": "movie", + "metadata_state": "observed", + "source": "seerr", + "paging": "unpaged", + "credit_context": "movie-character", + "items": [ + { + "performer_id": 216, + "name": "Sam Rockwell", + "character": "Sam Bell", + "credit_id": "cast-216", + "order": 0, + "profile_url": "https://images.example.invalid/sam.jpg" + } + ] + }, + "season": { + "tmdb_id": 44, + "media_type": "tv", + "metadata_state": "observed", + "season": { + "number": 1, + "name": "Season 1", + "overview": "A missing child.", + "episode_count": 10, + "recorded_episodes": 0, + "coverage": "none", + "request_state": "none", + "seerr_state": "untracked" + }, + "episodes": [ + { + "number": 1, + "name": "Secrets", + "overview": "A child disappears.", + "recorded": "missing", + "vote_average": 8.1, + "vote_count": 28 + } + ], + "observations": [] + }, + "performer": { + "performer_id": 216, + "metadata_state": "observed", + "source": "seerr", + "name": "Sam Rockwell", + "biography": "Actor.", + "also_known_as": [] + }, + "filmography": { + "performer_id": 216, + "metadata_state": "observed", + "source": "seerr", + "source_paging": "unpaged", + "completeness": "returned-non-adult-cast", + "page": 1, + "page_size": 20, + "items": [ + { + "title": { + "tmdb_id": 42, + "media_type": "movie", + "title": "Moon", + "overview": "An astronaut reaches the end of his assignment.", + "poster_url": "https://images.example.invalid/moon.jpg", + "backdrop_url": "https://images.example.invalid/moon-wide.jpg", + "release_date": "2009-06-12", + "held": "unheld", + "libraries": [], + "request_state": "none", + "seerr_state": "untracked", + "genre_ids": [ + 35 + ] + }, + "credits": [ + { + "credit_id": "cast-216", + "character": "Sam Bell" + } + ], + "visibility_state": "unknown", + "users": [] + } + ], + "observations": [] + }, + "activity": { + "items": [ + { + "actor": { + "name": "Shared", + "requester": "shared", + "role": "household" + }, + "filed_by_you": true, + "id": "request:7", + "media_type": "movie", + "on_behalf": true, + "requester": { + "key": "mindel", + "name": "Mindel" + }, + "seasons": [], + "stages": [ + { + "name": "request", + "source": "conductarr", + "state": "done", + "summary": "Request accepted" + }, + { + "name": "acquisition", + "source": "sonarr", + "state": "in_progress", + "summary": "Downloading" + }, + { + "name": "visibility", + "source": "jellyfin", + "state": "none", + "summary": "Not yet" + }, + { + "name": "playback", + "source": "jellyfin", + "state": "none", + "summary": "Not checked" + } + ], + "title": "Moon", + "tmdb_id": 42, + "blocking_stage": "acquisition" + } + ], + "requester": "all", + "sources": [], + "truncated": false + }, + "readiness": { + "tmdb_id": 42, + "media_type": "movie", + "viewer": { + "key": "mindel", + "name": "Mindel" + }, + "stages": [ + { + "name": "request", + "source": "conductarr", + "state": "done", + "summary": "Request accepted" + }, + { + "name": "acquisition", + "source": "sonarr", + "state": "in_progress", + "summary": "Downloading" + }, + { + "name": "visibility", + "source": "jellyfin", + "state": "none", + "summary": "Not yet" + }, + { + "name": "playback", + "source": "jellyfin", + "state": "none", + "summary": "Not checked" + } + ], + "seerr": { + "state": "observed", + "requests": [] + }, + "coverage": [], + "open_link": { + "state": "missing" + }, + "sources": [] + }, + "requests": { + "requests": [ + { + "actor": { + "name": "Shared", + "requester": "shared", + "role": "household" + }, + "attempts": 1, + "coverage": [], + "created_at": "2026-11-05T12:00:00Z", + "id": 7, + "media_type": "movie", + "membership": { + "status": "added" + }, + "on_behalf": true, + "requester": "mindel", + "requester_username": "Mindel", + "seasons": null, + "seerr": { + "outcome": "requested", + "request_id": 9 + }, + "source": "recommendation", + "state": "settled", + "tmdb_id": 42, + "updated_at": "2026-11-05T12:00:00Z" + } + ] + }, + "receipt": { + "actor": { + "name": "Shared", + "requester": "shared", + "role": "household" + }, + "attempts": 1, + "coverage": [], + "created_at": "2026-11-05T12:00:00Z", + "id": 7, + "media_type": "movie", + "membership": { + "status": "added" + }, + "on_behalf": true, + "requester": "mindel", + "requester_username": "Mindel", + "seasons": null, + "seerr": { + "outcome": "requested", + "request_id": 9 + }, + "source": "recommendation", + "state": "settled", + "tmdb_id": 42, + "updated_at": "2026-11-05T12:00:00Z" + }, + "hide": { + "created": true, + "dismissal": { + "hidden_at": "2026-11-05T12:00:00Z", + "hidden_by": "shared", + "media_type": "movie", + "poster_path": "/hidden.jpg", + "title": "Moon", + "tmdb_id": 42, + "year": 2017 + }, + "materialization": "current", + "profile": "shared" + }, + "restore": { + "media_type": "movie", + "profile": "shared", + "restored": true, + "tmdb_id": 42 + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt new file mode 100644 index 00000000..b0b918ae --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.Options +import org.justinvisser.wolfskin.conductarr.model.Page +import org.justinvisser.wolfskin.design.* + +/** One lane can interleave movie and TV reads without exposing transport pages on screen. */ +internal data class CatalogueLaneQuery( + val id: String, + val name: String, + val media: List, + val sort: DiscoverSort = DiscoverSort.POPULAR, + val service: Long? = null, + val genres: Map = emptyMap(), +) + +/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ +internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List) { + private data class SourcePage(val number: Int, val last: Boolean, val titles: List, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) + private val sources = mutableMapOf() + private val visited = mutableSetOf() + private val mutex = Mutex() + + suspend fun saved(): List = mutex.withLock { + queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) } + lanes() + } + + suspend fun through(index: Int, force: Boolean = false): List = mutex.withLock { + queries.take((index + 1).coerceIn(0, queries.size)).forEach { query -> + if (force || query.id !in visited) { + load(query, if (force) ReadTrigger.FORCE else ReadTrigger.IF_STALE, false) + visited.add(query.id) + } + } + lanes() + } + + suspend fun more(id: String): List = mutex.withLock { + queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.FORCE, true); visited.add(it.id) } + lanes() + } + + private suspend fun load(query: CatalogueLaneQuery, trigger: ReadTrigger, next: Boolean) = coroutineScope { + val updates = query.media.map { media -> async { + val key = "${query.id}:${media.name}" + val previous = sources[key] + if (next && previous?.last == true && !previous.failed) return@async key to previous + val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1 + val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort) + val result = client.read(call, trigger).lastOrNull() + val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } + if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null + val items = saved?.items.orEmpty().map { it.discoverTitle() } + val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items + key to SourcePage(if (saved != null) page else previous?.number ?: 1, + saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(), + result is ReadResult.Failure || saved?.state == Page.State.PARTIAL, + saved?.totalResults?.toInt() ?: previous?.total ?: 0, + next && saved != null && titles.size == previous?.titles?.size && saved.page < saved.totalPages) + } }.map { it.await() } + updates.forEach { (key, value) -> if (value != null) sources[key] = value } + } + + private fun lanes(): List = queries.map { query -> + val pages = query.media.mapNotNull { sources["${query.id}:${it.name}"] } + val items = pages.flatMap { it.titles }.distinctBy { it.key } + val pending = pages.isEmpty() && query.id !in visited + DiscoverLane(query.id, query.name, items, total = pages.sumOf { it.total }, tail = when { + pages.any { it.failed } -> DiscoverTail.Retry + pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore + pages.any { !it.last } -> DiscoverTail.SeeAll + else -> DiscoverTail.None + }, pending = pending) + } +} + +/** Option ids, not guessed service ids, determine every catalogue parameter. */ +internal fun catalogueQueries(options: List, service: DiscoverService? = null, type: String? = null): List { + val media = if (type == null) MediaType.entries else listOf(if (type == "movie") MediaType.MOVIE else MediaType.TV) + val noun = if (type == "movie") "movies" else "series" + val genres = options.flatMap { option -> option.genres.orEmpty().map { it.name to (MediaType.valueOf(option.mediaType.name) to it.id) } } + .groupBy({ it.first }, { it.second }) + if (service != null) return listOf( + CatalogueLaneQuery("sv-pop", "Popular $noun", media, service = service.id), + CatalogueLaneQuery("sv-new", "New $noun", media, DiscoverSort.NEW, service.id), + CatalogueLaneQuery("sv-top", "Top rated $noun", media, DiscoverSort.TOP, service.id), + ) + genres.entries.filter { entry -> entry.value.any { it.first in media } }.take(3).map { (name, ids) -> + CatalogueLaneQuery("sv-g-$name", "$name $noun", media.filter { item -> ids.any { it.first == item } }, service = service.id, genres = ids.toMap()) + } + val services = options.flatMap { it.services.orEmpty() }.distinctBy { it.id } + return services.map { CatalogueLaneQuery("new-${it.id}", "New on ${it.name}", media, DiscoverSort.NEW, it.id) } + + CatalogueLaneQuery("trending", "Trending", media) + + listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").mapNotNull { name -> + genres[name]?.let { ids -> CatalogueLaneQuery("genre-$name", name, + ids.map { it.first }.distinct(), genres = ids.toMap()) } + } + CatalogueLaneQuery("soon", "Coming soon", media, DiscoverSort.UPCOMING) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt index 0c8a7067..0880192e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt @@ -22,44 +22,63 @@ import org.justinvisser.wolfskin.design.RequestPhase class ConductarrDiscoverOperations( private val client: ConductarrClient, private val refreshSnapshot: suspend () -> DiscoverSnapshot, + private val profile: String? = null, + private val allowedRequesters: Set? = null, + private val canRequest: Boolean = true, ) : DiscoverOperations { + private val submissions = mutableSetOf>() + private val requestMutex = kotlinx.coroutines.sync.Mutex() override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { - val title = intent.title - require(title.type != "tv" || !title.seasons.isNullOrEmpty()) { "Whole-series season metadata is required" } - val body = RequestIntentBody( - mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, - recommendationProfile = intent.profile.takeIf { intent.fromRecommendation }, requester = intent.requester, - seasons = title.seasons?.map(Int::toLong), - source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, - tmdbId = title.tmdb, - ) - return when (val result = client.mutate(TvRoutes.request(body))) { - is MutationResult.Success -> result.value.outcome() - is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) - } + requestMutex.lock() + try { + val title = intent.title + if (!canRequest || allowedRequesters?.contains(intent.requester) == false || + (title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused) + val key = title.key to intent.requester + val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last() + if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed) + val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull { + it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong))) + } + if (existing != null) return existing.outcome() + if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed) + val body = RequestIntentBody( + mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, + recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester, + seasons = title.seasons?.map(Int::toLong), + source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, + tmdbId = title.tmdb, + ) + return when (val result = client.mutate(TvRoutes.request(body))) { + is MutationResult.Success -> result.value.outcome() + is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) + } + } finally { requestMutex.unlock() } } override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { - is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { it.requester == intent.requester }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) + is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { + it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong))) + }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed) } } override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { - val result = client.mutate(TvRoutes.hide(profile, + val result = client.mutate(TvRoutes.hide(this.profile ?: profile, RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) return result is MutationResult.Success || observedHidden(profile, title) == true } override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { - val result = client.mutate(TvRoutes.restore(profile, title.tmdb, title.mediaType())) + val result = client.mutate(TvRoutes.restore(this.profile ?: profile, title.tmdb, title.mediaType())) return result is MutationResult.Success || observedHidden(profile, title) == false } private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? { - return when (val result = client.read(TvRoutes.dismissals(profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { + return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { is ReadResult.Value -> result.saved.value?.dismissals?.any { it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt new file mode 100644 index 00000000..f15fdef7 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.File +import javax.inject.Singleton +import org.justinvisser.wolfskin.conductarr.FileCacheStore + +/** Advanced-only endpoint and fixture controls contain no credentials. */ +object ConductarrPreference { + private var preferences: SharedPreferences? = null + var baseUrl by mutableStateOf(BuildConfig.CONDUCTARR_BASE_URL) + private set + var fixtures by mutableStateOf(false) + private set + + fun initialize(context: Context) { + preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + baseUrl = preferences?.getString("wolfskin.conductarr.url", null)?.takeIf { it.isNotBlank() } ?: BuildConfig.CONDUCTARR_BASE_URL + fixtures = BuildConfig.DEBUG && preferences?.getBoolean("wolfskin.fixtures", false) == true + } + + fun updateBaseUrl(value: String) { + val normalized = value.trim().ifBlank { BuildConfig.CONDUCTARR_BASE_URL } + baseUrl = normalized + preferences?.edit { putString("wolfskin.conductarr.url", normalized) } + } + + fun updateFixtures(value: Boolean) { + fixtures = BuildConfig.DEBUG && value + preferences?.edit { putBoolean("wolfskin.fixtures", fixtures) } + } +} + +/** Household snapshots must not enter Android backup or shared storage. */ +@Module +@InstallIn(SingletonComponent::class) +object ConductarrModule { + @Provides + @Singleton + fun session(@ApplicationContext context: Context): ConductarrSession = + ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr"))) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt new file mode 100644 index 00000000..b4a819a4 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import org.justinvisser.wolfskin.conductarr.model.* +import org.justinvisser.wolfskin.design.* + +/** Relative TMDB art paths are not app or Conductarr-relative URLs. */ +internal fun catalogueImage(path: String?): String? = path?.takeIf { it.isNotBlank() }?.let { + if (it.startsWith("/")) "https://image.tmdb.org/t/p/w780$it" else it +} + +internal fun Candidate.discoverTitle(genres: Map): DiscoverTitle = DiscoverTitle( + tmdbId, mediaType.name.lowercase(), title, year.toInt(), genreIds.orEmpty().mapNotNull(genres::get), overview, + catalogueImage(posterPath), catalogueImage(backdropPath), score = score, +) + +internal fun Title.discoverTitle(): DiscoverTitle = DiscoverTitle( + tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, + poster = posterUrl, backdrop = backdropUrl, +) + +internal fun DiscoverSnapshot.homeRows(): List { + if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut)) + if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable)) + val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) -> + picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items -> + HomeRow("pk-$type", HomeRowKind.Shelf, label, items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) + } + } + return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane -> + lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title, + items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) } + } + HomeRow("explore", HomeRowKind.Explore) +} + +internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle( + id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds, seasons = seasonCount, + overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(), +) + +internal fun RequestProgress.comingIn(): ComingInRequest { + val stagesByName = stages.orEmpty().associateBy { it.name } + val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION] + val visibility = stagesByName[ReadinessStage.Name.VISIBILITY] + val stage = when { + stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed + visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready + awaitingApproval == true -> ComingInStage.Waiting + visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady + acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading + acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching + stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking + else -> ComingInStage.Waiting + } + return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, + requester.name, actor?.requester, actor?.name, stage) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt new file mode 100644 index 00000000..1713b965 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.lastOrNull +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.* +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.conductarr.MediaType + +/** Screen projections share the identity-scoped transport, never a second HTTP or credential path. */ +class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider by ClientTitleProvider(client) { + private var catalogue: ConductarrCatalogue? = null + private var options = emptyList() + private val services = mutableMapOf() + fun discover(profile: String, force: Boolean = false): Flow = flow { + val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY) + if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved) + emit(discoverSnapshot(profile, if (force) ReadTrigger.FORCE else ReadTrigger.OPEN)) + } + + private suspend fun discoverSnapshot(profile: String, trigger: ReadTrigger): DiscoverSnapshot = coroutineScope { + val reads = SnapshotReads(client, trigger) + val feedJob = async { reads.read(TvRoutes.feed(profile)) } + val hiddenJob = async { reads.read(TvRoutes.dismissals(profile)) } + val optionsJobs = MediaType.entries.map { media -> async { reads.read(TvRoutes.options(media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.FOREGROUND) } } + val feed = feedJob.await() + val options = optionsJobs.mapNotNull { it.await() } + this@ConductarrProvider.options = options + val queries = catalogueQueries(options) + val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries).also { catalogue = it } + val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE) + val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } + val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } + val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate -> + candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } } + }.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) } + val hidden = hiddenJob.await()?.dismissals.orEmpty().map { + DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath)) + } + val status = when { + reads.needsSignIn -> DiscoverStatus.NeedsSignIn + feed == null && lanes.all { it.titles.isEmpty() } -> DiscoverStatus.Unavailable + feed?.outcome == RecommendationFeed.Outcome.NO_SEEDS -> DiscoverStatus.NoSeeds + feed == null || feed.state == RecommendationFeed.State.UNAVAILABLE -> DiscoverStatus.PicksUnavailable + else -> DiscoverStatus.Ready + } + DiscoverSnapshot(picks, seeds, options.flatMap { it.services.orEmpty() }.distinctBy { it.id }.map { DiscoverService(it.id, it.name, 0f) }, + lanes, hidden, status, reads.savedTime(), reachable = !reads.failed) + } + + /** Read two shelves ahead; pagination is keyed by the exact service, genre and media parameters. */ + suspend fun lanesThrough(index: Int): List = catalogue?.through(index).orEmpty() + suspend fun more(lane: String): List = catalogue?.more(lane).orEmpty() + suspend fun readAhead(lane: String?): List { + val loader = catalogue ?: return emptyList() + val index = loader.queries.indexOfFirst { it.id == lane } + return loader.through(index + 2) + } + + suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List { + val key = "${service.id}:$type" + val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) } + return if (more != null) loader.more(more) else loader.through(through) + } + + fun requests(): Flow = flow { + for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.FOREGROUND)) { + val reads = SnapshotReads(client, trigger) + val feed = reads.read(TvRoutes.activity()) + if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue + val items = coroutineScope { + feed?.items.orEmpty().map { item -> async { + val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV + val art = reads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) + item.comingIn().copy(poster = art?.posterUrl) + } }.map { it.await() } + } + emit(ComingInSnapshot(items, + if (feed == null) ComingInStatus.Unavailable else ComingInStatus.Ready, + partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && feed != null)) + } + } + + suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles()) + suspend fun audiences(): List = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() +} + +/** A batch tracks source failure without replacing saved bodies with empty success. */ +internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) { + @Volatile var failed = false + private set + @Volatile var needsSignIn = false + private set + private var oldestSaved: Long? = null + + suspend fun read(call: ReadCall, event: ReadTrigger = trigger): T? { + return when (val result = client.read(call, event).lastOrNull()) { + is ReadResult.Value -> result.saved.value + is ReadResult.Failure -> { + failed = true + if (result.error == ClientError.NeedsSignIn) needsSignIn = true + result.saved?.let { saved -> synchronized(this) { oldestSaved = minOf(oldestSaved ?: Long.MAX_VALUE, saved.savedAtMillis) } } + result.saved?.value + } + null -> null + } + } + + fun savedTime(): String? = oldestSaved?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt new file mode 100644 index 00000000..6e15b7b0 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.lastOrNull +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.Rating +import org.justinvisser.wolfskin.design.LibraryRating +import org.justinvisser.wolfskin.design.ScreenRatings + +/** Failed reads are not cached as absence; saved observations survive offline. */ +class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings { + override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List? { + val media = if (type == "movie") MediaType.MOVIE else MediaType.TV + val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media)).lastOrNull()) { + is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type) + is ReadResult.Failure -> result.saved?.value?.ratings?.screenRatings(type) ?: return null + null -> return null + } + if (type != "episode" || season == null || episode == null) return ratings + val result = client.read(TvRoutes.season(tmdb, season), ReadTrigger.IF_STALE).lastOrNull() + val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } + val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() } + val score = vote?.voteAverage?.takeIf { it.isFinite() && it > 0 && it <= 10 && (vote.voteCount ?: 0) > 0 } ?: return ratings + return ratings.filterNot { it.source == "tmdb" } + LibraryRating("episode", score) + } +} + +private fun List.screenRatings(type: String): List { + val observed = filter { it.state == Rating.State.OBSERVED && it.score?.isFinite() == true } + val tomatoes = listOf(Rating.Source.RT_CRITICS to "rtCritics", Rating.Source.RT_AUDIENCE to "rtAudience").mapNotNull { (source, mark) -> + observed.firstOrNull { it.source == source && it.score!! in 0.0..100.0 }?.let { LibraryRating(mark, it.score!!, it.status) } + } + val imdb = observed.firstOrNull { type == "movie" && it.source == Rating.Source.IMDB && it.score!! > 0 && it.score!! <= 10 } + val tmdb = observed.firstOrNull { it.source == Rating.Source.TMDB && it.score!! > 0 && it.score!! <= 10 && (it.votes ?: 0) > 0 } + val community = imdb?.let { LibraryRating("imdb", it.score!!) } ?: tmdb?.let { LibraryRating("tmdb", it.score!!) } + return tomatoes + listOfNotNull(community) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt new file mode 100644 index 00000000..e0ace3ac --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import java.io.Closeable +import java.security.MessageDigest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.Operator + +/** A selected Jellyfin login owns every provider and all in-memory observations. */ +data class ConductarrIdentity( + val person: String, + val client: ConductarrClient, + val provider: ConductarrProvider, + val operator: Operator? = null, + val error: ClientError? = null, + val signedIn: Boolean = false, +) + +/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */ +class ConductarrSession(private val cache: CacheStore) : Closeable { + private val mutableIdentity = MutableStateFlow(null) + val identity: StateFlow = mutableIdentity.asStateFlow() + private var selection: ByteArray? = null + @Volatile private var generation = 0L + + @Synchronized + fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) { + val fingerprint = MessageDigest.getInstance("SHA-256") + .digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray()) + if (!retry && selection?.contentEquals(fingerprint) == true) return + generation++ + val currentGeneration = generation + mutableIdentity.value?.client?.close() + mutableIdentity.value = null + selection = fingerprint + if (token.isNullOrBlank()) return + val client = try { + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), onEvent = { event -> + if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) { + mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) } + } + }) + } catch (_: IllegalArgumentException) { return } + mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client)) + } + + /** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */ + suspend fun authenticate() { + val selected = mutableIdentity.value ?: return + selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result -> + if (mutableIdentity.value?.client !== selected.client) return@collect + when (result) { + is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) } + is ReadResult.Failure -> mutableIdentity.update { + if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error) + else it?.copy(error = result.error) + } + } + } + } + + override fun close() { + generation++ + mutableIdentity.value?.client?.close() + mutableIdentity.value = null + selection = null + } +} + +/** Person ids partition disk entries even if a caller accidentally supplies the same credential twice. */ +private class PersonCacheStore(private val store: CacheStore, private val person: String) : CacheStore { + override fun read(scope: String, key: String) = store.read("$person:$scope", key) + override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write("$person:$scope", key, entry, capacity) + override fun remove(scope: String, key: String) = store.remove("$person:$scope", key) + override fun clear(scope: String) = store.clear("$person:$scope") +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt new file mode 100644 index 00000000..439bca3d --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import kotlinx.coroutines.flow.Flow +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.conductarr.model.* + +/** Titles can adopt these reads without importing transport or owning another login. */ +interface ConductarrTitleProvider { + fun detail(tmdb: Long, media: MediaType): Flow> + fun cast(tmdb: Long, media: MediaType): Flow> + fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow> + fun season(tmdb: Long, number: Int): Flow> + fun performer(id: Long): Flow> + fun filmography(id: Long, page: Int = 1): Flow> + fun activity(tmdb: Long, media: MediaType, viewer: String): Flow> + fun requests(tmdb: Long, media: MediaType): Flow> + fun enrichment(tmdb: Long, media: MediaType): Flow> + fun display(tmdb: Long, media: MediaType): Flow> +} + +/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */ +internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider { + override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN) + override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN) + override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN) + override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN) + override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN) + override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN) + override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN) + override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE) + override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN) + override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index b107bd97..7db7870c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -2,54 +2,105 @@ package org.justinvisser.wolfskin.tv import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext import com.github.damontecres.wholphin.BuildConfig import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.design.DiscoverFixture -import org.justinvisser.wolfskin.design.DiscoverOperations -import org.justinvisser.wolfskin.design.DiscoverRequest -import org.justinvisser.wolfskin.design.DiscoverRequestResult -import org.justinvisser.wolfskin.design.DiscoverSnapshot -import org.justinvisser.wolfskin.design.DiscoverState -import org.justinvisser.wolfskin.design.DiscoverTitle -import org.justinvisser.wolfskin.design.DiscoverStatus -import org.justinvisser.wolfskin.design.RequestPhase -import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.conductarr.* +import org.justinvisser.wolfskin.design.* internal val LocalDiscoverState = compositionLocalOf { error("Discover requires the Wolfskin shell") } -/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */ +/** Production uses the selected login; explicit fixtures remain deterministic and network-free. */ @Composable internal fun rememberDiscoverState( shell: ShellState, push: (String) -> Unit, pop: () -> Unit, loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } }, + identity: ConductarrIdentity? = null, + fixtures: Boolean = BuildConfig.DEBUG, + retrySignIn: suspend () -> Unit = {}, ): DiscoverState { val context = LocalContext.current val scope = rememberCoroutineScope() - var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) } - LaunchedEffect(shell, shell.person.key) { - if (BuildConfig.DEBUG) { + val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf>() } + val refresh: suspend () -> DiscoverSnapshot = { + if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull() ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) + else { retrySignIn(); DiscoverSnapshot() } + } + val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) { + val operations = identity?.takeIf { it.signedIn }?.let { ConductarrDiscoverOperations(it.client, refresh, + profile = it.operator!!.recommendationProfile, + allowedRequesters = it.operator.capabilities.requesters.orEmpty().toSet(), canRequest = it.operator.capabilities.requestMedia) } + ?: SignedOutDiscoverOperations(refresh) + lateinit var loaded: DiscoverState + loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop, + { service, type -> servicePages["${service.id}:$type"].orEmpty() }, + requesters = if (fixtures) shell.people else shell.people.filter { it.key in identity?.operator?.capabilities?.requesters.orEmpty() }, + onMore = if (fixtures || identity?.signedIn != true) null else { lane -> + val service = loaded.service + if (service == null) identity.provider.more(lane) + else { + servicePages["${service.id}:${loaded.filter}"] = identity.provider.service(service, loaded.filter, more = lane) + loaded.snapshot.lanes + } + }) + loaded + } + var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf(null) } + LaunchedEffect(state, fixtures) { + if (fixtures) { val fixture = loadFixture(context) val snapshot = fixture.snapshot(shell.person.key) - val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab) val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service) - state = loaded - if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) + fixtureState = loaded + if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) + } else if (identity?.signedIn == true) { + identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it) } } } - LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) } - return state + LaunchedEffect(state.service?.id, state.filter, state) { + val service = state.service ?: return@LaunchedEffect + if (!fixtures && identity?.signedIn == true) { + servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter) + state.invalidateLanes() + } + } + LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) { + if (!fixtures && identity?.signedIn == true) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> + val service = state.service + if (service == null) { + state.updateLanes(identity.provider.readAhead(lane)) + val focused = state.focusedLane + if (focused != null && focused.tail == DiscoverTail.SeeAll && column >= focused.titles.size - WolfskinTokens.Behaviour.nextPageCards) { + state.updateLanes(identity.provider.more(focused.id)) + } + } else { + val index = state.lanes.indexOfFirst { it.id == lane } + servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2) + state.invalidateLanes() + } + } + } + LaunchedEffect(state.title?.key, state) { + val title = state.title ?: return@LaunchedEffect + if (!fixtures && identity?.signedIn == true) { + val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV + identity.provider.detail(title.tmdb, media).collect { result -> + val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value } + if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy( + runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(), + seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size)) + } + } + } + val result = if (fixtures) fixtureState ?: state else state + LaunchedEffect(shell.route, result) { result.enterRoute(shell.route) } + LaunchedEffect(result.snapshot.savedAt, shell.route) { if (shell.route.startsWith("discover")) shell.view.savedAt = result.snapshot.savedAt } + return result } private fun readDiscoverFixture(context: Context): DiscoverFixture { @@ -57,7 +108,6 @@ private fun readDiscoverFixture(context: Context): DiscoverFixture { return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" } } -/** Fixture writes are in-memory only and cannot reach a Conductarr client. */ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations { override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor) override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) @@ -66,10 +116,10 @@ private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, pr override suspend fun refresh() = snapshot } -private object SignedOutDiscoverOperations : DiscoverOperations { +private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations { override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused) override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) override suspend fun hide(profile: String, title: DiscoverTitle) = false override suspend fun restore(profile: String, title: DiscoverTitle) = false - override suspend fun refresh() = DiscoverSnapshot() + override suspend fun refresh() = retry() } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 91b75b04..9a9c04ac 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -37,25 +37,35 @@ class WolfskinHomeViewModel @Inject constructor( private val cache: HomeLibraryCache, private val navigation: NavigationManager, private val seeAll: WolfskinSeeAllLists, + private val conductarr: ConductarrSession, ) : ViewModel() { + val conductarrState get() = conductarr.identity var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) private set var toast by mutableStateOf(null) private set + private var catalogueJob: kotlinx.coroutines.Job? = null suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { + catalogueJob?.cancel() withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } + if (retry && conductarr.identity.value?.signedIn != true) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + conductarr.authenticate() + } val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut)) - if (person != user.name.orEmpty().lowercase()) { + if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) { snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) return } val scope = "${server.id}:${user.id}" val saved = withContext(Dispatchers.IO) { cache.read(scope) } + var libraryCopy = saved?.first ?: HomeLibrary() if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation)) try { val library = withContext(Dispatchers.IO) { home.read(user.id) } val savedAt = System.currentTimeMillis() + libraryCopy = library withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) } snapshot = HomeSnapshot(buildHomeRows(library, continuation)) } catch (exception: Exception) { @@ -64,6 +74,19 @@ class WolfskinHomeViewModel @Inject constructor( else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) if (retry) showToast("Still can’t reach it. The last saved view stays on screen.") } + val identity = conductarr.identity.value + if (identity?.signedIn == true) catalogueJob = viewModelScope.launch { + identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue -> + if (conductarr.identity.value?.client !== identity.client) return@collect + val status = when (catalogue.status) { + DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut + DiscoverStatus.Unavailable -> HomeAvailability.Unavailable + else -> if (catalogue.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready + } + snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogue.homeRows(), libraryAvailable = snapshot.jellyfinAvailable), + status, snapshot.jellyfinAvailable, catalogue.savedAt ?: snapshot.savedAt) + } + } } fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { @@ -123,9 +146,11 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( /** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */ @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { - LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) } + val identity by model.conductarrState.collectAsState() + LaunchedEffect(shell.person.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) } Box(modifier) { - HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } + val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot + HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } model.toast?.let { message -> Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding)) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt index 2b7562df..5984cb5a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt @@ -8,7 +8,7 @@ import org.justinvisser.wolfskin.design.ShellPerson /** Prototype identities are packaged exclusively in debug assets, never release resources. */ fun readShellPeople(context: Context, saved: List): List { - if (BuildConfig.DEBUG) { + if (ConductarrPreference.fixtures) { val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) } val profiles = json.getJSONArray("profiles") return List(profiles.length()) { index -> @@ -18,6 +18,6 @@ fun readShellPeople(context: Context, saved: List): List val name = user.name.orEmpty() - ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false) + ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString()) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 533cdc4e..d927b035 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor( private val backdrop: BackdropService, val libraries: LibraryReadFacade, val seeAll: WolfskinSeeAllLists, + val conductarr: ConductarrSession, ) : ViewModel() { val backdropFlow: StateFlow = backdrop.backdropFlow private val requestsSnapshot by lazy { readShellRequests(context) } @@ -80,17 +81,28 @@ class WolfskinShellViewModel @Inject constructor( private set var signedInPerson by mutableStateOf("") private set + private var identityJob: kotlinx.coroutines.Job? = null + private var fixtureMode: Boolean? = null suspend fun load(server: JellyfinServer, user: JellyfinUser) { WolfskinScreenPreference.initialize(context) - signedInPerson = user.name.orEmpty().lowercase() - if (session.state == null || session.serverId != server.id.toString()) { + ConductarrPreference.initialize(context) + identityJob?.cancel() + libraries.audiences = null + if (ConductarrPreference.fixtures) conductarr.close() + else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) + signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() + if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) { val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } val people = readShellPeople(context, saved.ifEmpty { listOf(user) }) session.serverId = server.id.toString() + fixtureMode = ConductarrPreference.fixtures session.state = ShellState(people, signedInPerson) { session.onAction(it) } } session.onAction = { action -> handleAction(action, server, user) } + identityJob = viewModelScope.launch { + if (!ConductarrPreference.fixtures) loadHousehold(user) + } try { libraryDestinations = libraries.readDestinations(user.id) } catch (exception: Exception) { @@ -99,13 +111,44 @@ class WolfskinShellViewModel @Inject constructor( } } + private suspend fun loadHousehold(user: JellyfinUser) { + conductarr.authenticate() + val identity = conductarr.identity.value ?: return + if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return + val profiles = identity.provider.profiles()?.profiles.orEmpty() + val audiences = identity.provider.audiences() + if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return + libraries.audiences = audiences.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } + val updated = session.state?.people.orEmpty().map { person -> + val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } + val current = person.jellyfinUserId == user.id.toString() + person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, + name = if (current) identity.operator!!.name else profile?.name ?: person.name, + hasAnime = audiences.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) }) + } + session.state?.updatePeople(updated) + signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson + } + + suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { + conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) + loadHousehold(user) + } + fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } - /** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ + /** Requests are person-scoped; a failed refresh retains the provider's saved observations. */ fun refreshRequests() { viewModelScope.launch { - val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } - session.state?.requests?.update(snapshot) + if (ConductarrPreference.fixtures) { + session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot }) + } else { + val identity = conductarr.identity.value + if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) + else identity.provider.requests().collect { snapshot -> + if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot) + } + } } } @@ -115,18 +158,23 @@ class WolfskinShellViewModel @Inject constructor( ShellAction.Pop -> navigation.goBack() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) - ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } - ShellAction.RetryRequests -> refreshRequests() + ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } + ShellAction.RetryRequests -> viewModelScope.launch { + if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) + refreshRequests() + } is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}")) } } private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { navigation.replace(Destination.Home()) - if (person.key == user.name.orEmpty().lowercase()) return + if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return + conductarr.close() + libraries.audiences = null viewModelScope.launch { val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) } + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } } if (saved == null || saved.isProtected) { signedInPerson = "" @@ -163,7 +211,14 @@ fun WolfskinApp( modifier: Modifier = Modifier, viewModel: WolfskinShellViewModel = hiltViewModel(), ) { - LaunchedEffect(server.id, user.id) { viewModel.load(server, user) } + val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() + LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } + LaunchedEffect(identity?.signedIn, identity?.person) { + if (!ConductarrPreference.fixtures) { + if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) + else viewModel.refreshRequests() + } + } val loading = remember(user.id) { ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } } @@ -172,10 +227,16 @@ fun WolfskinApp( state, { route -> navigationManager.navigateTo(Destination.Wolfskin(route)) }, navigationManager::goBack, + identity = identity, + fixtures = ConductarrPreference.fixtures, + retrySignIn = { + viewModel.retryConductarr(server, user) + }, ) RequestsRefresh(state, viewModel::refreshRequests) WolfskinTheme { - CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover) { + CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, + org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { Box(modifier) { val current = navigationManager.backStack.last() if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 7d75c019..3f51a1ba 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -52,7 +52,7 @@ fun WolfskinLibraryContent( ) { val view = shell.rememberedView(route) val fixture by produceState(null, context) { - if (BuildConfig.DEBUG) value = withContext(Dispatchers.IO) { + if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } } } @@ -74,7 +74,7 @@ fun WolfskinLibraryContent( return } LaunchedEffect(grid, grid.refreshRevision, fixture) { - if (route in LIBRARY_DESTINATIONS && (!BuildConfig.DEBUG || fixture != null)) { + if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { val wasLoading = grid.status == LibraryStatus.Loading if (fixture != null) { grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } @@ -89,7 +89,7 @@ fun WolfskinLibraryContent( if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false } } - LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null, view = view) + LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view) } val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime") diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 1696930a..47bf457d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -21,25 +21,29 @@ import org.justinvisser.wolfskin.design.HomeLibrary import org.justinvisser.wolfskin.design.HomeTitle /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ -class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService) { +class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { suspend fun read(userId: UUID): HomeLibrary { - val resume = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items.map(::title) - val next = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items.map(::title) + val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items + val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items + val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> + try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } + catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } + } + val resume = resumeItems.map { title(it, series) } + val next = nextItems.map { title(it, series) } val libraries = api.userViewsApi.getUserViews(userId = userId).content.items val destinations = linkedMapOf>() val newest = mutableListOf() val totals = linkedMapOf() for (library in libraries) { - val key = when { - "anime" in library.name.orEmpty().lowercase() -> "anime" - library.collectionType == CollectionType.MOVIES -> "movies" - library.collectionType == CollectionType.TVSHOWS -> "shows" - else -> continue - } + val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content - destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map(::title)) + destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) }) totals[key] = (totals[key] ?: 0) + response.totalRecordCount - newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map(::title)) + newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) }) } return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) } @@ -60,7 +64,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val }?.id } - private fun title(item: BaseItemDto): HomeTitle { + private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { val wrapped = BaseItem(item) return HomeTitle( id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, @@ -69,7 +73,8 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720), seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, - addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, tmdbId = item.providerIds?.get("Tmdb")?.toIntOrNull(), + addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, + tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index fbb037b9..7b12c908 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -16,18 +16,27 @@ import java.util.UUID import javax.inject.Inject /** SDK reads are confined here so shell and screen state never depend on SDK responses. */ +@javax.inject.Singleton class LibraryReadFacade @Inject constructor(private val api: ApiClient) { + var audiences: List? = null + + fun destination(name: String, collection: CollectionType?): String? { + audiences?.let { known -> + val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null + return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows" + } + return when { + name.contains("anime", ignoreCase = true) -> "anime" + collection == CollectionType.MOVIES -> "movies" + collection == CollectionType.TVSHOWS -> "shows" + else -> null + } + } suspend fun readDestinations(userId: UUID): Map { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() return buildMap { libraries.forEach { library -> - val name = library.name.orEmpty().lowercase() - val key = when { - "anime" in name -> "anime" - library.collectionType == CollectionType.MOVIES -> "movies" - library.collectionType == CollectionType.TVSHOWS -> "shows" - else -> null - } + val key = destination(library.name.orEmpty(), library.collectionType) if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) } } @@ -36,13 +45,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) { /** A destination spans every matching accessible library; containers never become titles. */ suspend fun readLibrary(userId: UUID, destination: String): List { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> - val anime = library.name.orEmpty().contains("anime", ignoreCase = true) - when (destination) { - "anime" -> anime - "movies" -> !anime && library.collectionType == CollectionType.MOVIES - "shows" -> !anime && library.collectionType == CollectionType.TVSHOWS - else -> false - } + destination(library.name.orEmpty(), library.collectionType) == destination } val titles = linkedMapOf() for (library in libraries) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 87c358a2..691541e1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -38,8 +38,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { val state = shell.requests val data = state.snapshot + val ratingsReader = LocalScreenRatings.current LaunchedEffect(state.focusedId, data.signedIn, data.status) { - state.awaitFocusedRatings() + if (ratingsReader == null) state.awaitFocusedRatings() + else if (data.signedIn) state.focused?.let { item -> + ratingsReader.read(item.tmdbId, item.mediaType, null, null)?.let { values -> + state.updateRatings(item.id, values.map { rating -> + val mapped = rating.homeRating() + ComingInRating(mapped.mark, mapped.score, mapped.percentage) + }) + } + } } Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) Column(modifier.fillMaxHeight().width(L.sheetWidth) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt index 86d6c5c3..42d4c93e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -85,6 +85,12 @@ class ComingInState { ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings } + fun updateRatings(id: String, values: List) { + snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it }) + snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) } + ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings + } + /** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */ suspend fun awaitFocusedRatings() { val item = focused ?: return diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt index a638c6e6..488d90d3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt @@ -19,7 +19,7 @@ internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle { } return LibraryTitle(id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster, - ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability, + ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability, badge = when { held -> "held" receipts.isEmpty() -> null diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt index bda06cdc..0c587c58 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -29,7 +29,7 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden } val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt()) - if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { + if (items.isNotEmpty() || lane.pending || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size, tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None) if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt index 3fc3b296..080ba943 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt @@ -45,6 +45,8 @@ data class DiscoverSnapshot( val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, val savedAt: String? = null, val reusesFixtureLanes: Boolean = false, + val reachable: Boolean = true, + val requestsAllowed: Boolean = true, ) /** Lane identity survives lazy recycling and provider refreshes. */ @@ -57,6 +59,7 @@ data class DiscoverLane( val pick: Boolean = false, val hidden: Boolean = false, val fullTitles: List = titles, + val pending: Boolean = false, ) /** Caller identity is deliberately absent: the authenticated client supplies the actor. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 68ac7436..1c2525a9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -48,13 +48,21 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) + val ratingsReader = LocalScreenRatings.current LaunchedEffect(focusedTitle?.key, state.contentFocused) { state.plateKey = null if (state.contentFocused && focusedTitle != null) { if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null)?.let { state.updateRatings(focusedTitle, it) } state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key } } + LaunchedEffect(state.hero?.key, state.title?.key, state.isSignedIn) { + if (state.isSignedIn && ratingsReader != null) { + val title = state.title ?: state.hero + if (title != null) ratingsReader.read(title.tmdb, title.type, null, null)?.let { state.updateRatings(title, it) } + } + } if (state.title != null) { DiscoverTitleDestination(state, modifier); return } state.collection?.let { LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 8883d432..4492a69c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -76,6 +76,10 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg) } + } else if (lane.pending) { + items(slots, key = { "pending:$it" }) { + Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card))) + } } else { itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> DiscoverPoster(state, item, active && index == column, Modifier.clickable { @@ -88,7 +92,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } } - if (!services && active) { + if (!services && active && !lane.pending) { val item = lane.titles.getOrNull(column) Caption( item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", @@ -105,7 +109,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { @Composable fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) { val receipts = state.requests[title.key] - val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) } + val card = remember(title, receipts, state.ratingsFor(title), state.shell.person.key) { title.libraryCard(state) } LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true, modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 23b11148..05f98b44 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -19,6 +19,8 @@ class DiscoverState( private val onPush: (String) -> Unit = {}, private val onPop: () -> Unit = {}, private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, + private val requesters: List = shell.people, + private val onMore: (suspend (String) -> List?)? = null, ) : ShellContentFocus { var snapshot by mutableStateOf(initial) private set @@ -46,6 +48,7 @@ class DiscoverState( val requests = mutableStateMapOf>() private val requestSheets = mutableMapOf, RequestSheetState>() private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } + private val ratingValues = mutableStateMapOf>() private lateinit var collectionCards: LinkedHashMap private var revision by mutableStateOf(0) private var cachedRevision = -1 @@ -59,11 +62,44 @@ class DiscoverState( private val dismissalVersions = mutableMapOf() private var rootFilter = "all" + fun updateSnapshot(value: DiscoverSnapshot) { + snapshot = value + hidden.clear() + value.hidden.forEach { hidden[it.key] = it } + revision++ + } + + fun invalidateLanes() { revision++ } + fun updateLanes(value: List) { snapshot = snapshot.copy(lanes = value); revision++ } + + private suspend fun loadLane(id: String) { + val loaded = onMore?.invoke(id) + if (loaded != null) updateLanes(loaded) else { snapshot = operations.refresh(); revision++ } + } + + fun updateTitle(value: DiscoverTitle) { + if (title?.key == value.key) title = value + } + + fun ratingsFor(item: DiscoverTitle): List = ratingValues[item.key] ?: item.ratings + + fun updateRatings(item: DiscoverTitle, values: List) { + val ratings = values.map { DiscoverRating(it.source, it.score, it.label) } + ratingValues[item.key] = ratings + fun replace(source: List) = source.map { if (it.key == item.key) it.copy(ratings = ratings) else it } + snapshot = snapshot.copy(picks = replace(snapshot.picks), + lanes = snapshot.lanes.map { it.copy(titles = replace(it.titles), fullTitles = replace(it.fullTitles)) }, + seeds = snapshot.seeds.map { it.copy(items = replace(it.items)) }) + title?.takeIf { it.key == item.key }?.let { title = it.copy(ratings = ratings) } + revision++ + } + val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn val canRequest: Boolean get() = when (snapshot.status) { DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true else -> false } + val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest val heroes: List get() { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { @@ -88,7 +124,7 @@ class DiscoverState( val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } - val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null + val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null override val cardKey: String? get() = when { overlay != null || title != null -> null @@ -127,7 +163,7 @@ class DiscoverState( grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings, onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = { scope.launch { - snapshot = operations.refresh(); revision++ + loadLane(lane.id) if (collection !== grid) return@launch val fresh = lanes.firstOrNull { it.id == lane.id } if (canRequest && fresh != null) { @@ -142,6 +178,16 @@ class DiscoverState( }).apply { tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } } + if (onMore != null && lane.tail != DiscoverTail.None) grid.pagination = LibraryPagination(grid) { + loadLane(lane.id) + val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable") + if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable") + titles = fresh.fullTitles.associateBy { it.key } + collectionCards = LinkedHashMap(titles.size).apply { + titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } + } + LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.tail != DiscoverTail.None) + } collection = grid onPush("discover-see-all/${lane.id}") } @@ -217,7 +263,7 @@ class DiscoverState( if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return } val item = lane.titles.getOrNull(column) if (item == null) { - if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh() + if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) scope.launch { loadLane(lane.id) } else openCollection(lane) } else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item) else openTitle(item) @@ -243,7 +289,8 @@ class DiscoverState( fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { if (!canRequest) { refresh(); return } - val choice = RequestSheetState(item, shell.person, shell.people, other, phase) + if (!requestsEnabled) { toast = "Can’t request right now. Try again."; return } + val choice = RequestSheetState(item, shell.person, requesters, other, phase) val previous = requestSheets[item.key to choice.selectedPerson.key] overlay = DiscoverOverlayState.Request( if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice, @@ -276,7 +323,7 @@ class DiscoverState( } private fun send(sheet: RequestSheetState, check: Boolean) { - if (!canRequest || (!check && !sheet.canSubmit)) return + if (!requestsEnabled || (!check && !sheet.canSubmit)) return if (!check) { val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key] if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 2d0a646c..edb0b636 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -80,6 +80,7 @@ fun HomeScreen( view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) } val cache = view.home.ratings + val ratingsReader = LocalScreenRatings.current val row = snapshot.rows.getOrNull(view.row) val title = row?.cards?.getOrNull(focus.column) val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull() @@ -87,13 +88,14 @@ fun HomeScreen( var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) } LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) { if (hero != null && snapshot.conductarr == HomeAvailability.Ready) { - heroRatings = hero.ratings - cache.save(hero, heroRatings) + val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) } + if (values != null) { heroRatings = values; cache.save(hero, values) } } if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) { val saved = cache.read(title) if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong()) - focusedRatings = saved ?: title.ratings.also { cache.save(title, it) } + val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode)?.map(LibraryRating::homeRating) } + if (values != null) { focusedRatings = values; cache.save(title, values) } } } val showRatings = snapshot.conductarr != HomeAvailability.SignedOut diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 42cede9a..c8eb77fd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -49,6 +49,7 @@ fun LibraryScreen( val focused = grid.focusedTitle val count = if (grid.catalogue) items.size else grid.source.size var plateKey by remember { mutableStateOf(null) } + val ratingsReader = LocalScreenRatings.current DisposableEffect(shell, grid, registerFocus) { if (registerFocus) shell.contentFocus = grid onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } @@ -72,6 +73,11 @@ fun LibraryScreen( plateKey = null if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) { if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (ratingsReader != null) { + val tmdb = focused.tmdbId?.toLongOrNull() ?: focused.id.substringAfter(':', "").toLongOrNull() + val ratings = tmdb?.let { ratingsReader.read(it, focused.type, null, null) } + if (ratings != null) grid.source = grid.source.map { if (it.id == focused.id) it.copy(ratings = ratings) else it } + } if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id) plateKey = focused.id } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt new file mode 100644 index 00000000..65e8ebe1 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.compositionLocalOf + +/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */ +fun interface ScreenRatings { + suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List? +} + +val LocalScreenRatings = compositionLocalOf { null } + +/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */ +fun LibraryRating.homeRating(): HomeRating = HomeRating( + mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } }, + score = score, percentage = source.startsWith("rt"), episode = source == "episode", +) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 58aa4c95..819d0936 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue /** Household identity data contains no credentials or server objects. */ -data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean) +data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean, val jellyfinUserId: String? = null) /** The shell owns keys; the integration owns navigation and authenticated user changes. */ sealed interface ShellAction { @@ -42,10 +42,21 @@ class RememberedView { /** Focus and Back transitions are independent of drawing and server availability. */ class ShellState( - val people: List, + people: List, initialPerson: String, private val onAction: (ShellAction) -> Unit = {}, ) { + var people by mutableStateOf(people) + private set + + /** Refresh household labels without discarding this person's navigation memory. */ + fun updatePeople(value: List) { + val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } + ?: value.firstOrNull { it.key == person.key } + people = value + if (selected != null) person = selected + pickerIndex = people.indexOf(person).coerceAtLeast(0) + } var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) private set var route by mutableStateOf("home") -- 2.49.1 From 09f1d72f30f6035910a7adf7749f52680a2c2d9f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 00:31:48 +0100 Subject: [PATCH 3/5] Keep Home responsive and resolve authorized request recipients --- .../wolfskin/tv/ConductarrHomeSurfaceTest.kt | 22 +++++++++------ .../wolfskin/tv/ConductarrSessionTest.kt | 2 +- .../wolfskin/tv/ConductarrSurfaceTest.kt | 7 +++-- .../wolfskin/tv/ConductarrTitleSurfaceTest.kt | 17 +++++++++--- .../wolfskin/tv/FakeConductarr.kt | 3 ++- .../wolfskin/tv/ConductarrProvider.kt | 27 ++++++++++++++++++- .../wolfskin/tv/ConductarrSession.kt | 2 +- .../justinvisser/wolfskin/tv/DiscoverHost.kt | 12 +++++++-- .../wolfskin/tv/HomeDestination.kt | 16 +++++++---- .../justinvisser/wolfskin/tv/TitleRequests.kt | 8 +++++- docker/README.md | 7 +++++ .../wolfskin/design/DiscoverState.kt | 12 ++++++++- .../wolfskin/design/RequestSheetState.kt | 14 ++++++++-- 13 files changed, 120 insertions(+), 29 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index f22642a5..13ac36d4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -9,6 +9,9 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onRoot +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -21,7 +24,6 @@ import java.io.File import java.util.UUID import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.last -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Rule @@ -50,7 +52,8 @@ class ConductarrHomeSurfaceTest { session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } val home = mockk() - coEvery { home.read(user.id) } returns HomeLibrary() + coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf( + HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) @@ -59,15 +62,18 @@ class ConductarrHomeSurfaceTest { rule.setContent { WolfskinTheme { LaunchedEffect(model) { model.loadLibrary(server, user, "shared") } val snapshot = model.snapshot - LaunchedEffect(snapshot.rows) { - val index = snapshot.rows.indexOfFirst { it.catalogue && it.cards.any { card -> card.title == "Moon" } } - if (index >= 0) { shell.view.row = index; shell.view.home.focusedKey = snapshot.rows[index].id; shell.navFocused = false } - } - CompositionLocalProvider(LocalScreenRatings provides ConductarrRatings(session.identity.value!!.client)) { + CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) { ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } } } } - rule.waitUntil(10_000) { model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } } + rule.waitUntil(timeoutMillis = 30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } + } + rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" } + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + rule.mainClock.advanceTimeBy(500) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held }) val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt index 4a1e952f..3027d5fc 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt @@ -69,7 +69,7 @@ class ConductarrSessionTest { fake.status = 200 fake.ratingsStatus = 401 val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null) - assertNull(ratings) + assertTrue(ratings!!.isEmpty()) assertFalse(reopened.identity.value!!.signedIn) assertNull(reopened.identity.value!!.operator) assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt index 4bb35f9f..265dcbd9 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt @@ -45,11 +45,14 @@ class ConductarrSurfaceTest { 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) }) { + CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) { WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } } } - rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.Ready } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + current.snapshot.status == DiscoverStatus.Ready + } rule.onNodeWithText("Moon").assertIsDisplayed() rule.mainClock.autoAdvance = false rule.runOnIdle { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index aef205b9..e41862d8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -52,7 +52,7 @@ class ConductarrTitleSurfaceTest { val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true)) try { val route = titleRoute("movie:42") - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply { + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false; enterDestination(route) } val art = mutableMapOf() @@ -67,7 +67,10 @@ class ConductarrTitleSurfaceTest { rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false) } } } - rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedIn } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" } + } rule.onNodeWithText("Moon").assertIsDisplayed() assertFalse(viewModel.title!!.options.any { it.id == "play" }) val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } @@ -75,7 +78,10 @@ class ConductarrTitleSurfaceTest { rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } - rule.waitUntil(10_000) { viewModel.title?.data?.requestActor == "Shared" } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.requestActor == "Shared" + } assertEquals("Mindel", viewModel.title!!.data.requestFor) rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed() val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" } @@ -86,7 +92,10 @@ class ConductarrTitleSurfaceTest { rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } fake.ratingsStatus = 401 runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } - rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable + } assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) } finally { viewModel.viewModelScope.cancel() } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index 4173d914..6170f043 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -75,7 +75,8 @@ internal class FakeConductarr : Closeable { if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map { JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore))) }))) - return response(if (localArt) localizeImages(body).toString() else body.toString()) + val responseBody = if (localArt) localizeImages(body).toString() else body.toString() + return response(responseBody) } } server.start() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt index 60c96798..922b5b4e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -9,6 +9,10 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.justinvisser.wolfskin.conductarr.* import org.justinvisser.wolfskin.conductarr.model.* import org.justinvisser.wolfskin.design.* @@ -21,10 +25,15 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider private val services = mutableMapOf() @Volatile private var libraryAudiences = emptyList() private var requestOperations: ConductarrDiscoverOperations? = null + private val profilesMutex = Mutex() + private var observedProfiles: RecommendationProfiles? = null + private val profileNames = MutableStateFlow>(emptyList()) + val requesterProfiles = profileNames.asStateFlow() /** One uncertain-submission ledger survives screen/navigation changes for this identity. */ fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) { requestOperations ?: ConductarrDiscoverOperations(client, { + profiles() discover(operator.recommendationProfile, force = true).lastOrNull() ?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) }, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia) @@ -101,7 +110,12 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider } } - suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles()) + suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock { + observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also { + observedProfiles = it + profileNames.value = it.profiles.orEmpty() + } + } suspend fun audiences(): List { libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() return libraryAudiences @@ -110,6 +124,17 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider fun holders(title: Title): Set = title.discoverTitle(libraryAudiences).heldBy } +/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */ +internal fun ConductarrIdentity.requesters(profiles: List, saved: List): List { + val allowed = operator?.capabilities?.requesters.orEmpty() + return buildMap { + saved.filter { it.key in allowed }.forEach { put(it.key, it) } + profiles.filter { it.key in allowed }.forEach { profile -> + put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false)) + } + }.values.toList() +} + /** A batch tracks source failure without replacing saved bodies with empty success. */ internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) { @Volatile var failed = false diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt index e0ace3ac..64cdba69 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -39,7 +39,7 @@ class ConductarrSession(private val cache: CacheStore) : Closeable { selection = fingerprint if (token.isNullOrBlank()) return val client = try { - ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), onEvent = { event -> + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) { mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index 550527d2..8b11ee3a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -27,6 +27,13 @@ internal fun rememberDiscoverState( val context = LocalContext.current val scope = rememberCoroutineScope() val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf>() } + val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() + val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) { + if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty() + } + LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { + if (!fixtures && identity?.signedIn == true) identity.provider.profiles() + } val refresh: suspend () -> DiscoverSnapshot = { if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) else { retrySignIn(); DiscoverSnapshot() } @@ -37,7 +44,7 @@ internal fun rememberDiscoverState( 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() }, + requesters = requesters, onMore = if (fixtures || identity?.signedIn != true) null else { lane -> val service = loaded.service if (service == null) identity.provider.more(lane) @@ -48,6 +55,7 @@ internal fun rememberDiscoverState( }) loaded } + SideEffect { if (!fixtures) state.updateRequesters(requesters) } var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf(null) } LaunchedEffect(state, fixtures) { if (fixtures) { @@ -68,7 +76,7 @@ internal fun rememberDiscoverState( } } 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) -> + if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> val service = state.service if (service == null) { state.updateLanes(identity.provider.readAhead(lane)) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index eba7ef13..034f46a7 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -53,7 +53,6 @@ class WolfskinHomeViewModel @Inject constructor( 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() } loadLibrary(server, user, person, retry) } @@ -77,7 +76,7 @@ class WolfskinHomeViewModel @Inject constructor( var libraryAvailable = true var librarySavedAt: String? = null val identity = conductarr.identity.value - var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) + var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) fun publish() { if (revision != loadRevision || sourceScope != scope) return val status = when (catalogueCopy.status) { @@ -92,17 +91,24 @@ class WolfskinHomeViewModel @Inject constructor( catalogueSnapshot = catalogueCopy publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } if (saved != null) publish() - if (identity?.signedIn == true) catalogueJob = viewModelScope.launch { - identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue -> + if (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch { + if (retry && !identity.signedIn) conductarr.authenticate() + val source = conductarr.identity.value + if (source == null || source.client !== identity.client) return@launch + if (!source.signedIn) { + publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable)) + return@launch + } + source.provider.discover(source.operator!!.recommendationProfile, retry).collect { catalogue -> if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) } } try { val library = withContext(Dispatchers.IO) { home.read(user.id) } val savedAt = System.currentTimeMillis() - withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* A disk failure must not turn a reachable source into an outage. */ } } libraryCopy = library publish() + withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } } catch (exception: Exception) { if (exception is CancellationException) throw exception if (revision != loadRevision) return diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt index 0a1ad443..fda961a9 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -9,15 +9,21 @@ import org.justinvisser.wolfskin.design.* internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String, identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? { val scope = rememberCoroutineScope() + val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() + val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() } + LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { + if (!fixtures && identity?.signedIn == true) identity.provider.profiles() + } val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute, identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn) else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) { identity?.takeIf { it.signedIn }?.let { DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!), scope, viewModel::openRoute, viewModel::popRoute, - requesters = shell.people.filter { person -> person.key in it.operator.capabilities.requesters.orEmpty() }) + requesters = requesters) } } + SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } LaunchedEffect(requests, title?.data) { val data = title?.data ?: return@LaunchedEffect if (!fixtures && requests != null && data.item.tmdbId != null) { diff --git a/docker/README.md b/docker/README.md index ea8ec610..ffaa984a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -459,6 +459,13 @@ the old client and in-memory providers. LKG files are isolated by person and cli identity in app-private, backup-excluded storage. A 401 clears that identity's cache and returns to sign-in states; Try again reconstructs and verifies the client. +Home publishes Jellyfin/LKG rows independently of Conductarr, including retry +authentication, and paints new Jellyfin data before writing its disk copy. +Production calls use 3-second connect, 5-second read and 8-second total limits. +Request recipients include authorized Conductarr profiles without saved Jellyfin +logins; that does not create selectable TV logins. Late name reads preserve the +open chooser's selected recipient and footer focus. + | Screen seam | Production supplier | | --- | --- | | People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 482687b9..47ec35ff 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -19,12 +19,22 @@ class DiscoverState( private val onPush: (String) -> Unit = {}, private val onPop: () -> Unit = {}, private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, - private val requesters: List = shell.people, + requesters: List = shell.people, private val onMore: (suspend (String) -> List?)? = null, private val nowMillis: () -> Long = System::currentTimeMillis, ) : ShellContentFocus { var snapshot by mutableStateOf(initial) private set + private var requesters = requesters + + fun updateRequesters(people: List) { + if (requesters == people) return + requesters = people + requestSheets.values.forEach { it.updatePeople(people) } + (overlay as? DiscoverOverlayState.Request)?.state?.let { current -> + if (requestSheets.values.none { it === current }) current.updatePeople(people) + } + } private var savedTime = initial.savedAt ?: formatSavedTime() private var serviceFilter by mutableStateOf("movie") var filter: String diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt index 6e9bd517..4ab9cc13 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt @@ -20,10 +20,11 @@ class RequestSheetState( other: Boolean, initialPhase: RequestPhase = RequestPhase.Choose, ) { - val people = listOf(viewer) + people.filter { it.key != viewer.key } + var people by mutableStateOf(listOf(viewer) + people.filter { it.key != viewer.key }) + private set val held = viewer.key in title.heldBy val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty() - val buttonIndex = this.people.size + val buttonIndex: Int get() = people.size var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0) var phase by mutableStateOf(initialPhase) var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected) @@ -36,6 +37,15 @@ class RequestSheetState( else -> "Request for ${selectedPerson.name}" } + fun updatePeople(value: List) { + val selectedKey = selectedPerson.key + val focusedKey = people.getOrNull(focus)?.key + val footerOffset = focus - buttonIndex + people = listOf(viewer) + value.filter { it.key != viewer.key } + selected = people.indexOfFirst { it.key == selectedKey }.coerceAtLeast(0) + focus = if (footerOffset >= 0) buttonIndex + footerOffset else people.indexOfFirst { it.key == focusedKey }.coerceAtLeast(0) + } + fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) { if (key == ShellKey.Back) { close(); return } when (phase) { -- 2.49.1 From 34ea983a5117d1a2720124bca782a8e1814b9d95 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 01:25:47 +0100 Subject: [PATCH 4/5] Scope artwork credentials and preserve library recovery --- .../tv/ConductarrAudienceRecoveryTest.kt | 70 ++++++++++++++ .../wolfskin/tv/ConductarrProjectionTest.kt | 2 +- .../wolfskin/tv/FakeConductarr.kt | 4 + .../wolfskin/tv/WolfskinArtworkTest.kt | 91 +++++++++++++++++++ .../wolfskin/tv/ConductarrProvider.kt | 10 +- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 15 ++- .../wolfskin/tv/WolfskinArtwork.kt | 36 ++++++++ docker/README.md | 18 +++- .../wolfskin/design/WolfskinImages.kt | 28 +++++- 9 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt new file mode 100644 index 00000000..00f970b1 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ConductarrAudienceRecoveryTest { + @Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) } + @After fun restoreMain() { Dispatchers.resetMain() } + + @Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + for (empty in listOf(false, true)) FakeConductarr().use { fake -> + fake.audiencesStatus = if (empty) 200 else 503 + fake.emptyAudiences = empty + ConductarrSession(MemoryCacheStore()).use { session -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + session.authenticate() + assertTrue(session.identity.value!!.signedIn) + val client = session.identity.value!!.client + val libraries = LibraryReadFacade(mockk()) + val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + try { + requireNotNull(model.onForeground(server, user)).join() + assertNull(libraries.audiences) + assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES)) + assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS)) + assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) + if (!empty) { + fake.audiencesStatus = 200 + requireNotNull(model.onForeground(server, user)).join() + assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries) + assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) + assertSame(client, session.identity.value!!.client) + } + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt index 2bcafe31..b5be3c2b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -15,7 +15,7 @@ class ConductarrProjectionTest { 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) + 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) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index 6170f043..b682b34c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -22,6 +22,8 @@ internal class FakeConductarr : Closeable { @Volatile var existingRequests = false @Volatile var notModified = false @Volatile var ratingsStatus = 200 + @Volatile var audiencesStatus = 200 + @Volatile var emptyAudiences = false @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false private val imageBytes = java.util.Base64.getDecoder().decode( @@ -61,9 +63,11 @@ internal class FakeConductarr : Closeable { else -> return response("", 404) } if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) + if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") var body = wire.getValue(key).jsonObject + if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList()))) if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt new file mode 100644 index 00000000..2e248969 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.request.SuccessResult +import coil3.imageLoader +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.CoilConfig +import io.mockk.every +import io.mockk.mockk +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import okio.Buffer +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.WolfskinImages +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WolfskinArtworkTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() { + MockWebServer().use { jellyfin -> + MockWebServer().use { artwork -> + jellyfin.start(); artwork.start() + val context = rule.activity + val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\"" + val authenticated = OkHttpClient.Builder().addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build()) + }.build() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null) + val repository = mockk { every { currentServer } returns server } + val network = WolfskinArtwork(authenticated, repository) + val bytes = ByteArrayOutputStream().also { + Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it) + }.toByteArray() + jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + val urls = listOf(jellyfin.url("/jellyfin.png"), + artwork.url("/public.png").newBuilder().host("127.0.0.1").build(), + artwork.url("/another-port.png")) + val results = CopyOnWriteArrayList() + try { + rule.setContent { + CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false) + LaunchedEffect(network) { + // Force the upstream authenticated parent to exist before configuring Wolfskin. + context.applicationContext.imageLoader + network.configure(context) + val loader = WolfskinImages.loader(context) + for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString()) + .memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build())) + } + } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + results.size == urls.size + } + results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) } + assertEquals(credential, jellyfin.takeRequest().headers["Authorization"]) + assertNull(artwork.takeRequest().headers["Authorization"]) + assertNull(artwork.takeRequest().headers["Authorization"]) + } finally { + WolfskinImages.loader(context).shutdown() + authenticated.dispatcher.executorService.shutdown() + authenticated.connectionPool.evictAll() + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt index 922b5b4e..06caea9c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -116,9 +116,13 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider profileNames.value = it.profiles.orEmpty() } } - suspend fun audiences(): List { - libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() - return libraryAudiences + /** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */ + suspend fun audiences(): List? { + val reads = SnapshotReads(client, ReadTrigger.IF_STALE) + val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() } + if (reads.failed || observed == null) return null + libraryAudiences = observed + return observed } fun holders(title: Title): Set = title.discoverTitle(libraryAudiences).heldBy diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index f1c28006..781f55dd 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -75,7 +75,9 @@ class WolfskinShellViewModel @Inject constructor( val libraries: LibraryReadFacade, val seeAll: WolfskinSeeAllLists, val conductarr: ConductarrSession, + artwork: WolfskinArtwork, ) : ViewModel() { + init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow private val requestsSnapshot by lazy { readShellRequests(context) } var libraryDestinations by mutableStateOf>(emptyMap()) @@ -119,18 +121,26 @@ class WolfskinShellViewModel @Inject constructor( 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() } + audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } + ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } 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()) }) + hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime) } session.state?.updatePeople(updated) signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson } + /** Foreground recovery retains the current identity and any usable audience mapping. */ + fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? { + if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null + identityJob?.cancel() + return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it } + } + suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) loadHousehold(user) @@ -222,6 +232,7 @@ fun WolfskinApp( ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } + androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } LaunchedEffect(identity?.signedIn, identity?.person) { if (!ConductarrPreference.fixtures) { if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt new file mode 100644 index 00000000..e546a09b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import coil3.ComponentRegistry +import coil3.network.ConnectivityChecker +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import javax.inject.Inject +import javax.inject.Singleton +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import org.justinvisser.wolfskin.design.WolfskinImages + +/** Strip credentials after upstream auth injection, including each redirect's network request. */ +@OptIn(coil3.annotation.ExperimentalCoilApi::class) +@Singleton +class WolfskinArtwork @Inject constructor( + @param:AuthOkHttpClient client: OkHttpClient, + repository: ServerRepository, +) { + private val artworkClient = client.newBuilder().addNetworkInterceptor { chain -> + val request = chain.request() + val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull() + val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme && + request.url.host == jellyfin.host && request.url.port == jellyfin.port + chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build()) + }.build() + private val configure: ComponentRegistry.Builder.() -> Unit = { + add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } })) + } + + /** Install host-scoped networking before any Wolfskin artwork is rendered. */ + fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure) +} diff --git a/docker/README.md b/docker/README.md index ffaa984a..b1ea4087 100644 --- a/docker/README.md +++ b/docker/README.md @@ -466,6 +466,13 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin logins; that does not create selectable TV logins. Late name reads preserve the open chooser's selected recipient and footer focus. +Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's +authenticated fetcher. The app's network interceptor removes Authorization from +every non-Jellyfin artwork origin (scheme, host and port), including redirects; +current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps +the existing mapping or Jellyfin name rule. Returning to the foreground retries +household metadata without rebuilding the selected identity. + | Screen seam | Production supplier | | --- | --- | | People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | @@ -485,6 +492,10 @@ server tests use representative wire JSON, never a live Conductarr or Jellyfin. isolation, 401/retry, saved offline data, actor-safe requests, ratings timing, title playback permissions/episode votes and native real-provider surfaces. The existing fixture screen tests remain in the normal `make test` gate. +`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts +and a same-host/different-port origin with an authenticated upstream parent, +and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers +operator success plus failed/empty audiences, name-rule retention and foreground healing. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. @@ -499,13 +510,16 @@ Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin. 1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared, then another saved person; verify their operator/requester and independent views. + Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or + IP addresses are refused even with the correct token. 2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast member and filmography credit; check real facts, related titles, episode votes, stage/actor labels, card dwell ratings and continuous See all loading. 3. Make the sandbox return 503 after a successful read. Reopen: saved content and Saved time remain, requests cannot submit, and Try again performs another read. -4. Return 401, then restore authentication. Verify ratings/request actions disappear; - Try again restores data without changing Jellyfin login or copying a token. +4. Return 401, then restore authentication. Verify ratings/request actions disappear + and this person's saved Conductarr data is cleared. Try again restores data + without changing Jellyfin login or copying a token. 5. Have the sandbox accept a Shared-on-behalf request but lose its POST response. Use Check again: the receipt says Shared, and the sandbox records one POST. 6. Restore the blank/default URL when the dedicated TV hostname and server's diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index d89254f9..4e0822bf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,11 +1,11 @@ package org.justinvisser.wolfskin.design import android.content.Context +import coil3.ComponentRegistry import coil3.ImageLoader import coil3.disk.DiskCache import coil3.intercept.Interceptor import coil3.disk.directory -import coil3.imageLoader import coil3.request.crossfade import coil3.memory.MemoryCache import coil3.request.ImageRequest @@ -18,21 +18,43 @@ import kotlinx.coroutines.sync.withPermit object WolfskinImages { private var application: Context? = null private var loader: ImageLoader? = null + private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 private val readPermits = Semaphore(IMAGE_READS) + /** The app supplies authenticated networking without exposing its server/session to design code. */ + @Synchronized + fun configureNetwork(context: Context, configure: ComponentRegistry.Builder.() -> Unit) { + val owner = context.applicationContext + if (application === owner && networkConfiguration === configure) return + loader?.shutdown() + loader = null + application = owner + networkConfiguration = configure + } + @Synchronized fun loader(context: Context): ImageLoader { val owner = context.applicationContext if (application !== owner) { loader?.shutdown() + loader = null application = owner + networkConfiguration = null + } + if (loader == null) { val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS) - loader = owner.imageLoader.newBuilder() + // Parent components may attach a Jellyfin token to every HTTP host. + val builder = ImageLoader.Builder(owner) + loader = builder .memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() } .diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() } - .components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) } + .components { + // Coil replaces this registry; install networking and bounds together. + networkConfiguration?.invoke(this) + add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) + } .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build() } return loader!! -- 2.49.1 From ec32346c71f6fcbb966653959ab31acbff3db399 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 01:32:59 +0100 Subject: [PATCH 5/5] Isolate the artwork test singleton lifecycle --- .../org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt index 2e248969..9e4d1118 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.test.junit4.createAndroidComposeRule import coil3.request.SuccessResult import coil3.imageLoader +import coil3.SingletonImageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest import com.github.damontecres.wholphin.data.ServerRepository @@ -38,6 +39,7 @@ import org.robolectric.annotation.GraphicsMode class WolfskinArtworkTest { @get:Rule val rule = createAndroidComposeRule() + @OptIn(coil3.annotation.DelicateCoilApi::class) @Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() { MockWebServer().use { jellyfin -> MockWebServer().use { artwork -> @@ -60,7 +62,10 @@ class WolfskinArtworkTest { artwork.url("/public.png").newBuilder().host("127.0.0.1").build(), artwork.url("/another-port.png")) val results = CopyOnWriteArrayList() + val previousParent = context.applicationContext.imageLoader try { + // Other screen tests may already have created Coil's process singleton. + SingletonImageLoader.reset() rule.setContent { CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false) LaunchedEffect(network) { @@ -82,6 +87,9 @@ class WolfskinArtworkTest { assertNull(artwork.takeRequest().headers["Authorization"]) } finally { WolfskinImages.loader(context).shutdown() + val testParent = context.applicationContext.imageLoader + SingletonImageLoader.setUnsafe(previousParent) + testParent.shutdown() authenticated.dispatcher.executorService.shutdown() authenticated.connectionPool.evictAll() } -- 2.49.1