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