diff --git a/contracts/api/v2/fixtures/admin_request_settings_ok.json b/contracts/api/v2/fixtures/admin_request_settings_ok.json index 57c4a3421f..3bb785c45c 100644 --- a/contracts/api/v2/fixtures/admin_request_settings_ok.json +++ b/contracts/api/v2/fixtures/admin_request_settings_ok.json @@ -3,5 +3,6 @@ "global_max_requests": 5, "global_window_days": 7, "global_auto_approval_enabled": false, - "force_dual_quality": false + "force_dual_quality": false, + "watchlist_requests": false } diff --git a/contracts/api/v2/fixtures/admin_requests_ok.json b/contracts/api/v2/fixtures/admin_requests_ok.json index 551cd3f907..5a5689a62c 100644 --- a/contracts/api/v2/fixtures/admin_requests_ok.json +++ b/contracts/api/v2/fixtures/admin_requests_ok.json @@ -12,6 +12,7 @@ "state": "approved", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -49,6 +50,7 @@ "state": "approved", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", @@ -86,6 +88,7 @@ "state": "approved", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "integration_kind": "radarr", diff --git a/contracts/api/v2/fixtures/cancel_request_ok.json b/contracts/api/v2/fixtures/cancel_request_ok.json index 212803d679..236bf729f1 100644 --- a/contracts/api/v2/fixtures/cancel_request_ok.json +++ b/contracts/api/v2/fixtures/cancel_request_ok.json @@ -10,6 +10,7 @@ "state": "approved", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "is_anime": false, diff --git a/contracts/api/v2/fixtures/create_request_ok.json b/contracts/api/v2/fixtures/create_request_ok.json index 9f05dc9465..b05c1137cb 100644 --- a/contracts/api/v2/fixtures/create_request_ok.json +++ b/contracts/api/v2/fixtures/create_request_ok.json @@ -10,6 +10,7 @@ "state": "pending", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "is_anime": false, diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index 3a5fecd6c6..fe2e88b9a9 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "a090db5a3b9114c65e340fd57ad7ca081b7452b01ab4b4d56d72f9ffc338b81b", + "contract_digest": "e58dc6fe806fc60826b6595b267edee71fd80e6eeffc25c72b83cff93ae46e37", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/contracts/api/v2/fixtures/list_my_requests_ok.json b/contracts/api/v2/fixtures/list_my_requests_ok.json index 67a73c0b0a..ad66ba543f 100644 --- a/contracts/api/v2/fixtures/list_my_requests_ok.json +++ b/contracts/api/v2/fixtures/list_my_requests_ok.json @@ -12,6 +12,7 @@ "state": "pending", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "is_anime": false, @@ -42,6 +43,7 @@ "state": "processing", "seasons": [], "season_progress": [], + "source": "direct", "requested_by_user_id": "1", "requested_by_profile_id": "p-owner", "is_anime": false, diff --git a/contracts/api/v2/fixtures/request_status_ok.json b/contracts/api/v2/fixtures/request_status_ok.json index a1bb72355b..c4d580e8bc 100644 --- a/contracts/api/v2/fixtures/request_status_ok.json +++ b/contracts/api/v2/fixtures/request_status_ok.json @@ -1,5 +1,5 @@ { - "revision": "dc6458db8af9354952b69baa1805dcb12a4cee23a40517a0dea2c3f02e441bb2", + "revision": "fb0dfe10f9b39bc2dee8429d9de0d4ccdb81f95d14d391b885bdabf233665f48", "state": "available", "allowed": true, "requests_enabled": true, @@ -7,5 +7,7 @@ "follow_supported": true, "season_requests_supported": true, "missing_seasons_requestable": false, - "download_progress_supported": true + "download_progress_supported": true, + "watchlist_titles_supported": false, + "watchlist_requests": false } diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 633adfc0a6..f7d2ed4453 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -12447,6 +12447,13 @@ }, "requests_enabled": { "type": "boolean" + }, + "watchlist_requests": { + "description": "Adding a title that is not in the library to a watchlist also requests it; absent on update keeps the stored value", + "examples": [ + true + ], + "type": "boolean" } }, "required": [ @@ -24698,7 +24705,7 @@ "type": "boolean" }, "download_progress_supported": { - "description": "Whether the server reports download progress (download on requests, their targets, and the title detail's request state). Whether a given request has any depends on its download server's request plugin.", + "description": "Whether the server reports download progress (download on requests, their targets, the title detail's request state, and watchlist titles). Whether a given request has any depends on its download server's request plugin.", "type": "boolean" }, "follow_supported": { @@ -24730,6 +24737,14 @@ "unsupported" ], "type": "string" + }, + "watchlist_requests": { + "description": "Whether adding a title the library doesn't have to the watchlist also requests it (or follows its request) for this viewer: requests and watchlist requests are on for the server, the profile has not opted out, and the viewer may request.", + "type": "boolean" + }, + "watchlist_titles_supported": { + "description": "Whether the server keeps watchlist entries for titles the library doesn't have: the /watchlist/titles operations, and in_watchlist on discovery results and the title detail. False while requests are disabled, when those operations answer 409 capability_disabled; the entries are kept.", + "type": "boolean" } }, "required": [ @@ -24739,6 +24754,8 @@ "season_requests_supported", "missing_seasons_requestable", "download_progress_supported", + "watchlist_titles_supported", + "watchlist_requests", "revision", "state", "allowed" @@ -28673,6 +28690,13 @@ }, "type": "array" }, + "source": { + "description": "What created the request: direct (the Request button or an API create) or watchlist (adding a title that is not in the library to a watchlist); more values may be added", + "examples": [ + "direct" + ], + "type": "string" + }, "state": { "description": "The one state to show a user: pending, approved, processing, partially_available (some requested seasons are in the library), available (in the library), declined, cancelled or failed", "examples": [ @@ -28742,6 +28766,7 @@ "state", "seasons", "season_progress", + "source", "is_anime", "targets", "created_at", @@ -36533,6 +36558,13 @@ ], "type": "string" }, + "in_watchlist": { + "description": "Whether the title is on the viewing profile's watchlist, as a watchlist title or through its catalog item", + "examples": [ + false + ], + "type": "boolean" + }, "last_air_date": { "description": "Calendar date, YYYY-MM-DD", "type": "string" @@ -36666,6 +36698,7 @@ "recommendations", "availability", "request", + "in_watchlist", "seasons" ], "type": "object" @@ -36724,6 +36757,13 @@ "description": "TMDB image path", "type": "string" }, + "in_watchlist": { + "description": "Whether the title is on the viewing profile's watchlist, as a watchlist title or through its catalog item", + "examples": [ + false + ], + "type": "boolean" + }, "library_content_id": { "description": "The catalog item when the media is available", "examples": [ @@ -36793,7 +36833,8 @@ "tmdb_id", "title", "availability", - "request" + "request", + "in_watchlist" ], "type": "object" }, @@ -36862,7 +36903,7 @@ "properties": { "download": { "$ref": "#/components/schemas/RequestDownload", - "description": "How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) carries it" + "description": "How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) and the watchlist titles (listWatchlistTitles) carry it" }, "following": { "description": "Whether the viewer will be notified when the media becomes available: they requested it or follow it", @@ -44589,6 +44630,167 @@ ], "type": "object" }, + "WatchlistTitle": { + "additionalProperties": false, + "properties": { + "added_at": { + "description": "When the title joined the watchlist", + "examples": [ + "2026-01-02T03:04:05.000Z" + ], + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, + "content_rating": { + "description": "US certification", + "examples": [ + "R" + ], + "type": "string" + }, + "media_type": { + "description": "movie or series", + "examples": [ + "movie" + ], + "type": "string" + }, + "poster_path": { + "description": "TMDB image path", + "examples": [ + "/abc.jpg" + ], + "type": "string" + }, + "release_date": { + "description": "Calendar date, YYYY-MM-DD: the release date of a movie, the first air date of a series", + "examples": [ + "1995-12-15" + ], + "type": "string" + }, + "request": { + "$ref": "#/components/schemas/RequestMediaState", + "description": "The title's request state for the viewer, with download progress while it downloads" + }, + "status": { + "description": "active, needs_review (TMDB deleted the ID and several titles could replace it) or removed (TMDB deleted the ID and nothing replaces it). More values may be added: read an unknown one as active", + "examples": [ + "active" + ], + "type": "string" + }, + "title": { + "examples": [ + "Heat" + ], + "type": "string" + }, + "tmdb_id": { + "description": "The title's current TMDB identifier (external, not a Silo ID)", + "examples": [ + 949 + ], + "format": "int64", + "type": "integer" + }, + "vote_average": { + "description": "TMDB rating out of 10; absent while the title has no votes", + "examples": [ + 7.9 + ], + "format": "double", + "type": "number" + }, + "year": { + "examples": [ + 1995 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id", + "title", + "added_at", + "status", + "request" + ], + "type": "object" + }, + "WatchlistTitleCollection": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/WatchlistTitle" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "WatchlistTitleEntry": { + "additionalProperties": false, + "properties": { + "added_at": { + "description": "When the title joined the watchlist; adding it again keeps the first time", + "examples": [ + "2026-01-02T03:04:05.000Z" + ], + "format": "date-time", + "pattern": "^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[2-9])-", + "patternDescription": "a non-zero RFC 3339 instant", + "type": "string" + }, + "item_id": { + "description": "The catalog item, when the library has the title and the entry went to the library watchlist", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + }, + "media_type": { + "description": "movie or series", + "examples": [ + "movie" + ], + "type": "string" + }, + "request": { + "$ref": "#/components/schemas/RequestMediaState", + "description": "The title's request state after the add, including why a watchlist request was refused" + }, + "tmdb_id": { + "description": "The title's current TMDB identifier (external, not a Silo ID)", + "examples": [ + 949 + ], + "format": "int64", + "type": "integer" + } + }, + "required": [ + "media_type", + "tmdb_id", + "added_at", + "request" + ], + "type": "object" + }, "WebInstallerPrerequisite": { "additionalProperties": false, "properties": { @@ -185333,24 +185535,541 @@ "bearerAuth": [] } ], - "summary": "Delegate a current login session and original room proof for one room handshake.", + "summary": "Delegate a current login session and original room proof for one room handshake.", + "tags": [ + "realtime" + ], + "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "natural_idempotent", + "x-silo-service-backed": true + } + }, + "/api/v2/watch/{id}": { + "get": { + "operationId": "getWatchState", + "parameters": [ + { + "description": "Optional. When present, it must name a profile of the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The stable device identifier used to resolve playback preferences", + "in": "header", + "name": "X-Silo-Device-Id", + "schema": { + "description": "The stable device identifier used to resolve playback preferences", + "examples": [ + "tv-1" + ], + "maxLength": 128, + "type": "string" + } + }, + { + "description": "A movie, episode, audiobook or ebook; a series is not directly playable", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "A movie, episode, audiobook or ebook; a series is not directly playable", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Prefer this file when the item has several versions", + "explode": false, + "in": "query", + "name": "file_id", + "schema": { + "description": "Prefer this file when the item has several versions", + "examples": [ + "42" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Present the item as a member of this library", + "explode": false, + "in": "query", + "name": "library_id", + "schema": { + "description": "Present the item as a member of this library", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Artwork variant to presign; absent picks each surface's default", + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "description": "Artwork variant to presign; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], + "examples": [ + "medium" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchDetail" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get what is needed to play an item: its versions, subtitles, markers and the acting profile's progress.", + "tags": [ + "watch" + ], + "x-silo-class": "profile_scoped", + "x-silo-service-backed": true + } + }, + "/api/v2/watched/{id}": { + "delete": { + "operationId": "unmarkWatched", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Mark an item unwatched for the acting profile; a season or series clears every episode. The server chooses the history cutoff when this request runs.", + "tags": [ + "watch" + ], + "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "non_retryable", + "x-silo-service-backed": true + }, + "post": { + "operationId": "markWatched", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "examples": [ + "movie:heat-1995" + ], + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Mark an item watched for the acting profile; a season or series marks every episode. Marking an already watched item is a no-op.", "tags": [ - "realtime" + "watch" ], "x-silo-class": "profile_scoped", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/watch/{id}": { + "/api/v2/watchlist": { "get": { - "operationId": "getWatchState", + "operationId": "listWatchlist", "parameters": [ { - "description": "Optional. When present, it must name a profile of the authenticated account.", + "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", "name": "X-Profile-Id", + "required": true, "schema": { "examples": [ "1" @@ -185370,75 +186089,50 @@ } }, { - "description": "The stable device identifier used to resolve playback preferences", - "in": "header", - "name": "X-Silo-Device-Id", - "schema": { - "description": "The stable device identifier used to resolve playback preferences", - "examples": [ - "tv-1" - ], - "maxLength": 128, - "type": "string" - } - }, - { - "description": "A movie, episode, audiobook or ebook; a series is not directly playable", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "A movie, episode, audiobook or ebook; a series is not directly playable", - "examples": [ - "movie:heat-1995" - ], - "minLength": 1, - "type": "string" - } - }, - { - "description": "Prefer this file when the item has several versions", + "description": "Artwork variant to presign; absent picks each surface's default", "explode": false, "in": "query", - "name": "file_id", + "name": "image_size", "schema": { - "description": "Prefer this file when the item has several versions", + "description": "Artwork variant to presign; absent picks each surface's default", + "enum": [ + "small", + "medium", + "large", + "original" + ], "examples": [ - "42" + "medium" ], - "minLength": 1, "type": "string" } }, { - "description": "Present the item as a member of this library", + "description": "Page size; default 50, maximum 200", "explode": false, "in": "query", - "name": "library_id", + "name": "limit", "schema": { - "description": "Present the item as a member of this library", + "default": 50, + "description": "Page size; default 50, maximum 200", "examples": [ - "1" + 50 ], - "minLength": 1, - "type": "string" + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" } }, { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "Opaque cursor from page.next_cursor", "explode": false, "in": "query", - "name": "image_size", + "name": "cursor", "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", - "enum": [ - "small", - "medium", - "large", - "original" - ], + "description": "Opaque cursor from page.next_cursor", "examples": [ - "medium" + "eyJvIjo1MH0" ], "type": "string" } @@ -185449,7 +186143,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WatchDetail" + "$ref": "#/components/schemas/WatchlistCollection" } } }, @@ -185551,17 +186245,17 @@ "bearerAuth": [] } ], - "summary": "Get what is needed to play an item: its versions, subtitles, markers and the acting profile's progress.", + "summary": "List the acting profile's watchlist as catalog cards, newest entry first; fully-watched series and items the viewer may not see are omitted.", "tags": [ - "watch" + "watchlist" ], "x-silo-class": "profile_scoped", "x-silo-service-backed": true } }, - "/api/v2/watched/{id}": { - "delete": { - "operationId": "unmarkWatched", + "/api/v2/watchlist/titles": { + "get": { + "operationId": "listWatchlistTitles", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -185587,23 +186281,46 @@ } }, { - "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", - "in": "path", - "name": "id", - "required": true, + "description": "Page size; default 50, maximum 200", + "explode": false, + "in": "query", + "name": "limit", "schema": { - "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "default": 50, + "description": "Page size; default 50, maximum 200", "examples": [ - "movie:heat-1995" + 50 + ], + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Opaque cursor from page.next_cursor", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Opaque cursor from page.next_cursor", + "examples": [ + "eyJvIjo1MH0" ], - "minLength": 1, "type": "string" } } ], "responses": { - "204": { - "description": "No Content" + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchlistTitleCollection" + } + } + }, + "description": "OK" }, "400": { "content": { @@ -185655,6 +186372,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -185701,16 +186428,17 @@ "bearerAuth": [] } ], - "summary": "Mark an item unwatched for the acting profile; a season or series clears every episode. The server chooses the history cutoff when this request runs.", + "summary": "List the acting profile's watchlist entries for titles the library doesn't have, newest first. Entries whose title reached the library move to the library watchlist first; titles above the viewer's rating ceiling are omitted.", "tags": [ - "watch" + "watchlist" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - }, - "post": { - "operationId": "markWatched", + } + }, + "/api/v2/watchlist/titles/{media_type}/{tmdb_id}": { + "delete": { + "operationId": "deleteWatchlistTitle", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -185736,18 +186464,36 @@ } }, { - "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "description": "The media type", "in": "path", - "name": "id", + "name": "media_type", "required": true, "schema": { - "description": "A movie, ebook, episode, season or series; a season or series expands to its episodes", + "description": "The media type", + "enum": [ + "movie", + "series" + ], "examples": [ - "movie:heat-1995" + "movie" ], - "minLength": 1, "type": "string" } + }, + { + "description": "TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it", + "in": "path", + "name": "tmdb_id", + "required": true, + "schema": { + "description": "TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it", + "examples": [ + 949 + ], + "format": "int64", + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -185804,6 +186550,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -185850,18 +186606,17 @@ "bearerAuth": [] } ], - "summary": "Mark an item watched for the acting profile; a season or series marks every episode. Marking an already watched item is a no-op.", + "summary": "Remove a title from the acting profile's watchlist by its current or a former TMDB ID, from the library watchlist too, and withdraw the request the watchlist made for it while nothing has been sent. An absent entry succeeds, but automatic retries can repeat provider and refresh effects.", "tags": [ - "watch" + "watchlist" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true - } - }, - "/api/v2/watchlist": { - "get": { - "operationId": "listWatchlist", + }, + "put": { + "operationId": "addWatchlistTitle", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -185887,53 +186642,36 @@ } }, { - "description": "Artwork variant to presign; absent picks each surface's default", - "explode": false, - "in": "query", - "name": "image_size", + "description": "The media type", + "in": "path", + "name": "media_type", + "required": true, "schema": { - "description": "Artwork variant to presign; absent picks each surface's default", + "description": "The media type", "enum": [ - "small", - "medium", - "large", - "original" + "movie", + "series" ], "examples": [ - "medium" + "movie" ], "type": "string" } }, { - "description": "Page size; default 50, maximum 200", - "explode": false, - "in": "query", - "name": "limit", + "description": "TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it", + "in": "path", + "name": "tmdb_id", + "required": true, "schema": { - "default": 50, - "description": "Page size; default 50, maximum 200", + "description": "TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it", "examples": [ - 50 + 949 ], "format": "int64", - "maximum": 200, "minimum": 1, "type": "integer" } - }, - { - "description": "Opaque cursor from page.next_cursor", - "explode": false, - "in": "query", - "name": "cursor", - "schema": { - "description": "Opaque cursor from page.next_cursor", - "examples": [ - "eyJvIjo1MH0" - ], - "type": "string" - } } ], "responses": { @@ -185941,7 +186679,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WatchlistCollection" + "$ref": "#/components/schemas/WatchlistTitleEntry" } } }, @@ -185997,6 +186735,16 @@ }, "description": "Not Acceptable" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "422": { "content": { "application/problem+json": { @@ -186043,11 +186791,13 @@ "bearerAuth": [] } ], - "summary": "List the acting profile's watchlist as catalog cards, newest entry first; fully-watched series and items the viewer may not see are omitted.", + "summary": "Add a title to the acting profile's watchlist. A title the library has goes onto the library watchlist; otherwise it is kept by TMDB ID and, when watchlist requests apply, requested or followed. Automatic retries are unsafe because provider, refresh and request effects are not change-gated.", "tags": [ "watchlist" ], "x-silo-class": "profile_scoped", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, diff --git a/contracts/settings/v1/conformance.json b/contracts/settings/v1/conformance.json index f63d46fa00..5b58f8e6d2 100644 --- a/contracts/settings/v1/conformance.json +++ b/contracts/settings/v1/conformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 14, + "manifest_revision": 15, "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", "cases": [ { diff --git a/contracts/settings/v1/manifest.json b/contracts/settings/v1/manifest.json index 427ab69649..f2252ac4dd 100644 --- a/contracts/settings/v1/manifest.json +++ b/contracts/settings/v1/manifest.json @@ -1,6 +1,6 @@ { "api_version": 1, - "revision": 14, + "revision": 15, "option_sets": { "playback_audio_languages": { "type": "language_tag", @@ -1075,7 +1075,7 @@ "category": "appearance", "label": "Poster badges", "description": "Which badges appear on poster cards, and where.", - "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13." + "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The server-wide default configuration stays in server_settings and is not a user setting. This key describes which badges to show, not whether to show them — that is ui.card_overlays_enabled. Revision 13 adds the advisory_age overlay id; a server below revision 13 rejects a value that contains it, so a client offers that badge only when manifest_revision is at least 13. Revision 15 adds the request_status overlay id (a watchlist title's request status on its poster) under the same rule: a client offers it only when manifest_revision is at least 15." }, { "key": "ui.card_overlays_enabled", @@ -1437,6 +1437,20 @@ "description": "Show the Audiobooks section in navigation.", "recommended_control": "switch", "notes": "Contract-known local: an opt-in navigation surface, hidden by default, with existing Apple (AppNavPreferences.showAudiobooks) and Android parity. Android stores it locally at nav.show_audiobooks.local." + }, + { + "key": "requests.watchlist_auto_request", + "introduced_in": 15, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "requests", + "label": "Request titles I add to my watchlist", + "description": "When you add a title that is not in the library to your watchlist, also request it.", + "recommended_control": "switch", + "notes": "Profile scope because each household member decides whether their own watchlist adds become requests. It applies only while the server-wide watchlist_requests request setting is on and the profile may request at all; a client shows it only then (GET /api/v2/requests/status reports the effective value as watchlist_requests). Request limits, approval and routing apply exactly as for the Request button. Turning it off never cancels an existing request." } ] } diff --git a/contracts/settings/v1/schemas/card-overlays.json b/contracts/settings/v1/schemas/card-overlays.json index 2ea582ec2d..112a5a6a9b 100644 --- a/contracts/settings/v1/schemas/card-overlays.json +++ b/contracts/settings/v1/schemas/card-overlays.json @@ -67,6 +67,7 @@ "rating_rt_audience", "content_rating", "advisory_age", + "request_status", "year", "runtime", "original_language", diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md index 5ea7937400..cebc0d8562 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -1294,7 +1294,10 @@ surfaces read and write one store. Operation ids: `listFavorites`, `getFavorite` membership reads answer a body instead of a bare `204`; `setRating` answers `422` (typed `out_of_range`) where v1 answered `400`; the list responses carry `page` instead of `has_more`; the watchlist list still hides fully-watched series as v1 does; and the v2 access filter carries -no device id because the v2 listener reads no device header. +no device id because the v2 listener reads no device header. `listWatchlist`, +`getWatchlistEntry` and v1 `GET /watchlist` first move watchlist entries for titles +outside the library that have since arrived onto this list; see +[Watchlist titles](#watchlist-titles). The `profiles` section (Phase 4) ports the rest of the household surface around the pilot's `updateProfile`: `listProfiles`, `createProfile`, `deleteProfile`, `listHouseholdSessions`, @@ -1832,10 +1835,61 @@ Request state gains `following` and `requested_by_viewer`, and `GET /api/v2/requests/status` advertises `follow_supported`. The frozen v1 surface has no follow operation and does not carry these fields. +Requests carry `source`: `direct` for a request someone made, `watchlist` for +one a watchlist add made. It is an open set; the admin queue reads it too. +Discovery results (search, the Discover sections and browse operations, and a +title detail's recommendations) and the title detail carry `in_watchlist`, +true when the acting profile has the title on its watchlist, in or out of the +library. + Native Apple and Android request migrations accompany this contract change; integrate those client changes before retiring their v1 routes. Jellyfin compatibility does not expose this request management surface and keeps its existing behavior. +### Watchlist titles + +A profile can keep movies and series the library doesn't have on its watchlist, +by TMDB ID, under the `watchlist` tag: + +- `GET /api/v2/watchlist/titles` (`listWatchlistTitles`) pages the entries by + `limit` and a signed cursor over `(added_at DESC, title id DESC)`, bound to the + account, profile and viewer policy. Each `WatchlistTitle` carries the current + `tmdb_id`, a snapshot for display (`title`, `year`, `release_date`, + `poster_path`, `vote_average`, `content_rating`), `added_at`, `status` and the + viewer's `request` state, with `download` while it downloads. `status` is + `active`, `needs_review` (TMDB now lists the title twice) or `removed` (TMDB + no longer lists it); it is an open set and clients read an unknown value as + `active`. Titles above the viewer's rating ceiling are omitted, and `has_more` + is decided from the raw rows. +- `PUT /api/v2/watchlist/titles/{media_type}/{tmdb_id}` (`addWatchlistTitle`) + answers `200` with `{media_type, tmdb_id, added_at, item_id?, request}`. When + exactly one library item the viewer can see has the title, it is added to the + library watchlist instead and `item_id` names it. A TMDB ID TMDB doesn't have, + or a title above the ceiling, is `404`. The add may also request the title + (below). +- `DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id}` + (`deleteWatchlistTitle`) answers `204` whether or not the entry existed. It + accepts a title's current or former TMDB ID and also removes the matching + library watchlist entry. + +The three operations belong to the requests surface: while requests are off +they answer `409 capability_disabled` and the stored entries are kept. The two +mutations are profile-scoped, demo-restricted and `non_retryable`. +`GET /api/v2/requests/status` advertises `watchlist_titles_supported`, false +while requests are off, and +`watchlist_requests` when an add by the acting profile will also request the +title: requests are on, the admin's `watchlist_requests` setting and the +profile's `requests.watchlist_auto_request` setting are both on, and the +account may request. + +Library reads promote: `listWatchlistTitles`, `listWatchlist`, +`getWatchlistEntry`, a catalog query with `source: "watchlist"`, the home +Watchlist section and item detail `user_state` first move entries whose title +reached the library onto the library watchlist, keeping `added_at`, with the +side effects of a manual add. The frozen v1 routes gain no field. jellycompat +has no view of these entries. There is no new realtime event. See +[External watchlist titles](external-watchlist.md). + ### History imports Seven v2 operations list sources, list/create/read import runs, create/check a Plex @@ -2066,6 +2120,11 @@ limit row has a version-zero default representation; a nonexistent target accoun returns 404. Row locks and conditional upserts arbitrate concurrent writers. Explicit wildcards overwrite atomically; first-party clients never supply them automatically. +The v2 request settings add `watchlist_requests` (default on): whether a +watchlist add of a title outside the library requests it. An update that omits +it keeps the stored value, and so does every v1 settings write; responses +always carry it. + Settings and limits expose only their editable fields (plus the target account ID for limits), without volatile metadata. Integration reads include persisted public configuration and check status, all covered by the row revision, and a credential diff --git a/docs/architecture/external-watchlist.md b/docs/architecture/external-watchlist.md new file mode 100644 index 0000000000..aa0e0b3215 --- /dev/null +++ b/docs/architecture/external-watchlist.md @@ -0,0 +1,346 @@ +# Watchlist titles outside the library + +A profile can put a movie or series on its watchlist before the library has it, +usually from Discover. The entry is kept by TMDB ID, shows on the watchlist +page's "Not in your library yet" tab, and moves onto the normal library +watchlist when the title arrives, keeping its original `added_at`. Where the +server and the profile allow it, adding such a title also requests it. + +The code lives in `internal/watchlist` (`titles.go`, `titles_repo.go`, +`titles_repair.go`), the catalog match in `internal/catalog/provider_aliases.go`, +watchlist requests in `internal/requests/watchlist.go`, the library-watchlist +side in `internal/api/handlers/watchlist_titles.go`, and the v2 operations in +`internal/apiv2/watchlist_titles.go`. + +Scope: movies and series only, native `/api/v2` only. jellycompat has nowhere +to show a title outside the library, and `/api/v1` is frozen. Catalog queries, +smart filters, the calendar, recommendations, Watch Together and the home +Watchlist row read catalog items only and never see these entries. + +## Tables + +- `watchlist_titles`: one row per external title, shared by every profile that + added it, so its IDs are checked once for all of them. It holds the current + TMDB, IMDb and TVDB IDs, a display snapshot from the last TMDB read (title, + year, release date, poster, US certification, `vote_average`), the TMDB + `state` (`active`, `needs_review`, `removed`), and the check bookkeeping + (`not_found_count`, `last_not_found_at`, `checked_at`, `next_check_at`). The + `id` comes from idgen and is never exposed. +- `watchlist_title_aliases`: every provider ID (`tmdb`, `imdb`, `tvdb`) a title + has held, keyed by `(media_type, provider, provider_id)`. Two titles can never + hold the same ID, which is how a duplicate is recognized. A title's former + TMDB ID stays here after a repoint, so the API still finds the title by it. +- `user_watchlist_titles`: one row per profile entry, keyed by + `(user_id, profile_id, title_id)`, with `added_at`. `user_id` cascades from + `users`; there is no profile foreign key because profiles may live in the + SQLite user store. + +`stale_media_ids` gained an index on `(provider, provider_id)`, built +concurrently in its own `NO TRANSACTION` migration, so a lookup by a rejected +value no longer scans the table. + +## Invariants + +1. **No catalog IDs are stored.** None of the three tables stores a + `content_id`, and no column uses a name from the `silo_rename_content_id` + sweep list (`media_item_id`, `content_id`, `item_id`, `series_id`, … in + `migrations/sql/20260614120000_content_id_online_reid.sql`). Re-anchoring, + merging and deleting catalog items never touch these tables. Keep it that way + when adding columns. +2. **The title row is locked first.** Every write that can delete a + `watchlist_titles` row, or attach an entry to one, locks that row before it + touches entries: + - Add: `SELECT … FOR UPDATE` on the title holding the TMDB alias, or create + the title. Creation inserts the TMDB alias with `ON CONFLICT DO NOTHING`; + losing that race restarts the add against the winner's title (at most 3 + attempts). + - Lookup by TMDB alias (add and remove): a merge can delete the title the + lock waited on and move its aliases to the survivor, and the row lock + then comes back empty. An empty result counts as "no title" only when a + fresh statement finds no alias either; otherwise the lock is retried + against the alias's new title (at most 3 attempts). + - Remove: lock the title, delete the entry, then delete the title if no + entry references it. The orphan check is a separate statement after the + lock, so it sees any add it waited on. + - Promote: lock the title and, only while the profile's entry exists, add + the item to the library watchlist, then delete the entry and the orphaned + title before the lock is released. A remove of the same title waits, so it + either runs first (promotion then adds nothing) or runs after the library + entry exists and can take it off. + - Profile purge: lock the profile's titles in id order, delete its entries, + delete the titles left without entries. + - Merge: lock both titles in id order. + + So an add never attaches to a title that a concurrent remove is deleting. +3. **Promotion side effects fire once, and only for a new library entry.** + Only the caller whose `DELETE` removed the entry runs the side effects, and + only when the library watchlist entry is the promotion's own: its + `AddToWatchlistAt` inserted the row, or the existing row carries the entry's + `added_at` (to the second), which is the row a node left behind when it died + between the add and the delete. An item the profile already had on its + library watchlist absorbs the entry silently. +4. **Titles merge only through 404 recovery.** A live TMDB detail never merges + two titles. When a check or detail view reports an IMDb or TVDB ID that is + already another title's alias, that alias is skipped. +5. **Entries are never deleted automatically.** Only the user's remove, + promotion, a merge (which moves the entry to the surviving title), and + profile or user deletion remove an entry. A title TMDB no longer lists keeps + its entries and shows as needing attention. + +### Orphan titles + +Two paths delete entries without the title lock: the Postgres user store's +profile delete (`user_watchlist_titles` is in its cascade list) and user +deletion through the `users` foreign key. Both can leave titles with no +entries. After every profile delete, the profile handler calls +`Titles.PurgeProfile`, which removes that profile's entries under the lock and +then sweeps up to 1000 orphaned titles (`FOR UPDATE SKIP LOCKED`, checked again +after locking). After every account delete (v1 `DELETE /admin/users/{id}` and +the v2 admin account delete), the admin handler runs the same sweep through +`Titles.SweepOrphanTitles`. A sweep failure is logged and the next delete +continues it. + +A profile delete also withdraws the requests the profile's watchlist made that +nothing has been sent for yet (`WithdrawProfileWatchlistRequests`), as removing +each title would. It needs no entries: a request names the profile that made +it and its `source`. Requests already sent stay in the pipeline. + +Until the sweep runs, an orphan's snapshot is not kept current, so lookups by +TMDB ID (`Titles.Find`, which the v2 add uses for its stored snapshot and +rating check, and the TMDB detail observers) skip titles with no entries. An +add that attaches to an orphan stores the caller's snapshot on it and makes its +next check due. + +## Resolution + +`catalog.ItemRepository.ResolveProviderAliases` decides whether the library has +a title. It is computed when needed and never stored. A catalog item matches an +alias when any of these holds: + +- `media_items.tmdb_id`, `imdb_id` or `tvdb_id` equals the alias; +- a `media_item_provider_ids` row equals the alias (same item type); +- a `stale_media_ids` row equals the alias, which catches an item that carries, + or once carried, a TMDB duplicate ID since deleted. + +Only items of the title's media type with a `media_item_libraries` row in an +enabled folder count. Unlike `LookupExternalIDs`, it returns every distinct +match: + +- **One `content_id`:** the library has the title. +- **Several:** the library holds the title twice. Nothing is promoted and a + warning is logged; the entry resolves once the catalog duplicate is merged. + The add path likewise keeps such a title as an entry. +- **None:** the library does not have it. + +`ItemProviderAliases` is the reverse: every current and former TMDB, IMDb and +TVDB ID of one catalog item, used by `PromoteItem`. + +## Promotion + +`Titles.PromoteProfile(viewer)` loads the profile's entries with their aliases +(one index probe when there are none) and resolves them in one query. +`Titles.PromoteItem(viewer, contentID)` does the same for the entries whose +aliases match one catalog item's IDs. Every matched copy is checked against +the viewer's access filter in one `EnsureAccessibleIDs` query, and only the +copies the viewer may see count: a duplicate in a library the viewer can't +open doesn't make the title ambiguous for them. A title with no visible copy +stays external (library or rating limits); one with several waits until the +catalog merges them. For each title with exactly one visible copy: + +1. Lock the title and check the entry still exists (invariant 2). +2. `AddToWatchlistAt(profileID, contentID, entry.added_at)` through the + notification-wrapped user store, so a promoted series queues an interest + recompute. When the row already exists, `GetWatchlistEntry` tells whether + it is a half-done promotion (invariant 3). +3. Delete the entry, and the title if it is now orphaned, then commit. +4. If this call removed the entry and the library entry is new (invariant 3), + run `Effects.WatchlistPromoted`, implemented by + `handlers.PersonalDataHandler`: the provider export event + (`dispatchLocalListEvent`), the recommendations refresh + (`triggerProfileRefresh`) and a realtime `user_state.changed` event with + `change: "watchlist"`. These are the effects of a manual add. + +The library add in step 3 uses a second pool connection while the promotion's +transaction holds one. Each node runs at most 4 such promotions at once, and at +most half the pool's connections, so they cannot exhaust the pool. A pool of +one connection (`database.max_connections: 1`) can't lend that second +connection, so there the library add runs first, after a check that the entry +still exists, and the title is locked afterwards. A remove landing between that +check and the lock can still leave the item on the library watchlist. + +Promotion runs only in request-scoped reads, so a viewer is always available. +A failure is logged and the read continues; the next read retries. + +| Read | Call | +|---|---| +| `GET /api/v2/watchlist/titles` | `PromoteProfile` on the first page only, then the list | +| `GET /api/v2/watchlist` and v1 `GET /watchlist` (`PersonalDataHandler.ListWatchlistPage` / `ListWatchlist`) | `PromoteProfile` on the first page only (no cursor, offset 0) | +| Catalog query with `source: "watchlist"` (`CatalogResolver.resolvePersonalSource`) | `PromoteProfile` | +| Watchlist section in `internal/sections/fetcher.go` (home rows and the recommendations fetcher) | `PromoteProfile` | +| Item detail `user_state.in_watchlist` (`CatalogResourceHandler.enrichViewerState`, movies and series) and `GET /api/v2/watchlist/{item_id}` | `PromoteItem` | + +Other catalog reads (item lists through `ItemsHandler`, people, jellycompat) do +not promote. Calling promotion from v1 changes no v1 contract: the arrived item +simply appears in the list. + +## ID repair without a scheduled task + +There is no task, lease table or queue for watchlist titles. Checks happen only +when someone lists or views a title, and each title is checked once for every +profile that has it. Do not add a scheduled task for this. + +**Cadence.** A title is due when `next_check_at <= now()`. After a successful +check the next one is 1 day away while the release date is unknown, upcoming or +within the last 180 days, and 30 days away otherwise. A `removed` or +`needs_review` title is checked again every 30 days. A non-404 error (TMDB +unreachable) backs off 6 hours and leaves the state alone. + +**Triggers.** + +1. **A TMDB detail is read for another reason.** `requests.Service.GetDetail` + (the Discover title page) and the add path call the injected + `TitleObserver`: `ObservedDetail` after a successful fetch, `ObservedNotFound` + on `tmdb.ErrNotFound`. Each is one alias lookup and writes only when the ID + is a tracked title's current TMDB ID and something changed. Errors are + logged, never returned. +2. **A watchlist titles page is listed.** `ScheduleChecks` takes up to 3 + overdue titles from the page, reserves a per-node slot for each (at most 4 + concurrent checks per node), and claims them with a conditional update that + pushes `next_check_at` out by 1 hour. Only one node wins a title; if it dies, + the claim expires and a later read retries. The checks run detached from the + request with a 30 second timeout. The page renders from the stored snapshot + and never waits for TMDB. + +**A check** fetches the movie or TV detail with `external_ids` (`GetMediaDetail`, +cached briefly by the TMDB client), stores the current IMDb and TVDB IDs, adds +new aliases, refreshes the snapshot, resets the 404 count and sets the next +check time. + +| Case | Detection | Action | +|---|---|---| +| TMDB deleted the ID (a merged duplicate) | Two 404s at least 24 hours apart. The first only schedules the confirming check. | Recover through the stored IMDb aliases, then TVDB, with `tmdb.Client.FindByExternalID` (`/find/{id}?external_source=…`). One candidate of the right media type repoints `tmdb_id`, keeping the old ID as an alias, and refreshes the snapshot. | +| The recovered ID is another title's alias | Alias key conflict | Merge: lock both, move entries keeping the earliest `added_at`, move aliases, delete the losing title. A profile that had both ends with one entry. | +| Several candidates | More than one result | `state = 'needs_review'` | +| No candidates | No results | `state = 'removed'`; the entry stays. | +| IMDb or TVDB ID added or corrected on TMDB | Check or detail observation | Update the current IDs and add aliases, skipping any held by another title (invariant 4). | +| The library imports the title under the dead ID | Resolution matches the old alias | Promoted as usual. | +| TMDB outage | Non-404 error | Back off 6 hours; state unchanged. | + +A title-and-year search is never applied automatically. The web shows +`needs_review` and `removed` titles with a "Find it" link to a Discover search +and a remove button. + +## Watchlist requests + +Adding a title the library doesn't have can also request it, so the existing +`request.fulfilled` notification tells the profile when it arrives. See +[Media requests](media-requests.md#watchlist-requests) for the request side. + +- Server switch: `request_settings.watchlist_requests` (default on), edited in + admin Settings › Requests and carried by the v2 admin request settings. +- Profile opt-out: the settings-contract key `requests.watchlist_auto_request` + (profile scope, default `true`), shown in Settings › Requests. + +An add requests only when requests are on, both switches are on, and the +account may request. `GET /api/v2/requests/status` reports that effective value +as `watchlist_requests`. If the profile's setting cannot be read, the add does +not request. + +The entry is saved first and the request step runs after it, so a refused or +failed request never loses the entry, and a repeated add is a no-op once either +step has succeeded. Removing the title withdraws a request the watchlist made +while it is still withdrawable and drops the profile's follows. + +A request keeps the TMDB ID it was made under; repair never rewrites +`media_requests`. So every request lookup for a title (the add, the card's +request state, the withdrawal) checks the title's current TMDB ID and each +former one, and a follow goes under the request's own ID. Matching a title to +the library also uses every TMDB ID it has had, so a library copy known only by +a replaced ID still counts. + +## API + +The three operations are on `/api/v2` and live on the requests surface: they +answer `409 capability_disabled` while requests are off, and the entries are +kept. Promotion on library reads does not depend on that gate. + +- `GET /api/v2/watchlist/titles` (`listWatchlistTitles`): promotes, then pages + the remaining entries by `(added_at DESC, title id DESC)` with a signed cursor + bound to the account, profile and viewer policy. Titles whose stored US + certification is above the viewer's rating ceiling are omitted (a title with + no certification fails closed under a ceiling). Each item carries its request + state for the viewer, with `download` while it downloads. +- `PUT /api/v2/watchlist/titles/{media_type}/{tmdb_id}` (`addWatchlistTitle`): + a title already tracked under that ID (current or former) uses its stored + snapshot; otherwise the server reads the TMDB detail and answers 404 when TMDB + has no such title or it is above the ceiling. If exactly one library item the + viewer can see has the title, it goes onto the library watchlist through the + normal add and the response carries `item_id`. Otherwise the entry is saved + and watchlist requests apply. +- `DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id}` + (`deleteWatchlistTitle`): finds the title by a current or former TMDB ID and + first withdraws the watchlist's request under every TMDB ID the title has + had, since a request keeps the ID it was made under. Only then does it remove + the library watchlist entry of the one visible library item that has the + title (matched by every ID while the entry still stands), then the entry, and + that library watchlist entry once more for a promotion that moved the entry + onto it in between. Last, it withdraws once more if the title is still off + the watchlist. The order matters: removing the last entry deletes the title and + its former IDs, so a withdrawal that fails part way leaves the entry and a + retry still knows every ID. The second withdrawal, together with the add + rechecking after it requests and withdrawing when the title is off the + watchlist, means an overlapping add and delete of the same title don't leave + a request without an entry. "Off the watchlist" (`WatchlistTitleOff`) means + both forms: no entry outside the library and no library watchlist entry for + its library copy. A title promotion moved while an add was requesting, or + one a re-add put back, is on the watchlist and keeps its request. An absent + entry succeeds with 204. + +Both mutations are `non_retryable`, as the library watchlist mutations are. +Discovery results and the title detail carry `in_watchlist`, hydrated with two +reads per page: title entries by TMDB ID, and the library watchlist by +`library_content_id`. `GET /api/v2/requests/status` advertises +`watchlist_titles_supported`, which is false while requests are off: the +operations then answer `409 capability_disabled` and the entries are kept. See [api-contract.md](api-contract.md#watchlist-titles). + +There is no new realtime event. `user_state.changed` is keyed by `content_id`, +so other devices pick up entry changes on their next load; promotion emits the +normal event. + +## Status badge on the poster + +The web shows a title's request status as the `request_status` card overlay in +the `ribbons` group (default on, top-left, with an icon), so it follows the +viewer's overlay preset, corner, accent and `ui.card_overlays_enabled`. It has a +value only for titles outside the library. Attention states (`needs_review`, +`removed`) use an amber accent through the overlay's `getAccent`, unless the +viewer picked an accent for the badge. While a title downloads, the card draws +a progress bar that belongs to this badge: it shows only when overlays and the +badge are both on. The card caption always states the status in words, so the +tab reads the same with overlays off. TMDB rating and content rating badges use +the snapshot's `vote_average` and certification; file-based badges have no +value. + +The settings contract added `request_status` to the card-overlays `overlayId` +enum at manifest revision 15. A server below revision 15 rejects a stored value +that contains it, so clients offer the badge only when `manifest_revision` is at +least 15. + +## Multi-node and failure behavior + +- Repair claims are conditional updates; promotion adds to the library + watchlist and deletes the entry while it holds the title lock; adds, removes, + purges and merges follow the lock order. +- A node that dies mid-check leaves a claim that expires after 1 hour. +- A node that dies between the promotion add and the entry delete rolls the + delete back and leaves both rows. The next read finishes the move, and that + caller fires the side effects because the library row carries the entry's + `added_at`. + +## Not built + +Watch-provider (Trakt, Simkl, MDBList) and Plex imports still drop rows the +library doesn't have, and export only library items. Carrying unowned titles +through them needs its own design; imports would never auto-request. A profile +that cannot request, or has opted out, gets no arrival notification: only the +request's `request.fulfilled` notification tells anyone. diff --git a/docs/architecture/media-requests.md b/docs/architecture/media-requests.md index 0118170c0c..501f13791e 100644 --- a/docs/architecture/media-requests.md +++ b/docs/architecture/media-requests.md @@ -14,6 +14,10 @@ A request row carries two fields: - `status`: `pending → approved → queued → downloading → completed`. - `outcome`: `active`, or the terminal `declined`, `cancelled`, `failed`. +It also records its `source`: `direct` when someone requested the title, or +`watchlist` when a watchlist add made the request (see +[Watchlist requests](#watchlist-requests)). The source never changes. + Once a request is submitted, it fans out into one `media_request_targets` row per quality. Each target change recomputes the request's status and outcome in the same transaction (`aggregateStatus`), so after submission the targets own the @@ -331,6 +335,45 @@ Request state carries `following` (the viewer requested or follows the title) and `requested_by_viewer` (the viewing profile made the request, so there is nothing to follow); `GET /requests/status` advertises `follow_supported`. +## Watchlist requests + +Adding a movie or series the library doesn't have to a watchlist +([External watchlist titles](external-watchlist.md)) can also request it, so the +fulfilled notification tells the profile when it arrives. This happens only +when requests are on, the server's `request_settings.watchlist_requests` is on +(default on), the profile's `requests.watchlist_auto_request` setting is on +(default on), and the account may request (`RequestCapabilityAllowed`). A +profile setting that cannot be read counts as off. `WatchlistRequestsEnabled` +answers that question, and `GET /requests/status` reports it as +`watchlist_requests`. + +`RequestFromWatchlist` runs after the watchlist entry is saved: + +- No open request for the title: it creates one as the viewer, with + `source = 'watchlist'`, through the normal `CreateRequest`, so limits, + auto-approve, routing and the default seasons of a series all apply. +- Another profile's open request: it follows that request. An + `ErrAlreadyRequested` race re-reads the open request and follows it. +- The viewer's own open request: nothing. + +A refused request (request limit, blocked account, requests off for the +account, title already available) is logged, and the returned state reports it +with `requestable: false` and the reason. The call never fails because of it, +so the entry stays on the watchlist. Repeating the add is a no-op once the +request or follow exists. + +Removing the title from the watchlist calls `WithdrawWatchlistRequest`. It +cancels the viewer's open request only when that request's source is +`watchlist` and nothing has been sent for it yet (`guardWithdrawable`, pending, +or approved with no target sent, checked under the row lock), with the outcome +reason "Removed from the watchlist". It then removes the profile's follows on +the title. A request someone made directly, or one already sent to a download +server, is left alone. + +`WatchlistRequestStates` gives the watchlist page each title's request state +from the stored IDs, without a TMDB call, and the download progress of the +page's titles from one read of their targets. + ## Without a router Requests do not need Sonarr, Radarr or any other router plugin. When no @@ -503,7 +546,8 @@ still refuses a failed request). A closed request stays closed: a target that reports later updates only itself. A request's history is its `media_request_events` rows. Target updates record the request's status or outcome only when it changes, so neither a reconcile pass -nor a second target repeats an entry. +nor a second target repeats an entry. The queue shows a request with source +`watchlist` as "via watchlist". ## Who can request diff --git a/docs/catalog-api.md b/docs/catalog-api.md index c20df473c2..4295282b82 100644 --- a/docs/catalog-api.md +++ b/docs/catalog-api.md @@ -161,6 +161,24 @@ by the stored timestamp column so the existing profile/time indexes can serve th `added_at` fields remain UTC timestamps with millisecond precision. The frozen v1 list queries and their timestamp formatting are unchanged. +## Watchlist titles outside the library + +The watchlist can also hold movies and series the library doesn't have, keyed by +TMDB ID. They are not catalog items: `GET /api/v2/watchlist`, `GET /api/v2/catalog` +with `source=watchlist`, smart filters and the home Watchlist row never return them. +Read them with `GET /api/v2/watchlist/titles`, which pages by the same kind of opaque +cursor over descending `added_at`, then descending title ID; add and remove them with +`PUT` and `DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id}`. Check +`watchlist_titles_supported` on `GET /api/v2/requests/status` first. It is false +while requests are off, and the operations then answer `409 capability_disabled`. + +When such a title reaches the library, the next watchlist read moves it onto the +library watchlist with its original `added_at`: the watchlist list and entry reads, +a catalog query with `source=watchlist`, the home Watchlist row and an item's +`user_state.in_watchlist`, as well as `GET /api/v2/watchlist/titles` itself. See +[api-contract.md](architecture/api-contract.md#watchlist-titles) and +[External watchlist titles](architecture/external-watchlist.md). + ## Catalog query windows `POST /api/v2/catalog/query` is the structured-body form of `GET /api/v2/catalog`. diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 3eb685fa40..fe5e7f18dd 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -154,6 +154,12 @@ type AdminHandler struct { OnServerSettingUpdated func(ctx context.Context, key, value string) RestartStatus *ServerRestartStatusTracker CatalogSearchStatus catalog.CatalogSearchStatusProvider + // WatchlistTitlesSweeper deletes watchlist titles no entry references. + // Deleting an account drops its entries through the users foreign key, + // which can leave such titles behind. Nil skips the sweep. + WatchlistTitlesSweeper interface { + SweepOrphanTitles(ctx context.Context) error + } // logLevelCounts caches the 24h error/warning tallies served on // /admin/server/status. The dashboard polls that route every 15s, and the // counts are only ever read as a rough signal, so re-counting per request @@ -1096,11 +1102,26 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request) if h.OnUserSessionsRevoked != nil { h.OnUserSessionsRevoked(r.Context(), id) } + h.sweepWatchlistTitles(r.Context(), id) h.invalidateStats(r.Context(), cache.ChannelAdmin, cache.EventAdminStatsInvalidated, strconv.Itoa(id)) w.WriteHeader(http.StatusNoContent) } +// sweepWatchlistTitles removes the watchlist titles a deleted account's +// entries were the last to reference. The account is already gone, so a +// failure is logged and the next delete's sweep picks the titles up. +func (h *AdminHandler) sweepWatchlistTitles(ctx context.Context, userID int) { + if h.WatchlistTitlesSweeper == nil { + return + } + sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := h.WatchlistTitlesSweeper.SweepOrphanTitles(sweepCtx); err != nil { + slog.WarnContext(ctx, "watchlist title sweep failed after account delete", "component", "api", "user_id", userID, "error", err) + } +} + // HandleImpersonateUser handles POST /admin/users/{id}/impersonate. func (h *AdminHandler) HandleImpersonateUser(w http.ResponseWriter, r *http.Request) { if h.ImpersonationService == nil { diff --git a/internal/api/handlers/admin_users_service.go b/internal/api/handlers/admin_users_service.go index a52a9cd2f8..0ac2c7e5f4 100644 --- a/internal/api/handlers/admin_users_service.go +++ b/internal/api/handlers/admin_users_service.go @@ -288,6 +288,7 @@ func (h *AdminHandler) DeleteAdminAccount(ctx context.Context, id int, revision, if h.OnUserSessionsRevoked != nil { h.OnUserSessionsRevoked(ctx, id) } + h.sweepWatchlistTitles(ctx, id) h.invalidateStats(ctx, cache.ChannelAdmin, cache.EventAdminStatsInvalidated, strconv.Itoa(id)) return nil } diff --git a/internal/api/handlers/admin_watchlist_sweep_test.go b/internal/api/handlers/admin_watchlist_sweep_test.go new file mode 100644 index 0000000000..b71ee7bed1 --- /dev/null +++ b/internal/api/handlers/admin_watchlist_sweep_test.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" +) + +// watchlistSweepRecorder counts orphan-title sweeps and checks each runs on a +// live context. +type watchlistSweepRecorder struct { + calls int + ctxErr error +} + +func (r *watchlistSweepRecorder) SweepOrphanTitles(ctx context.Context) error { + r.calls++ + r.ctxErr = ctx.Err() + return nil +} + +// Deleting an account drops its watchlist entries through the users foreign +// key; both delete paths then sweep the titles left without entries. +func TestAdminAccountDeleteSweepsWatchlistTitles(t *testing.T) { + t.Run("v2 delete", func(t *testing.T) { + sweeper := &watchlistSweepRecorder{} + h := &AdminHandler{userRepo: &mutatingUserRepo{current: userAccount()}, WatchlistTitlesSweeper: sweeper} + if err := h.DeleteAdminAccount(claimsCtx(7), testAdminID, -1, 0); err != nil { + t.Fatal(err) + } + if sweeper.calls != 1 || sweeper.ctxErr != nil { + t.Fatalf("sweeps = %d ctxErr = %v, want one on a live context", sweeper.calls, sweeper.ctxErr) + } + }) + t.Run("v2 refused delete", func(t *testing.T) { + sweeper := &watchlistSweepRecorder{} + h := &AdminHandler{userRepo: &mutatingUserRepo{current: adminAccount()}, WatchlistTitlesSweeper: sweeper} + if err := h.DeleteAdminAccount(claimsCtx(7), testAdminID, -1, 0); err == nil { + t.Fatal("admin deleted another admin") + } + if sweeper.calls != 0 { + t.Fatalf("sweeps = %d after a refused delete", sweeper.calls) + } + }) + t.Run("v1 delete", func(t *testing.T) { + sweeper := &watchlistSweepRecorder{} + h := &AdminHandler{userRepo: &scopedKeyUserRepo{user: new(userAccount())}, WatchlistTitlesSweeper: sweeper} + req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/users/9", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "9") + req = req.WithContext(apimw.SetClaims(context.WithValue(req.Context(), chi.RouteCtxKey, rctx), + &auth.Claims{UserID: 7, Role: "admin", TokenType: auth.TokenTypeAccess, SessionID: "s1"})) + rec := httptest.NewRecorder() + h.HandleDeleteUser(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if sweeper.calls != 1 { + t.Fatalf("sweeps = %d, want 1", sweeper.calls) + } + }) +} diff --git a/internal/api/handlers/catalog_resources.go b/internal/api/handlers/catalog_resources.go index 03ca38cc22..c026db69a2 100644 --- a/internal/api/handlers/catalog_resources.go +++ b/internal/api/handlers/catalog_resources.go @@ -15,7 +15,21 @@ import ( // CatalogResourceHandler serves canonical catalog resource read routes. type CatalogResourceHandler struct { - items *ItemsHandler + items *ItemsHandler + watchlistPromoter WatchlistItemPromoter +} + +// WatchlistItemPromoter moves the profile's watchlist entry for a title the +// library did not have onto the library watchlist once the item carries the +// title's IDs. *watchlist.Titles implements it; a failure is its to log. +type WatchlistItemPromoter interface { + PromoteWatchlistItem(ctx context.Context, access catalog.AccessFilter, contentID string) +} + +// SetWatchlistPromoter makes item detail promote the viewer's matching +// watchlist entry before it reports user_state.in_watchlist. +func (h *CatalogResourceHandler) SetWatchlistPromoter(p WatchlistItemPromoter) { + h.watchlistPromoter = p } // NewCatalogResourceHandler creates a new canonical catalog resource handler. @@ -295,6 +309,11 @@ func (h *CatalogResourceHandler) enrichViewerState(ctx context.Context, v ItemVi if err != nil { return } + if h.watchlistPromoter != nil && (detail.Type == "movie" || detail.Type == "series") { + promoteAccess := v.Access + promoteAccess.UserID, promoteAccess.ProfileID = apimw.GetUserID(ctx), profileID + h.watchlistPromoter.PromoteWatchlistItem(ctx, promoteAccess, detail.ContentID) + } inWatchlist, err := store.InWatchlist(ctx, profileID, detail.ContentID) if err != nil { return diff --git a/internal/api/handlers/favorites.go b/internal/api/handlers/favorites.go index bf2874c7d6..5dda88e213 100644 --- a/internal/api/handlers/favorites.go +++ b/internal/api/handlers/favorites.go @@ -18,6 +18,7 @@ import ( "github.com/Silo-Server/silo-server/internal/imagesize" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/watchlist" "github.com/Silo-Server/silo-server/internal/watchsync" ) @@ -43,6 +44,7 @@ type PersonalDataHandler struct { profileStaler ProfileStaler profileRefreshRequester ProfileRefreshRequester ebookProgressStore EbookReaderProgressLister + watchlistTitles *watchlist.Titles } // NewPersonalDataHandler creates a new PersonalDataHandler. @@ -374,6 +376,11 @@ func (h *PersonalDataHandler) HandleListWatchlist(w http.ResponseWriter, r *http // entries the viewer may not see have no card, so the raw entries, not the // cards, decide whether another page follows. func (h *PersonalDataHandler) ListWatchlist(ctx context.Context, viewer PersonalListViewer, limit, offset int) ([]userstore.WatchlistEntry, []CollectionItemView, error) { + // Promotion scans the whole profile, so a paged read runs it once, on the + // first page, rather than once per page. + if offset == 0 { + h.promoteWatchlist(ctx, viewer) + } store, err := h.storeProvider.ForUser(ctx, viewer.UserID) if err != nil { return nil, nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") @@ -398,6 +405,10 @@ func (h *PersonalDataHandler) ListWatchlist(ctx context.Context, viewer Personal // strictly after the key (nil = from the newest row), and the cards of the // entries the viewer may see (fully-watched series hidden) in the same order. func (h *PersonalDataHandler) ListWatchlistPage(ctx context.Context, viewer PersonalListViewer, after *userstore.ListKey, limit int) ([]userstore.WatchlistEntry, []CollectionItemView, error) { + // Once per traversal, on the first page; see ListWatchlist. + if after == nil { + h.promoteWatchlist(ctx, viewer) + } store, err := h.storeProvider.ForUser(ctx, viewer.UserID) if err != nil { return nil, nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") @@ -456,6 +467,7 @@ func (h *PersonalDataHandler) GetWatchlistEntry(ctx context.Context, viewer Pers if err := h.ensureAccessibleItem(ctx, itemID, viewer.Access); err != nil { return userstore.WatchlistEntry{}, false, apiError(http.StatusNotFound, "not_found", "Item not found") } + h.promoteWatchlistItem(ctx, viewer, itemID) e, err := store.GetWatchlistEntry(ctx, viewer.ProfileID, itemID) if err != nil { return userstore.WatchlistEntry{}, false, apiError(http.StatusInternalServerError, "internal_error", "Failed to check watchlist") diff --git a/internal/api/handlers/profiles.go b/internal/api/handlers/profiles.go index 92fd8d937f..cc5451beee 100644 --- a/internal/api/handlers/profiles.go +++ b/internal/api/handlers/profiles.go @@ -43,6 +43,17 @@ type ProfileHandler struct { DroppedSeriesPurger interface { PurgeProfile(ctx context.Context, userID int, profileID string) error } + // WatchlistTitlesPurger removes a deleted profile's watchlist entries for + // titles outside the library, and the titles no profile keeps any more. + // They live in Postgres whichever store holds the profile. + WatchlistTitlesPurger interface { + PurgeProfile(ctx context.Context, userID int, profileID string) error + } + // WatchlistRequestWithdrawer cancels the unsent requests a deleted + // profile's watchlist made, as removing each title would. + WatchlistRequestWithdrawer interface { + WithdrawProfileWatchlistRequests(ctx context.Context, userID int, profileID string) error + } // EventsHub, when set, receives a user_settings.changed event for every // canonical setting row a profile mutation syncs (see // profiles_settings_sync.go). Nil (as in tests) simply skips publishing. @@ -747,6 +758,13 @@ func (h *ProfileHandler) DeleteProfile(ctx context.Context, cmd ProfileDeleteCom if err := store.DeleteProfile(ctx, profileID); err != nil { return apiError(http.StatusNotFound, "not_found", "Profile not found") } + if h.WatchlistRequestWithdrawer != nil { + withdrawCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if withdrawErr := h.WatchlistRequestWithdrawer.WithdrawProfileWatchlistRequests(withdrawCtx, userID, profileID); withdrawErr != nil { + slog.WarnContext(ctx, "profile watchlist request withdrawal failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", withdrawErr) + } + } if isUploadedAvatarRef(profile.Avatar) { if cleanupErr := deleteUploadedAvatarObjects(ctx, h.AvatarStore, userID, profileID); cleanupErr != nil { slog.WarnContext(ctx, "profile avatar cleanup failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr) @@ -766,6 +784,13 @@ func (h *ProfileHandler) DeleteProfile(ctx context.Context, cmd ProfileDeleteCom slog.WarnContext(ctx, "profile dropped-series purge failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", purgeErr) } } + if h.WatchlistTitlesPurger != nil { + purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if purgeErr := h.WatchlistTitlesPurger.PurgeProfile(purgeCtx, userID, profileID); purgeErr != nil { + slog.WarnContext(ctx, "profile watchlist-title purge failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", purgeErr) + } + } return nil } diff --git a/internal/api/handlers/profiles_test.go b/internal/api/handlers/profiles_test.go index a6bfe1e936..6ad479e1d4 100644 --- a/internal/api/handlers/profiles_test.go +++ b/internal/api/handlers/profiles_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -679,6 +680,68 @@ func TestHandleDeleteProfile_AllowsPrimaryToDeleteOther(t *testing.T) { } } +type recordingProfilePurger struct { + calls []string +} + +func (p *recordingProfilePurger) PurgeProfile(_ context.Context, userID int, profileID string) error { + p.calls = append(p.calls, fmt.Sprintf("%d/%s", userID, profileID)) + return nil +} + +func TestHandleDeleteProfile_PurgesWatchlistTitles(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + purger := &recordingProfilePurger{} + handler.WatchlistTitlesPurger = purger + + req := newAuthorizedProfileRequestWithRole(http.MethodDelete, "/profiles/profile-2", "", "user", "profile-1") + rr := httptest.NewRecorder() + handler.HandleDeleteProfile(rr, withProfileRouteParam(req, "id", "profile-2")) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if len(purger.calls) != 1 || !strings.HasSuffix(purger.calls[0], "/profile-2") { + t.Fatalf("purge calls = %v, want one for profile-2", purger.calls) + } +} + +type recordingWatchlistRequestWithdrawer struct { + calls []string +} + +func (w *recordingWatchlistRequestWithdrawer) WithdrawProfileWatchlistRequests(_ context.Context, userID int, profileID string) error { + w.calls = append(w.calls, fmt.Sprintf("%d/%s", userID, profileID)) + return nil +} + +// A deleted profile's watchlist requests are withdrawn, as removing each title +// would; the requests name the profile, so no watchlist entry is needed. +func TestHandleDeleteProfile_WithdrawsWatchlistRequests(t *testing.T) { + store := newProfileTestStore(t) + if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { + t.Fatalf("create profile: %v", err) + } + handler := NewProfileHandler(testUserStoreProvider{store: store}) + withdrawer := &recordingWatchlistRequestWithdrawer{} + handler.WatchlistRequestWithdrawer = withdrawer + + req := newAuthorizedProfileRequestWithRole(http.MethodDelete, "/profiles/profile-2", "", "user", "profile-1") + rr := httptest.NewRecorder() + handler.HandleDeleteProfile(rr, withProfileRouteParam(req, "id", "profile-2")) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if len(withdrawer.calls) != 1 || !strings.HasSuffix(withdrawer.calls[0], "/profile-2") { + t.Fatalf("withdraw calls = %v, want one for profile-2", withdrawer.calls) + } +} + func TestHandleDeleteProfile_BlocksPrimaryDeletion(t *testing.T) { store := newProfileTestStore(t) if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-2", Name: "Kids"}); err != nil { diff --git a/internal/api/handlers/watchlist_titles.go b/internal/api/handlers/watchlist_titles.go new file mode 100644 index 0000000000..aa0d225a83 --- /dev/null +++ b/internal/api/handlers/watchlist_titles.go @@ -0,0 +1,317 @@ +package handlers + +import ( + "context" + "errors" + "log/slog" + "net/http" + "slices" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/watchlist" + "github.com/Silo-Server/silo-server/internal/watchsync" +) + +// Watchlist entries for titles the library doesn't have yet. The entries live +// in watchlist.Titles; this handler adds the library-watchlist side: an added +// title the library already has goes onto the library watchlist, promotion +// runs ahead of library watchlist reads, and a promoted entry fires the same +// effects as a manual add. + +var _ watchlist.Effects = (*PersonalDataHandler)(nil) + +// SetWatchlistTitles wires the service that keeps watchlist entries for +// titles the library doesn't have. Without it the watchlist reads skip +// promotion and the title operations answer 500. +func (h *PersonalDataHandler) SetWatchlistTitles(titles *watchlist.Titles) { + h.watchlistTitles = titles +} + +// WatchlistPromoted implements watchlist.Effects: an entry that moved onto the +// library watchlist notifies the same listeners as a manual add. +func (h *PersonalDataHandler) WatchlistPromoted(ctx context.Context, userID int, profileID, contentID string) { + h.dispatchLocalListEvent(ctx, watchsync.ListKindWatchlist, watchsync.ListChangeAdded, userID, profileID, contentID) + triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, userID, profileID) + publishUserStateEvent(ctx, h.EventsHub, userID, profileID, contentID, "", "watchlist", userStateEventState{ + InWatchlist: boolPtr(true), + }) +} + +// watchlistAccess is the viewer's access filter naming the viewer, as the +// watchlist promotion hooks read it. +func (v PersonalListViewer) watchlistAccess() catalog.AccessFilter { + access := v.Access + access.UserID, access.ProfileID = v.UserID, v.ProfileID + return access +} + +func (v PersonalListViewer) watchlistViewer() watchlist.Viewer { + return watchlist.Viewer{UserID: v.UserID, ProfileID: v.ProfileID, Access: v.watchlistAccess()} +} + +// promoteWatchlist moves the viewer's entries whose titles the library now +// has onto the library watchlist before a library watchlist read. A failure +// is logged and the read goes on. +func (h *PersonalDataHandler) promoteWatchlist(ctx context.Context, viewer PersonalListViewer) { + if h.watchlistTitles != nil { + h.watchlistTitles.PromoteWatchlist(ctx, viewer.watchlistAccess()) + } +} + +func (h *PersonalDataHandler) promoteWatchlistItem(ctx context.Context, viewer PersonalListViewer, itemID string) { + if h.watchlistTitles != nil { + h.watchlistTitles.PromoteWatchlistItem(ctx, viewer.watchlistAccess(), itemID) + } +} + +func (h *PersonalDataHandler) titlesOrError() (*watchlist.Titles, error) { + if h.watchlistTitles == nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Watchlist titles are not configured") + } + return h.watchlistTitles, nil +} + +// ListWatchlistTitlesPage answers at most limit of the viewer's entries for +// titles the library doesn't have, newest first, strictly after the key (nil +// = from the newest). The first page (nil key) first promotes the entries the +// library now has. +func (h *PersonalDataHandler) ListWatchlistTitlesPage(ctx context.Context, viewer PersonalListViewer, after *watchlist.PageKey, limit int) ([]watchlist.Entry, error) { + titles, err := h.titlesOrError() + if err != nil { + return nil, err + } + // Promotion scans the whole profile, so a paged read runs it once, on + // the first page, rather than once per page. + if after == nil { + h.promoteWatchlist(ctx, viewer) + } + entries, err := titles.ListPage(ctx, viewer.watchlistViewer(), after, limit) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to list watchlist titles") + } + return entries, nil +} + +// FindWatchlistTitle returns the title holding the TMDB ID, current or +// former, or nil when no watchlist tracks it. +func (h *PersonalDataHandler) FindWatchlistTitle(ctx context.Context, mediaType string, tmdbID int) (*watchlist.Title, error) { + titles, err := h.titlesOrError() + if err != nil { + return nil, err + } + title, err := titles.Find(ctx, mediaType, tmdbID) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to look up the watchlist title") + } + return title, nil +} + +// WatchlistTitleAdded is where AddWatchlistTitle put a title: on the library +// watchlist (ItemID set) or as an entry for a title the library doesn't have. +type WatchlistTitleAdded struct { + ItemID string + AddedAt time.Time +} + +// AddWatchlistTitle puts a title on the viewer's watchlist. When exactly one +// library item has the title and the viewer may see it, the item goes onto +// the library watchlist through the normal add, with its effects; otherwise +// the title is kept as an entry until the library has it. Adding a title +// already there keeps its original added_at. +func (h *PersonalDataHandler) AddWatchlistTitle(ctx context.Context, viewer PersonalListViewer, snap watchlist.Snapshot) (WatchlistTitleAdded, error) { + titles, err := h.titlesOrError() + if err != nil { + return WatchlistTitleAdded{}, err + } + tmdbIDs := append([]int{snap.TMDBID}, snap.FormerTMDBIDs...) + itemID, err := h.accessibleLibraryItem(ctx, viewer, titles, snap.MediaType, tmdbIDs, snap.IMDbID, snap.TVDBID) + if err != nil { + return WatchlistTitleAdded{}, err + } + if itemID != "" { + if err := h.AddToWatchlist(ctx, viewer, itemID); err != nil { + return WatchlistTitleAdded{}, err + } + entry, found, err := h.GetWatchlistEntry(ctx, viewer, itemID) + if err != nil { + return WatchlistTitleAdded{}, err + } + added := time.Now().UTC() + if found { + if parsed, err := time.Parse(time.RFC3339Nano, entry.AddedAt); err == nil { + added = parsed + } + } + // Not found: a concurrent remove won, and the add itself succeeded. + return WatchlistTitleAdded{ItemID: itemID, AddedAt: added}, nil + } + entry, _, err := titles.AddSnapshot(ctx, viewer.watchlistViewer(), snap, time.Now().UTC()) + if err != nil { + return WatchlistTitleAdded{}, apiError(http.StatusInternalServerError, "internal_error", "Failed to add to watchlist") + } + return WatchlistTitleAdded{AddedAt: entry.AddedAt}, nil +} + +// RemoveWatchlistTitle takes the title holding the TMDB ID, current or +// former, off the viewer's watchlist: the entry for it, and the library +// watchlist entry of the one library item the viewer may see that has it. +// It returns the title (nil when no watchlist tracks the ID). Removing a +// title that is not on the watchlist succeeds. +// +// The library entry goes first. Removing the last entry deletes the title and +// its former IDs, and a library item may be known only by one of those, so +// until the entry goes a retry still resolves the item by every ID. The +// library entry is checked again after the entry goes, for a promotion that +// moved the entry onto it in between. +func (h *PersonalDataHandler) RemoveWatchlistTitle(ctx context.Context, viewer PersonalListViewer, mediaType string, tmdbID int) (*watchlist.Title, error) { + titles, err := h.titlesOrError() + if err != nil { + return nil, err + } + known, err := titles.Find(ctx, mediaType, tmdbID) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to look up the watchlist title") + } + tmdbIDs, imdbID, tvdbID := []int{tmdbID}, "", 0 + if known != nil { + imdbID, tvdbID = known.IMDbID, known.TVDBID + for _, id := range append([]int{known.TMDBID}, known.FormerTMDBIDs...) { + if !slices.Contains(tmdbIDs, id) { + tmdbIDs = append(tmdbIDs, id) + } + } + } + itemID, err := h.accessibleLibraryItem(ctx, viewer, titles, mediaType, tmdbIDs, imdbID, tvdbID) + if err != nil { + return nil, err + } + if err := h.removeLibraryWatchlistItem(ctx, viewer, itemID); err != nil { + return nil, err + } + title, _, err := titles.Remove(ctx, viewer.watchlistViewer(), mediaType, tmdbID) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to remove from watchlist") + } + if err := h.removeLibraryWatchlistItem(ctx, viewer, itemID); err != nil { + return nil, err + } + if title == nil { + title = known + } + return title, nil +} + +// removeLibraryWatchlistItem takes the item off the viewer's library +// watchlist through the normal remove, when it is there. An absent item is +// no removal, so nothing is exported to a provider. +func (h *PersonalDataHandler) removeLibraryWatchlistItem(ctx context.Context, viewer PersonalListViewer, itemID string) error { + if itemID == "" { + return nil + } + store, err := h.storeProvider.ForUser(ctx, viewer.UserID) + if err != nil { + return apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") + } + onList, err := store.GetWatchlistEntry(ctx, viewer.ProfileID, itemID) + if err != nil { + return apiError(http.StatusInternalServerError, "internal_error", "Failed to check watchlist") + } + if onList == nil { + return nil + } + return h.RemoveFromWatchlist(ctx, viewer, itemID) +} + +// accessibleLibraryItem returns the library item that has the title when +// exactly one does and the viewer may see it, else "". Several matches are +// left alone until the library merges them, as promotion leaves them. +func (h *PersonalDataHandler) accessibleLibraryItem(ctx context.Context, viewer PersonalListViewer, titles *watchlist.Titles, mediaType string, tmdbIDs []int, imdbID string, tvdbID int) (string, error) { + matches, err := titles.LibraryMatches(ctx, mediaType, tmdbIDs, imdbID, tvdbID) + if err != nil { + return "", apiError(http.StatusInternalServerError, "internal_error", "Failed to match the title to the library") + } + // Only the copies this viewer may see count: duplicates in libraries the + // viewer can't open don't make the title ambiguous for them. + visible := make([]string, 0, len(matches)) + for _, id := range matches { + if h.itemRepo != nil { + if err := h.itemRepo.EnsureAccessible(ctx, id, viewer.watchlistAccess()); err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + continue + } + return "", apiError(http.StatusInternalServerError, "internal_error", "Failed to check item access") + } + } + visible = append(visible, id) + } + if len(visible) != 1 { + if len(visible) > 1 { + slog.InfoContext(ctx, "watchlist title matches several library items; keeping it as a title entry", + "component", "watchlist", "media_type", mediaType, "tmdb_ids", tmdbIDs, "content_ids", visible) + } + return "", nil + } + return visible[0], nil +} + +// WatchlistTitleOff reports whether the title is off the viewer's watchlist +// in both forms: no entry for it outside the library, and no library +// watchlist entry for the one library item the viewer may see that has it. +// A title the library received while it was on the watchlist was promoted, +// not removed, so it still counts as on. +func (h *PersonalDataHandler) WatchlistTitleOff(ctx context.Context, viewer PersonalListViewer, snap watchlist.Snapshot) (bool, error) { + titles, err := h.titlesOrError() + if err != nil { + return false, err + } + key := watchlist.TitleKey{MediaType: snap.MediaType, TMDBID: snap.TMDBID} + on, err := titles.OnWatchlist(ctx, viewer.watchlistViewer(), []watchlist.TitleKey{key}) + if err != nil { + return false, apiError(http.StatusInternalServerError, "internal_error", "Failed to check watchlist titles") + } + if on[key] { + return false, nil + } + tmdbIDs := append([]int{snap.TMDBID}, snap.FormerTMDBIDs...) + itemID, err := h.accessibleLibraryItem(ctx, viewer, titles, snap.MediaType, tmdbIDs, snap.IMDbID, snap.TVDBID) + if err != nil || itemID == "" { + return itemID == "" && err == nil, err + } + store, err := h.storeProvider.ForUser(ctx, viewer.UserID) + if err != nil { + return false, apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") + } + entry, err := store.GetWatchlistEntry(ctx, viewer.ProfileID, itemID) + if err != nil { + return false, apiError(http.StatusInternalServerError, "internal_error", "Failed to check watchlist") + } + return entry == nil, nil +} + +// WatchlistMembership reports which titles and which library items are on +// the viewer's watchlist: titles by any TMDB ID their entry has held, items +// through the library watchlist. It makes two reads whatever the page size. +func (h *PersonalDataHandler) WatchlistMembership(ctx context.Context, viewer PersonalListViewer, keys []watchlist.TitleKey, itemIDs []string) (map[watchlist.TitleKey]bool, map[string]bool, error) { + titles, err := h.titlesOrError() + if err != nil { + return nil, nil, err + } + onTitles := map[watchlist.TitleKey]bool{} + if len(keys) > 0 { + if onTitles, err = titles.OnWatchlist(ctx, viewer.watchlistViewer(), keys); err != nil { + return nil, nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to check watchlist titles") + } + } + onItems := map[string]bool{} + if len(itemIDs) > 0 { + store, err := h.storeProvider.ForUser(ctx, viewer.UserID) + if err != nil { + return nil, nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") + } + if onItems, err = store.ListWatchlistByMediaItems(ctx, viewer.ProfileID, itemIDs); err != nil { + return nil, nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to check watchlist") + } + } + return onTitles, onItems, nil +} diff --git a/internal/api/handlers/watchlist_titles_db_test.go b/internal/api/handlers/watchlist_titles_db_test.go new file mode 100644 index 0000000000..d6fa8b56d0 --- /dev/null +++ b/internal/api/handlers/watchlist_titles_db_test.go @@ -0,0 +1,286 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "strconv" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" + "github.com/Silo-Server/silo-server/internal/watchlist" +) + +// WatchlistTitleOff counts a title as off the watchlist only when neither form +// holds it: an entry outside the library, or the library copy on the library +// watchlist. A title the library received and promotion moved is still on, +// so the add and delete race handling never withdraws its request. +func TestWatchlistTitleOffDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + stamp := time.Now().UnixNano() + prefix := fmt.Sprintf("wlt-off-%d", stamp) + tmdbID := 1_500_000_000 + int(stamp/1000%100_000) + var userID, folder int + if err := pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, prefix).Scan(&userID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, TRUE) RETURNING id`, prefix).Scan(&folder); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + bg := context.Background() + _, _ = pool.Exec(bg, `DELETE FROM users WHERE id = $1`, userID) + _, _ = pool.Exec(bg, `DELETE FROM watchlist_titles WHERE tmdb_id = $1`, tmdbID) + _, _ = pool.Exec(bg, `DELETE FROM media_items WHERE content_id = $1`, prefix) + _, _ = pool.Exec(bg, `DELETE FROM media_folders WHERE id = $1`, folder) + }) + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, userID) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "p1"}); err != nil { + t.Fatal(err) + } + items := catalog.NewItemRepository(pool) + h := NewPersonalDataHandler(provider, items) + h.SetWatchlistTitles(watchlist.NewTitles(pool, items, provider, nil, nil)) + viewer := PersonalListViewer{UserID: userID, ProfileID: "p1"} + snap := watchlist.Snapshot{MediaType: "movie", TMDBID: tmdbID, Title: "Arriving"} + + check := func(want bool, when string) { + t.Helper() + off, err := h.WatchlistTitleOff(ctx, viewer, snap) + if err != nil || off != want { + t.Fatalf("%s: off = %v %v, want %v", when, off, err, want) + } + } + check(true, "never added") + if _, _, err := h.watchlistTitles.AddSnapshot(ctx, viewer.watchlistViewer(), snap, time.Now().UTC()); err != nil { + t.Fatal(err) + } + check(false, "entry outside the library") + + // The library receives the title and a read promotes the entry. + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, genres, tmdb_id) VALUES ($1, 'movie', $1, '{}'::text[], $2)`, + prefix, strconv.Itoa(tmdbID)); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, prefix, folder); err != nil { + t.Fatal(err) + } + if promoted, err := h.watchlistTitles.PromoteProfile(ctx, viewer.watchlistViewer()); err != nil || len(promoted) != 1 { + t.Fatalf("PromoteProfile = %v %v", promoted, err) + } + check(false, "promoted onto the library watchlist") + + if err := store.RemoveFromWatchlist(ctx, "p1", prefix); err != nil { + t.Fatal(err) + } + check(true, "library watchlist entry removed") +} + +// failingRemoveProvider hands out stores whose library watchlist remove fails +// while fail is set. +type failingRemoveProvider struct { + userstore.UserStoreProvider + fail *bool +} + +func (p failingRemoveProvider) ForUser(ctx context.Context, userID int) (userstore.UserStore, error) { + store, err := p.UserStoreProvider.ForUser(ctx, userID) + if err != nil { + return nil, err + } + return failingRemoveStore{UserStore: store, fail: p.fail}, nil +} + +type failingRemoveStore struct { + userstore.UserStore + fail *bool +} + +func (s failingRemoveStore) RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error { + if *s.fail { + return fmt.Errorf("user store unavailable") + } + return s.UserStore.RemoveFromWatchlist(ctx, profileID, mediaItemID) +} + +// A delete whose library step fails leaves the title's entry, and with it +// every TMDB ID the title has had, so a retry still finds a library item +// known only by a former ID and takes both off. +func TestRemoveWatchlistTitleRetryKeepsFormerIDsDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + stamp := time.Now().UnixNano() + prefix := fmt.Sprintf("wlt-retry-%d", stamp) + former := 1_600_000_000 + int(stamp/1000%100_000) + current := former + 1 + var userID, folder int + if err := pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, prefix).Scan(&userID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, TRUE) RETURNING id`, prefix).Scan(&folder); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + bg := context.Background() + _, _ = pool.Exec(bg, `DELETE FROM users WHERE id = $1`, userID) + _, _ = pool.Exec(bg, `DELETE FROM watchlist_titles WHERE tmdb_id IN ($1, $2)`, former, current) + _, _ = pool.Exec(bg, `DELETE FROM media_items WHERE content_id = $1`, prefix) + _, _ = pool.Exec(bg, `DELETE FROM media_folders WHERE id = $1`, folder) + }) + fail := true + provider := failingRemoveProvider{UserStoreProvider: pgstore.NewPostgresProvider(pool), fail: &fail} + store, err := provider.ForUser(ctx, userID) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "p1"}); err != nil { + t.Fatal(err) + } + items := catalog.NewItemRepository(pool) + titles := watchlist.NewTitles(pool, items, provider, nil, nil) + h := NewPersonalDataHandler(provider, items) + h.SetWatchlistTitles(titles) + viewer := PersonalListViewer{UserID: userID, ProfileID: "p1"} + + // The entry was added under the former ID and TMDB repointed it since. + entry, _, err := titles.AddSnapshot(ctx, viewer.watchlistViewer(), watchlist.Snapshot{MediaType: "movie", TMDBID: former, Title: "Repointed"}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO watchlist_title_aliases (title_id, media_type, provider, provider_id) VALUES ($1, 'movie', 'tmdb', $2)`, + entry.Title.ID, strconv.Itoa(current)); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `UPDATE watchlist_titles SET tmdb_id = $2 WHERE id = $1`, entry.Title.ID, current); err != nil { + t.Fatal(err) + } + // The library copy carries only the former ID and is on the library watchlist. + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, genres, tmdb_id) VALUES ($1, 'movie', $1, '{}'::text[], $2)`, + prefix, strconv.Itoa(former)); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, prefix, folder); err != nil { + t.Fatal(err) + } + if err := store.AddToWatchlist(ctx, "p1", prefix); err != nil { + t.Fatal(err) + } + + if _, err := h.RemoveWatchlistTitle(ctx, viewer, "movie", current); err == nil { + t.Fatal("a failed library removal answered success") + } + if found, err := titles.Find(ctx, "movie", current); err != nil || found == nil { + t.Fatalf("after the failure the title = %v %v, want it kept for the retry", found, err) + } + + fail = false + if _, err := h.RemoveWatchlistTitle(ctx, viewer, "movie", current); err != nil { + t.Fatal(err) + } + if onList, err := store.GetWatchlistEntry(ctx, "p1", prefix); err != nil || onList != nil { + t.Fatalf("library watchlist entry = %v %v, want removed through the former ID", onList, err) + } + if found, err := titles.Find(ctx, "movie", current); err != nil || found != nil { + t.Fatalf("title = %v %v, want gone after the retry", found, err) + } +} + +// Promotion scans the whole profile, so a paged titles read runs it once, on +// the first page: a later page leaves an arrived title where it is, and the +// next first page moves it. +func TestListWatchlistTitlesPagePromotesOnFirstPageOnlyDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + stamp := time.Now().UnixNano() + prefix := fmt.Sprintf("wlt-page-%d", stamp) + tmdbID := 1_700_000_000 + int(stamp/1000%100_000) + var userID, folder int + if err := pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, prefix).Scan(&userID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, TRUE) RETURNING id`, prefix).Scan(&folder); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + bg := context.Background() + _, _ = pool.Exec(bg, `DELETE FROM users WHERE id = $1`, userID) + _, _ = pool.Exec(bg, `DELETE FROM watchlist_titles WHERE tmdb_id = $1`, tmdbID) + _, _ = pool.Exec(bg, `DELETE FROM media_items WHERE content_id = $1`, prefix) + _, _ = pool.Exec(bg, `DELETE FROM media_folders WHERE id = $1`, folder) + }) + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, userID) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "p1"}); err != nil { + t.Fatal(err) + } + items := catalog.NewItemRepository(pool) + titles := watchlist.NewTitles(pool, items, provider, nil, nil) + h := NewPersonalDataHandler(provider, items) + h.SetWatchlistTitles(titles) + viewer := PersonalListViewer{UserID: userID, ProfileID: "p1"} + if _, _, err := titles.AddSnapshot(ctx, viewer.watchlistViewer(), watchlist.Snapshot{MediaType: "movie", TMDBID: tmdbID, Title: "Arriving"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, genres, tmdb_id) VALUES ($1, 'movie', $1, '{}'::text[], $2)`, + prefix, strconv.Itoa(tmdbID)); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, prefix, folder); err != nil { + t.Fatal(err) + } + + later := &watchlist.PageKey{AddedAt: time.Now().Add(time.Hour), TitleID: 1 << 62} + if _, err := h.ListWatchlistTitlesPage(ctx, viewer, later, 10); err != nil { + t.Fatal(err) + } + if onList, err := store.GetWatchlistEntry(ctx, "p1", prefix); err != nil || onList != nil { + t.Fatalf("a later page promoted the title: %v %v", onList, err) + } + if _, err := h.ListWatchlistTitlesPage(ctx, viewer, nil, 10); err != nil { + t.Fatal(err) + } + if onList, err := store.GetWatchlistEntry(ctx, "p1", prefix); err != nil || onList == nil { + t.Fatalf("the first page did not promote the title: %v %v", onList, err) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 49032ac0bb..0b1b0fdcb3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -90,6 +90,7 @@ import ( "github.com/Silo-Server/silo-server/internal/themesongs" "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/watchlist" "github.com/Silo-Server/silo-server/internal/watchstate" "github.com/Silo-Server/silo-server/internal/watchtogether" "github.com/Silo-Server/silo-server/internal/webhooksync" @@ -721,6 +722,10 @@ func newChiRouter(deps Dependencies) chi.Router { var catalogSearchService *catalog.CatalogSearchService var webhookSyncHandler *handlers.WebhookSyncHandler var requestHandler *handlers.RequestsHandler + // watchlistTitles is the one watchlist.Titles every surface shares: the + // v2 title operations, promotion on library watchlist reads, the title + // detail's repair observer and the profile purge. + var watchlistTitles *watchlist.Titles var onboardingHandler *handlers.OnboardingHandler // Declared here (assigned in the playback block below) so the onboarding // gates closure can reference it before that block runs. @@ -730,6 +735,7 @@ func newChiRouter(deps Dependencies) chi.Router { var ebookProgressStore *handlers.PGEbookReaderProgressStore var ebookConfigStore *handlers.PGEbookReaderConfigStore var ebookAnnotationStore *handlers.PGEbookReaderAnnotationStore + var watchlistRequestWithdrawer *mediarequests.Service if deps.DB != nil { ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB) ebookConfigStore = handlers.NewPGEbookReaderConfigStore(deps.DB) @@ -858,26 +864,37 @@ func newChiRouter(deps Dependencies) chi.Router { ebookReaderHandler.Conversion = conv } } + tmdbAPIKey := "" + if deps.Config != nil { + tmdbAPIKey = deps.Config.TMDBAPIKey + } + requestsTMDB := tmdb.NewClient(tmdbAPIKey, 40) + // deps.UserStoreProvider is the notification-wrapped provider, so a + // promoted series queues an interest recompute. The promotion effects + // are the personal data handler, wired once it exists. + watchlistTitles = watchlist.NewTitles(deps.DB, itemRepo, deps.UserStoreProvider, requestsTMDB, nil) + catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler) + catalogResourceHandler.SetWatchlistPromoter(watchlistTitles) catalogHandler = handlers.NewCatalogHandler( catalog.NewCatalogResolver(browseRepo, itemRepo). WithEpisodeRepository(episodeRepo). WithUserStoreProvider(deps.UserStoreProvider). - WithSearchProvider(catalogSearchService.Provider()), + WithSearchProvider(catalogSearchService.Provider()). + WithWatchlistPromoter(watchlistTitles), itemsHandler, ) catalogHandler.SetWorkSummaryProvider(literaryRepo) - tmdbAPIKey := "" - if deps.Config != nil { - tmdbAPIKey = deps.Config.TMDBAPIKey - } requestsRepo := mediarequests.NewRepository(deps.DB, deps.SecretCipher) requestSvc := mediarequests.NewService( requestsRepo, - tmdb.NewClient(tmdbAPIKey, 40), + requestsTMDB, mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), ) + requestSvc.SetTitleObserver(watchlistTitles) + watchlistRequestWithdrawer = requestSvc + requestSvc.SetWatchlistPreference(mediarequests.StoreWatchlistPreference{Stores: deps.UserStoreProvider}) AttachRequestRouter(requestSvc, deps.PluginService) requestSvc.SetAnimeIndex(animeids.NewStore(deps.DB)) requestSvc.SetGroupPolicyProvider(accessGroupStore) @@ -1003,6 +1020,10 @@ func newChiRouter(deps Dependencies) chi.Router { if deps.DB != nil { // Drops live in Postgres whichever store holds the profile. profileHandler.DroppedSeriesPurger = catalog.NewDroppedSeriesRepo(deps.DB) + profileHandler.WatchlistTitlesPurger = watchlistTitles + } + if watchlistRequestWithdrawer != nil { + profileHandler.WatchlistRequestWithdrawer = watchlistRequestWithdrawer } profileHandler.ProfileTokens = profileTokenService // Private S3 preserves existing avatar keys and presigned delivery. Local @@ -1019,6 +1040,10 @@ func newChiRouter(deps Dependencies) chi.Router { } personalDataHandler.SetEpisodeRepo(episodeRepo) personalDataHandler.SetSeasonRepo(seasonRepo) + if watchlistTitles != nil { + personalDataHandler.SetWatchlistTitles(watchlistTitles) + watchlistTitles.SetEffects(personalDataHandler) + } personalDataHandler.EventsHub = deps.EventsHub if dispatcher, ok := deps.WatchProviderService.(handlers.LocalListEventDispatcher); ok { personalDataHandler.SetLocalListEventDispatcher(dispatcher) @@ -1436,6 +1461,9 @@ func newChiRouter(deps Dependencies) chi.Router { adminHandler.RestartStatus = restartStatus adminHandler.CatalogSearchStatus = catalogSearchService adminHandler.DiagnosticsStore = diagnosticsStore + if watchlistTitles != nil && deps.DB != nil { + adminHandler.WatchlistTitlesSweeper = watchlistTitles + } if settingsRepo != nil { adminHandler.SettingsRepo = settingsRepo } @@ -1732,6 +1760,9 @@ func newChiRouter(deps Dependencies) chi.Router { sectionBulkHandler = &handlers.SectionBulkHandler{Repo: sectionRepo} sectionFetcher := sections.NewFetcher(deps.DB) sectionFetcher.StoreProvider = deps.UserStoreProvider + if watchlistTitles != nil { + sectionFetcher.WatchlistPromoter = watchlistTitles + } sectionFetcher.CollectionRepo = catalog.NewLibraryCollectionRepository(deps.DB) sectionFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider) sectionFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB) @@ -1943,6 +1974,9 @@ func newChiRouter(deps Dependencies) chi.Router { if deps.DB != nil { recsFetcher := sections.NewFetcher(deps.DB) recsFetcher.StoreProvider = deps.UserStoreProvider + if watchlistTitles != nil { + recsFetcher.WatchlistPromoter = watchlistTitles + } recsFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider) recsFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB) recsHandler.Fetcher = recsFetcher @@ -2697,6 +2731,10 @@ func newChiRouter(deps Dependencies) chi.Router { v2deps.Requests = requestHandler.Service() v2deps.RequestLifecycle = requestHandler.Service() v2deps.AdminRequests = requestHandler.Service() + if watchlistRequests, ok := requestHandler.Service().(apiv2.WatchlistRequestService); ok && personalDataHandler != nil && watchlistTitles != nil { + v2deps.WatchlistTitles = personalDataHandler + v2deps.WatchlistRequests = watchlistRequests + } } if collectionHandler != nil { v2deps.PersonalCollections = collectionHandler diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index ac9b32d43d..4a1d487659 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -796,6 +796,9 @@ GET /api/v2/watch/{id} non-media DELETE /api/v2/watched/{id} non-media POST /api/v2/watched/{id} non-media GET /api/v2/watchlist non-media +GET /api/v2/watchlist/titles non-media +DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id} non-media +PUT /api/v2/watchlist/titles/{media_type}/{tmdb_id} non-media DELETE /api/v2/watchlist/{item_id} non-media GET /api/v2/watchlist/{item_id} non-media PUT /api/v2/watchlist/{item_id} non-media @@ -1978,6 +1981,9 @@ GET /api/v2/watch/{id} non-media DELETE /api/v2/watched/{id} non-media POST /api/v2/watched/{id} non-media GET /api/v2/watchlist non-media +GET /api/v2/watchlist/titles non-media +DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id} non-media +PUT /api/v2/watchlist/titles/{media_type}/{tmdb_id} non-media DELETE /api/v2/watchlist/{item_id} non-media GET /api/v2/watchlist/{item_id} non-media PUT /api/v2/watchlist/{item_id} non-media diff --git a/internal/apiv2/admin_requests.go b/internal/apiv2/admin_requests.go index eebffd2266..3cd60c93cf 100644 --- a/internal/apiv2/admin_requests.go +++ b/internal/apiv2/admin_requests.go @@ -55,6 +55,9 @@ type AdminRequestSettings struct { GlobalWindowDays int `json:"global_window_days" minimum:"1"` GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"` ForceDualQuality bool `json:"force_dual_quality"` + // WatchlistRequests is optional on update so a client that predates it + // keeps the stored value; responses always carry it. + WatchlistRequests *bool `json:"watchlist_requests,omitempty" doc:"Adding a title that is not in the library to a watchlist also requests it; absent on update keeps the stored value" example:"true"` } type AdminRequestSettingsOutput struct { ETag string `header:"ETag"` @@ -311,10 +314,24 @@ func (reg *Registry) listAdminRequests(ctx context.Context, cursors *Cursors, in return &MediaRequestCollectionOutput{Body: MediaRequestCollection{Collection: Paginated(items, next)}}, nil } func adminSettingsOf(s mediarequests.Settings) AdminRequestSettings { - return AdminRequestSettings{s.RequestsEnabled, s.GlobalMaxRequests, s.GlobalWindowDays, s.GlobalAutoApprovalEnabled, s.ForceDualQuality} + watchlist := s.WatchlistRequests + return AdminRequestSettings{ + RequestsEnabled: s.RequestsEnabled, + GlobalMaxRequests: s.GlobalMaxRequests, + GlobalWindowDays: s.GlobalWindowDays, + GlobalAutoApprovalEnabled: s.GlobalAutoApprovalEnabled, + ForceDualQuality: s.ForceDualQuality, + WatchlistRequests: &watchlist, + } } -func (b AdminRequestSettings) domain() mediarequests.Settings { - return mediarequests.Settings{RequestsEnabled: b.RequestsEnabled, GlobalMaxRequests: b.GlobalMaxRequests, GlobalWindowDays: b.GlobalWindowDays, GlobalAutoApprovalEnabled: b.GlobalAutoApprovalEnabled, ForceDualQuality: b.ForceDualQuality} + +// domain converts an update body; current supplies a field the body omits. +func (b AdminRequestSettings) domain(current mediarequests.Settings) mediarequests.Settings { + watchlist := current.WatchlistRequests + if b.WatchlistRequests != nil { + watchlist = *b.WatchlistRequests + } + return mediarequests.Settings{RequestsEnabled: b.RequestsEnabled, GlobalMaxRequests: b.GlobalMaxRequests, GlobalWindowDays: b.GlobalWindowDays, GlobalAutoApprovalEnabled: b.GlobalAutoApprovalEnabled, ForceDualQuality: b.ForceDualQuality, WatchlistRequests: watchlist} } func (reg *Registry) getAdminRequestSettings(ctx context.Context, _ *struct{}) (*AdminRequestSettingsOutput, error) { s, p := reg.adminRequestService() @@ -345,7 +362,7 @@ func (reg *Registry) updateAdminRequestSettings(ctx context.Context, in *AdminRe if p != nil { return nil, p } - r, err = g.UpdateSettingsConditional(ctx, v, in.Body.domain(), rev) + r, err = g.UpdateSettingsConditional(ctx, v, in.Body.domain(r), rev) if errors.Is(err, mediarequests.ErrStaleRevision) { current, e := reg.getAdminRequestSettings(ctx, &struct{}{}) if e != nil { diff --git a/internal/apiv2/document.go b/internal/apiv2/document.go index da1b72400c..b3b28d098d 100644 --- a/internal/apiv2/document.go +++ b/internal/apiv2/document.go @@ -715,6 +715,7 @@ func registerAll(reg *Registry) { registerSystem(reg) registerServerIdentity(reg) registerWatchlist(reg) + registerWatchlistTitles(reg) registerWatch(reg) registerOpenAPIDocument(reg) registerAPIDocs(reg) diff --git a/internal/apiv2/document_test.go b/internal/apiv2/document_test.go index 9cede85694..818c4c89f5 100644 --- a/internal/apiv2/document_test.go +++ b/internal/apiv2/document_test.go @@ -299,6 +299,9 @@ func TestGeneratedDocumentStatuses(t *testing.T) { for _, id := range []string{"listFavorites", "getFavorite", "addFavorite", "deleteFavorite", "listRatings", "getRating", "setRating", "deleteRating", "listWatchlist", "getWatchlistEntry", "addToWatchlist", "deleteWatchlistEntry"} { profileToken[id] = true } + for _, id := range watchlistTitleOperationIDs { + profileToken[id] = true + } for _, id := range recommendationOperationIDs { profileToken[id] = true } diff --git a/internal/apiv2/request_lifecycle.go b/internal/apiv2/request_lifecycle.go index 6ba0f69473..e6f3d41448 100644 --- a/internal/apiv2/request_lifecycle.go +++ b/internal/apiv2/request_lifecycle.go @@ -48,7 +48,15 @@ type FeatureStatus struct { MissingSeasonsRequestable bool `json:"missing_seasons_requestable" doc:"Whether a series already in the library can be requested for the seasons it is missing. False while a download server that takes series uses a request plugin that cannot fetch individual seasons, so such a series stays already_available."` // DownloadProgressSupported advertises download on requests, their // targets, and the title detail's request state. - DownloadProgressSupported bool `json:"download_progress_supported" doc:"Whether the server reports download progress (download on requests, their targets, and the title detail's request state). Whether a given request has any depends on its download server's request plugin."` + DownloadProgressSupported bool `json:"download_progress_supported" doc:"Whether the server reports download progress (download on requests, their targets, the title detail's request state, and watchlist titles). Whether a given request has any depends on its download server's request plugin."` + // WatchlistTitlesSupported advertises the /watchlist/titles operations + // and in_watchlist on discovery results and title detail. It follows + // RequestsEnabled because the operations answer 409 capability_disabled + // while requests are off. + WatchlistTitlesSupported bool `json:"watchlist_titles_supported" doc:"Whether the server keeps watchlist entries for titles the library doesn't have: the /watchlist/titles operations, and in_watchlist on discovery results and the title detail. False while requests are disabled, when those operations answer 409 capability_disabled; the entries are kept."` + // WatchlistRequests is the effective watchlist-requests setting for the + // viewer. + WatchlistRequests bool `json:"watchlist_requests" doc:"Whether adding a title the library doesn't have to the watchlist also requests it (or follows its request) for this viewer: requests and watchlist requests are on for the server, the profile has not opted out, and the viewer may request."` } type RequestFeatureStatusOutput struct { Status int @@ -188,7 +196,8 @@ func registerRequestLifecycle(reg *Registry, requests RequestLifecycleService, p return nil, requestProblem(err) } } - return &RequestFeatureStatusOutput{Body: FeatureStatus{Capability: Capability{State: enabledCapabilityState(status.RequestsEnabled), Allowed: &allowed}, RequestsEnabled: status.RequestsEnabled, RatingRestrictionsEnforced: status.RatingRestrictionsEnforced, FollowSupported: true, SeasonRequestsSupported: true, MissingSeasonsRequestable: status.MissingSeasonsRequestable, DownloadProgressSupported: true}}, nil + return &RequestFeatureStatusOutput{Body: FeatureStatus{Capability: Capability{State: enabledCapabilityState(status.RequestsEnabled), Allowed: &allowed}, RequestsEnabled: status.RequestsEnabled, RatingRestrictionsEnforced: status.RatingRestrictionsEnforced, FollowSupported: true, SeasonRequestsSupported: true, MissingSeasonsRequestable: status.MissingSeasonsRequestable, DownloadProgressSupported: true, + WatchlistTitlesSupported: status.RequestsEnabled && reg.deps.WatchlistTitles != nil && reg.deps.WatchlistRequests != nil, WatchlistRequests: status.WatchlistRequests && allowed}}, nil }) Register(reg, op(http.MethodPost, "/requests/{id}/cancel", "cancelRequest", "Cancel an accessible request."), func(ctx context.Context, in *RequestCancelInput) (*MediaRequestOutput, error) { if requests == nil { diff --git a/internal/apiv2/request_lifecycle_test.go b/internal/apiv2/request_lifecycle_test.go index e081e7c7ce..5ee88c1a81 100644 --- a/internal/apiv2/request_lifecycle_test.go +++ b/internal/apiv2/request_lifecycle_test.go @@ -19,6 +19,8 @@ type fakeLifecycle struct { viewer mediarequests.Viewer id, reason string err error + // requestsDisabled turns requests off for the server. + requestsDisabled bool } func (f *fakeLifecycle) Cancel(_ context.Context, v mediarequests.Viewer, id, reason string) (*mediarequests.Request, error) { @@ -29,7 +31,7 @@ func (f *fakeLifecycle) Cancel(_ context.Context, v mediarequests.Viewer, id, re } func (f *fakeLifecycle) GetFeatureStatus(_ context.Context, v mediarequests.Viewer) (mediarequests.FeatureStatus, error) { f.viewer = v - return mediarequests.FeatureStatus{RequestsEnabled: true, RatingRestrictionsEnforced: true}, f.err + return mediarequests.FeatureStatus{RequestsEnabled: !f.requestsDisabled, RatingRestrictionsEnforced: true}, f.err } type fakeWatchLifecycle struct { diff --git a/internal/apiv2/requests.go b/internal/apiv2/requests.go index 39e4151e7e..56a90aa7f2 100644 --- a/internal/apiv2/requests.go +++ b/internal/apiv2/requests.go @@ -31,9 +31,9 @@ type RequestMediaState struct { Following bool `json:"following" doc:"Whether the viewer will be notified when the media becomes available: they requested it or follow it" example:"false"` // RequestedByViewer tells a client whether to offer a follow toggle. RequestedByViewer bool `json:"requested_by_viewer" doc:"Whether the viewing profile made the active request, so there is nothing to follow" example:"false"` - // Download is filled on the title detail only: search and discovery do - // not load each result's targets. - Download *RequestDownload `json:"download,omitempty" doc:"How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) carries it"` + // Download is filled on the title detail and the watchlist titles only: + // search and discovery do not load each result's targets. + Download *RequestDownload `json:"download,omitempty" doc:"How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) and the watchlist titles (listWatchlistTitles) carry it"` } // RequestDownload is how far downloads are, as the download server last @@ -63,6 +63,7 @@ type RequestMediaResult struct { Availability string `json:"availability" doc:"missing or available in this server's catalog" example:"missing"` LibraryContentID string `json:"library_content_id,omitempty" doc:"The catalog item when the media is available" example:"movie:heat-1995"` Request RequestMediaState `json:"request"` + InWatchlist bool `json:"in_watchlist" doc:"Whether the title is on the viewing profile's watchlist, as a watchlist title or through its catalog item" example:"false"` } // RequestMediaCastMember is one cast credit on a detail document. @@ -107,6 +108,7 @@ type RequestMediaDetail struct { Availability string `json:"availability" doc:"missing or available in this server's catalog" example:"missing"` LibraryContentID string `json:"library_content_id,omitempty" doc:"The catalog item when the media is available"` Request RequestMediaState `json:"request"` + InWatchlist bool `json:"in_watchlist" doc:"Whether the title is on the viewing profile's watchlist, as a watchlist title or through its catalog item" example:"false"` Seasons []RequestMediaSeason `json:"seasons" doc:"Series: the regular seasons (specials excluded) with library availability and request coverage; empty for movies"` } @@ -224,6 +226,7 @@ type MediaRequest struct { Seasons []int `json:"seasons" doc:"Series: the requested season numbers; empty means the whole series (requests made through v1 or before season requests)"` SeasonProgress []RequestSeasonProgress `json:"season_progress" doc:"Series season requests: each requested season's episodes, once the series is in the library; empty otherwise"` OutcomeReason string `json:"outcome_reason,omitempty" doc:"Why the request was declined or withdrawn, when a reason was given"` + Source string `json:"source" doc:"What created the request: direct (the Request button or an API create) or watchlist (adding a title that is not in the library to a watchlist); more values may be added" example:"direct"` RequestedByUserID ID `json:"requested_by_user_id,omitempty" example:"1"` RequestedByProfileID ID `json:"requested_by_profile_id,omitempty" example:"p-owner"` IntegrationKind string `json:"integration_kind,omitempty" doc:"Admins only: the download server's kind" example:"radarr"` @@ -601,7 +604,9 @@ func (reg *Registry) searchRequestMedia(ctx context.Context, in *RequestMediaSea if err != nil { return nil, requestProblem(err) } - return &RequestMediaPageOutput{Body: requestMediaPageOf(page)}, nil + out := requestMediaPageOf(page) + reg.markInWatchlist(ctx, watchlistMarksOf(out.Results)) + return &RequestMediaPageOutput{Body: out}, nil } // getRequestMediaDetail is v1 GET /requests/detail/{media_type}/{tmdb_id}. @@ -614,7 +619,10 @@ func (reg *Registry) getRequestMediaDetail(ctx context.Context, in *RequestMedia if err != nil { return nil, requestProblem(err) } - return &RequestMediaDetailOutput{Body: requestMediaDetailOf(detail)}, nil + out := requestMediaDetailOf(detail) + marks := append(watchlistMarksOf(out.Recommendations), watchlistMark{mediaType: out.MediaType, tmdbID: out.TMDBID, itemID: out.LibraryContentID, in: &out.InWatchlist}) + reg.markInWatchlist(ctx, marks) + return &RequestMediaDetailOutput{Body: out}, nil } // RequestMediaStateOutput is the followRequestMedia response. @@ -656,9 +664,14 @@ func (reg *Registry) listDiscoverSections(ctx context.Context, _ *struct{}) (*Di return nil, requestProblem(err) } items := make([]DiscoverSection, 0, len(sections)) + var marks []watchlistMark for i := range sections { items = append(items, discoverSectionOf(§ions[i])) } + for i := range items { + marks = append(marks, watchlistMarksOf(items[i].Results)...) + } + reg.markInWatchlist(ctx, marks) return &DiscoverSectionCollectionOutput{Body: DiscoverSectionCollection{Collection: NewCollection(items)}}, nil } @@ -672,7 +685,9 @@ func (reg *Registry) getDiscoverSection(ctx context.Context, in *DiscoverSection if err != nil { return nil, requestProblem(err) } - return &DiscoverSectionOutput{Body: discoverSectionOf(section)}, nil + out := discoverSectionOf(section) + reg.markInWatchlist(ctx, watchlistMarksOf(out.Results)) + return &DiscoverSectionOutput{Body: out}, nil } // listDiscoverBrands is v1 GET /requests/discover/{genres,networks,studios}. @@ -717,7 +732,9 @@ func (reg *Registry) browseDiscoverBrand(ctx context.Context, in *DiscoverBrowse if err != nil { return nil, requestProblem(err) } - return &DiscoverBrowsePageOutput{Body: discoverBrowsePageOf(resp)}, nil + out := discoverBrowsePageOf(resp) + reg.markInWatchlist(ctx, watchlistMarksOf(out.Results)) + return &DiscoverBrowsePageOutput{Body: out}, nil } // browseDiscoverGenre is v1 GET /requests/discover/browse/genre/{slug}. @@ -733,7 +750,9 @@ func (reg *Registry) browseDiscoverGenre(ctx context.Context, in *DiscoverGenreB if err != nil { return nil, requestProblem(err) } - return &DiscoverBrowsePageOutput{Body: discoverBrowsePageOf(resp)}, nil + out := discoverBrowsePageOf(resp) + reg.markInWatchlist(ctx, watchlistMarksOf(out.Results)) + return &DiscoverBrowsePageOutput{Body: out}, nil } // requireSlug refuses a blank slug before the service sees it (v1 trims and @@ -800,6 +819,15 @@ func requestProblem(err error) *Problem { return NewProblem(TypeInternalError, "An unexpected error occurred.") } +// requestSourceOf reads an unset source (a request built outside the store) +// as a direct request. +func requestSourceOf(s mediarequests.Source) mediarequests.Source { + if s == "" { + return mediarequests.SourceDirect + } + return s +} + // mediaRequestOf maps a request for the viewer. The download server details // (which server and routing rule took each target, the server's own ids and // raw statuses, and the submission and target errors, which can name servers @@ -824,6 +852,7 @@ func mediaRequestOf(r *mediarequests.Request, viewer mediarequests.Viewer) Media Seasons: NonNil(r.Seasons), SeasonProgress: requestSeasonProgressOf(r.SeasonProgress), OutcomeReason: r.OutcomeReason, + Source: string(requestSourceOf(r.Source)), IsAnime: r.IsAnime, Targets: make([]RequestTarget, 0, len(r.Targets)), LibraryContentID: r.LibraryContentID, diff --git a/internal/apiv2/router.go b/internal/apiv2/router.go index 5a8e565818..4ec65a3116 100644 --- a/internal/apiv2/router.go +++ b/internal/apiv2/router.go @@ -346,6 +346,12 @@ type Dependencies struct { // PersonalLists reads and edits a profile's favorites // (*handlers.PersonalDataHandler). PersonalLists PersonalListService + // WatchlistTitles keeps a profile's watchlist entries for titles the + // library doesn't have (*handlers.PersonalDataHandler). + WatchlistTitles WatchlistTitleService + // WatchlistRequests gates the watchlist title operations and applies + // watchlist requests (*requests.Service). + WatchlistRequests WatchlistRequestService // Ratings reads and edits a profile's ratings (*handlers.RatingsHandler). Ratings RatingService // Recommendations answers the profile-scoped recommendation reads diff --git a/internal/apiv2/watchlist_titles.go b/internal/apiv2/watchlist_titles.go new file mode 100644 index 0000000000..716682ccc7 --- /dev/null +++ b/internal/apiv2/watchlist_titles.go @@ -0,0 +1,448 @@ +package apiv2 + +import ( + "context" + "errors" + "log/slog" + "net/http" + "slices" + "strconv" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/watchlist" +) + +// Watchlist titles: the acting profile's watchlist entries for movies and +// series the library doesn't have yet, keyed by TMDB ID. They live on the +// requests surface, so the three operations answer 409 capability_disabled +// while requests are off; the entries are kept. An entry whose title reaches +// the library moves onto the library watchlist (GET /watchlist) on the next +// read. + +// WatchlistTitleService is the slice of *handlers.PersonalDataHandler the +// watchlist title operations use. Every method acts as the viewer's profile +// and returns an *handlers.APIError on failure. +type WatchlistTitleService interface { + // ListWatchlistTitlesPage answers at most limit of the entries ordered by + // (added_at DESC, title id DESC) strictly after the key (nil = from the + // newest). The first page first moves the entries the library now has + // onto the library watchlist. + ListWatchlistTitlesPage(ctx context.Context, viewer handlers.PersonalListViewer, after *watchlist.PageKey, limit int) ([]watchlist.Entry, error) + // FindWatchlistTitle answers the title holding the TMDB ID, current or + // former, or nil when no watchlist tracks it. + FindWatchlistTitle(ctx context.Context, mediaType string, tmdbID int) (*watchlist.Title, error) + // AddWatchlistTitle puts the title on the library watchlist when the + // viewer may see the one library item that has it, else keeps it as an + // entry. A title already there keeps its added_at. + AddWatchlistTitle(ctx context.Context, viewer handlers.PersonalListViewer, snap watchlist.Snapshot) (handlers.WatchlistTitleAdded, error) + // RemoveWatchlistTitle takes the title holding the TMDB ID off the + // watchlist, entry and library item both, and answers the title (nil + // when no watchlist tracks the ID). + RemoveWatchlistTitle(ctx context.Context, viewer handlers.PersonalListViewer, mediaType string, tmdbID int) (*watchlist.Title, error) + // WatchlistMembership answers which titles (by any TMDB ID they have + // held) and which library items are on the viewer's watchlist. + WatchlistMembership(ctx context.Context, viewer handlers.PersonalListViewer, keys []watchlist.TitleKey, itemIDs []string) (map[watchlist.TitleKey]bool, map[string]bool, error) + // WatchlistTitleOff answers whether the title is off the viewer's + // watchlist both as an entry and, once the library has it, as a library + // watchlist item. + WatchlistTitleOff(ctx context.Context, viewer handlers.PersonalListViewer, snap watchlist.Snapshot) (bool, error) +} + +// WatchlistRequestService is the slice of *requests.Service the watchlist +// title operations use: the requests gate and rating ceiling, the TMDB +// detail of a title being added, and watchlist requests. +type WatchlistRequestService interface { + WatchlistCeiling(ctx context.Context, viewer mediarequests.Viewer) (string, error) + WatchlistTitleDetail(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) (*tmdb.MediaDetail, error) + RequestFromWatchlist(ctx context.Context, viewer mediarequests.Viewer, title mediarequests.WatchlistTitle) (mediarequests.RequestState, error) + WithdrawWatchlistRequest(ctx context.Context, viewer mediarequests.Viewer, mediaType mediarequests.MediaType, tmdbID int) error + WatchlistRequestStates(ctx context.Context, viewer mediarequests.Viewer, titles []mediarequests.WatchlistTitle) (map[mediarequests.WatchlistKey]mediarequests.RequestState, error) +} + +// WatchlistTitleListInput is the listWatchlistTitles query. +type WatchlistTitleListInput struct { + LimitParam + Cursor string `query:"cursor" doc:"Opaque cursor from page.next_cursor" example:"eyJvIjo1MH0"` +} + +// WatchlistTitleInput names one title by its TMDB identity. +type WatchlistTitleInput struct { + MediaType string `path:"media_type" enum:"movie,series" doc:"The media type" example:"movie"` + TMDBID int `path:"tmdb_id" minimum:"1" doc:"TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it" example:"949"` +} + +// WatchlistTitle is one watchlist entry for a title the library doesn't have. +type WatchlistTitle struct { + MediaType string `json:"media_type" doc:"movie or series" example:"movie"` + TMDBID int `json:"tmdb_id" doc:"The title's current TMDB identifier (external, not a Silo ID)" example:"949"` + Title string `json:"title" example:"Heat"` + Year int `json:"year,omitempty" example:"1995"` + ReleaseDate string `json:"release_date,omitempty" doc:"Calendar date, YYYY-MM-DD: the release date of a movie, the first air date of a series" example:"1995-12-15"` + PosterPath string `json:"poster_path,omitempty" doc:"TMDB image path" example:"/abc.jpg"` + VoteAverage *float64 `json:"vote_average,omitempty" doc:"TMDB rating out of 10; absent while the title has no votes" example:"7.9"` + ContentRating string `json:"content_rating,omitempty" doc:"US certification" example:"R"` + AddedAt Instant `json:"added_at" doc:"When the title joined the watchlist" example:"2026-01-02T03:04:05.000Z"` + Status string `json:"status" doc:"active, needs_review (TMDB deleted the ID and several titles could replace it) or removed (TMDB deleted the ID and nothing replaces it). More values may be added: read an unknown one as active" example:"active"` + Request RequestMediaState `json:"request" doc:"The title's request state for the viewer, with download progress while it downloads"` +} + +// WatchlistTitleCollection is the named envelope the contract carries: the +// profile's watchlist titles, newest entry first. +type WatchlistTitleCollection struct { + Collection[WatchlistTitle] +} + +// WatchlistTitleCollectionOutput is the listWatchlistTitles response. +type WatchlistTitleCollectionOutput struct { + Body WatchlistTitleCollection +} + +// WatchlistTitleEntry is where addWatchlistTitle put a title. +type WatchlistTitleEntry struct { + MediaType string `json:"media_type" doc:"movie or series" example:"movie"` + TMDBID int `json:"tmdb_id" doc:"The title's current TMDB identifier (external, not a Silo ID)" example:"949"` + AddedAt Instant `json:"added_at" doc:"When the title joined the watchlist; adding it again keeps the first time" example:"2026-01-02T03:04:05.000Z"` + ItemID ID `json:"item_id,omitempty" doc:"The catalog item, when the library has the title and the entry went to the library watchlist" example:"movie:heat-1995"` + Request RequestMediaState `json:"request" doc:"The title's request state after the add, including why a watchlist request was refused"` +} + +// WatchlistTitleEntryOutput is the addWatchlistTitle response. +type WatchlistTitleEntryOutput struct { + Body WatchlistTitleEntry +} + +// watchlistTitlePosition is the listWatchlistTitles cursor payload: the +// keyset of the last entry the previous page emitted. +type watchlistTitlePosition struct { + AddedAt time.Time `json:"a"` + TitleID int64 `json:"t"` +} + +const ( + opListWatchlistTitles = "listWatchlistTitles" + opAddWatchlistTitle = "addWatchlistTitle" + opDeleteWatchlistTitle = "deleteWatchlistTitle" +) + +var watchlistTitleOperationIDs = []string{opListWatchlistTitles, opAddWatchlistTitle, opDeleteWatchlistTitle} + +func registerWatchlistTitles(reg *Registry) { + cursors := NewCursors(reg.deps.CursorSecret) + + list := humaOp(http.MethodGet, Prefix+"/watchlist/titles", opListWatchlistTitles, "watchlist", + "List the acting profile's watchlist entries for titles the library doesn't have, newest first. Entries whose title reached the library move to the library watchlist first; titles above the viewer's rating ceiling are omitted.") + list.Errors = []int{http.StatusConflict} + Register(reg, viewerOperation(list), func(ctx context.Context, in *WatchlistTitleListInput) (*WatchlistTitleCollectionOutput, error) { + return reg.listWatchlistTitles(ctx, cursors, in) + }) + + add := humaOp(http.MethodPut, Prefix+"/watchlist/titles/{media_type}/{tmdb_id}", opAddWatchlistTitle, "watchlist", + "Add a title to the acting profile's watchlist. A title the library has goes onto the library watchlist; otherwise it is kept by TMDB ID and, when watchlist requests apply, requested or followed. Automatic retries are unsafe because provider, refresh and request effects are not change-gated.") + add.Errors = []int{http.StatusNotFound, http.StatusConflict} + Register(reg, Operation{Operation: add, Class: ClassProfileScoped, ServiceBacked: true, DemoRestricted: true, RetrySafety: RetrySafetyNonRetryable}, reg.addWatchlistTitle) + + remove := humaOp(http.MethodDelete, Prefix+"/watchlist/titles/{media_type}/{tmdb_id}", opDeleteWatchlistTitle, "watchlist", + "Remove a title from the acting profile's watchlist by its current or a former TMDB ID, from the library watchlist too, and withdraw the request the watchlist made for it while nothing has been sent. An absent entry succeeds, but automatic retries can repeat provider and refresh effects.") + remove.DefaultStatus = http.StatusNoContent + remove.Errors = []int{http.StatusConflict} + Register(reg, Operation{Operation: remove, Class: ClassProfileScoped, ServiceBacked: true, DemoRestricted: true, RetrySafety: RetrySafetyNonRetryable}, reg.deleteWatchlistTitle) +} + +// watchlistTitleViewer resolves the viewer for both services and the rating +// ceiling. It answers 409 capability_disabled while requests are off. +func (reg *Registry) watchlistTitleViewer(ctx context.Context) (handlers.PersonalListViewer, mediarequests.Viewer, string, *Problem) { + if reg.deps.WatchlistTitles == nil { + return handlers.PersonalListViewer{}, mediarequests.Viewer{}, "", unavailable("watchlist") + } + if reg.deps.WatchlistRequests == nil { + return handlers.PersonalListViewer{}, mediarequests.Viewer{}, "", unavailable("requests") + } + viewer, p := personalListViewer(ctx, "") + if p != nil { + return handlers.PersonalListViewer{}, mediarequests.Viewer{}, "", p + } + rv := lifecycleViewer(ctx) + ceiling, err := reg.deps.WatchlistRequests.WatchlistCeiling(ctx, rv) + if err != nil { + return handlers.PersonalListViewer{}, mediarequests.Viewer{}, "", requestProblem(err) + } + return viewer, rv, ceiling, nil +} + +// watchlistTitleScope binds the cursor to the profile and the viewer policy, +// whose rating ceiling filters the page, and to the keyset it pages by. +func watchlistTitleScope(ctx context.Context, viewer handlers.PersonalListViewer) CursorScope { + return CursorScope{ + OperationID: opListWatchlistTitles, + Security: strconv.Itoa(viewer.UserID) + "/" + viewer.ProfileID + "/" + viewerScopeDigest(ctx), + Sort: "-added_at,-title_id", + Tiebreaker: "title_id", + } +} + +// listWatchlistTitles pages by keyset. A limit+1 probe decides has_more from +// the raw rows, so a title above the ceiling never hides the rows behind it. +func (reg *Registry) listWatchlistTitles(ctx context.Context, cursors *Cursors, in *WatchlistTitleListInput) (*WatchlistTitleCollectionOutput, error) { + viewer, rv, ceiling, p := reg.watchlistTitleViewer(ctx) + if p != nil { + return nil, p + } + scope := watchlistTitleScope(ctx, viewer) + var after *watchlist.PageKey + if in.Cursor != "" { + var pos watchlistTitlePosition + if p := cursors.Decode(scope, in.Cursor, &pos); p != nil { + return nil, p + } + after = &watchlist.PageKey{AddedAt: pos.AddedAt, TitleID: pos.TitleID} + } + entries, err := reg.deps.WatchlistTitles.ListWatchlistTitlesPage(ctx, viewer, after, in.Limit+1) + if err != nil { + return nil, serviceProblem(err) + } + next := "" + if len(entries) > in.Limit { + entries = entries[:in.Limit] + last := entries[len(entries)-1].PageKey() + if next, err = cursors.Encode(scope, watchlistTitlePosition{AddedAt: last.AddedAt, TitleID: last.TitleID}); err != nil { + return nil, NewProblem(TypeInternalError, "An unexpected error occurred.") + } + } + visible := make([]watchlist.Entry, 0, len(entries)) + titles := make([]mediarequests.WatchlistTitle, 0, len(entries)) + for _, e := range entries { + // The stored US certification alone, as Discover filters; a title + // without one fails closed under a ceiling. + if ceiling != "" && !access.RatingAllowed(e.Title.Certification, ceiling) { + continue + } + visible = append(visible, e) + titles = append(titles, requestTitleOf(e.Title.Snapshot())) + } + states, err := reg.deps.WatchlistRequests.WatchlistRequestStates(ctx, rv, titles) + if err != nil { + return nil, requestProblem(err) + } + items := make([]WatchlistTitle, 0, len(visible)) + for _, e := range visible { + t := e.Title + item := WatchlistTitle{ + MediaType: t.MediaType, TMDBID: t.TMDBID, Title: t.Title, Year: t.Year, PosterPath: t.PosterPath, + VoteAverage: t.VoteAverage, ContentRating: t.Certification, AddedAt: NewInstant(e.AddedAt), + Status: string(t.State), + Request: requestMediaStateOf(states[mediarequests.WatchlistKey{MediaType: mediarequests.MediaType(t.MediaType), TMDBID: t.TMDBID}]), + } + if t.ReleaseDate != nil { + item.ReleaseDate = t.ReleaseDate.Format(time.DateOnly) + } + items = append(items, item) + } + return &WatchlistTitleCollectionOutput{Body: WatchlistTitleCollection{Collection: Paginated(items, next)}}, nil +} + +// addWatchlistTitle saves the entry first and applies watchlist requests +// after, so a refused or failed request never loses the entry, and a repeat +// is a no-op once either step has succeeded. +func (reg *Registry) addWatchlistTitle(ctx context.Context, in *WatchlistTitleInput) (*WatchlistTitleEntryOutput, error) { + viewer, rv, ceiling, p := reg.watchlistTitleViewer(ctx) + if p != nil { + return nil, p + } + snap, p := reg.watchlistTitleSnapshot(ctx, rv, ceiling, in) + if p != nil { + return nil, p + } + added, err := reg.deps.WatchlistTitles.AddWatchlistTitle(ctx, viewer, snap) + if err != nil { + return nil, serviceProblem(err) + } + title := requestTitleOf(snap) + var state mediarequests.RequestState + if added.ItemID != "" { + // The library has it: nothing to request, only the state to report. + states, err := reg.deps.WatchlistRequests.WatchlistRequestStates(ctx, rv, []mediarequests.WatchlistTitle{title}) + if err != nil { + return nil, requestProblem(err) + } + state = states[mediarequests.WatchlistKey{MediaType: title.MediaType, TMDBID: title.TMDBID}] + } else { + if state, err = reg.deps.WatchlistRequests.RequestFromWatchlist(ctx, rv, title); err != nil { + return nil, requestProblem(err) + } + // A delete of the same title can run between the save and the + // request: it withdraws, then removes the entry. Rechecking the entry + // after requesting, with the delete withdrawing again after its + // removal, leaves no request behind whichever way the two interleave. + off, err := reg.deps.WatchlistTitles.WatchlistTitleOff(ctx, viewer, snap) + if err != nil { + return nil, serviceProblem(err) + } + if off { + if err := reg.withdrawWatchlistRequests(ctx, rv, snap.MediaType, append([]int{snap.TMDBID}, snap.FormerTMDBIDs...)); err != nil { + return nil, requestProblem(err) + } + states, err := reg.deps.WatchlistRequests.WatchlistRequestStates(ctx, rv, []mediarequests.WatchlistTitle{title}) + if err != nil { + return nil, requestProblem(err) + } + state = states[mediarequests.WatchlistKey{MediaType: title.MediaType, TMDBID: title.TMDBID}] + } + } + return &WatchlistTitleEntryOutput{Body: WatchlistTitleEntry{ + MediaType: snap.MediaType, TMDBID: snap.TMDBID, AddedAt: NewInstant(added.AddedAt), + ItemID: ID(added.ItemID), Request: requestMediaStateOf(state), + }}, nil +} + +// watchlistTitleSnapshot resolves what the add stores: a title a watchlist +// already tracks under the ID (current or former) keeps its stored snapshot, +// which the refresh keeps current; any other title is read from TMDB. A +// title TMDB doesn't have or above the viewer's ceiling is a 404, as the +// title detail answers it. +func (reg *Registry) watchlistTitleSnapshot(ctx context.Context, rv mediarequests.Viewer, ceiling string, in *WatchlistTitleInput) (watchlist.Snapshot, *Problem) { + notFound := NewProblem(TypeNotFound, "The title was not found.") + known, err := reg.deps.WatchlistTitles.FindWatchlistTitle(ctx, in.MediaType, in.TMDBID) + if err != nil { + return watchlist.Snapshot{}, serviceProblem(err) + } + if known != nil { + if ceiling != "" && !access.RatingAllowed(known.Certification, ceiling) { + return watchlist.Snapshot{}, notFound + } + return known.Snapshot(), nil + } + detail, err := reg.deps.WatchlistRequests.WatchlistTitleDetail(ctx, rv, mediarequests.MediaType(in.MediaType), in.TMDBID) + if errors.Is(err, mediarequests.ErrNotFound) { + return watchlist.Snapshot{}, notFound + } + if err != nil { + return watchlist.Snapshot{}, requestProblem(err) + } + snap, err := watchlist.SnapshotFromDetail(detail) + if err != nil { + slog.WarnContext(ctx, "watchlist title detail is unusable", "component", "watchlist", + "media_type", in.MediaType, "tmdb_id", in.TMDBID, "error", err) + return watchlist.Snapshot{}, notFound + } + return snap, nil +} + +// deleteWatchlistTitle withdraws the watchlist's request under every TMDB ID +// the title has had, removes the entries, then withdraws once more if the +// title is still off the watchlist. A request keeps the ID it was made under. +// The first withdrawal runs before the removal because removing the last +// entry drops the title and its former IDs: a failure part way leaves the +// entry, so a retry still knows every ID. The second catches a request an +// overlapping add made after the first; it is skipped when a re-add already +// put the title back, whose request is wanted. The add rechecks its own entry +// after requesting for the other order. +func (reg *Registry) deleteWatchlistTitle(ctx context.Context, in *WatchlistTitleInput) (*struct{}, error) { + viewer, rv, _, p := reg.watchlistTitleViewer(ctx) + if p != nil { + return nil, p + } + known, err := reg.deps.WatchlistTitles.FindWatchlistTitle(ctx, in.MediaType, in.TMDBID) + if err != nil { + return nil, serviceProblem(err) + } + snap := watchlist.Snapshot{MediaType: in.MediaType, TMDBID: in.TMDBID} + ids := []int{in.TMDBID} + if known != nil { + snap = known.Snapshot() + ids = append(ids, known.TMDBID) + ids = append(ids, known.FormerTMDBIDs...) + } + if err := reg.withdrawWatchlistRequests(ctx, rv, in.MediaType, ids); err != nil { + return nil, requestProblem(err) + } + if _, err := reg.deps.WatchlistTitles.RemoveWatchlistTitle(ctx, viewer, in.MediaType, in.TMDBID); err != nil { + return nil, serviceProblem(err) + } + off, err := reg.deps.WatchlistTitles.WatchlistTitleOff(ctx, viewer, snap) + if err != nil { + return nil, serviceProblem(err) + } + if off { + if err := reg.withdrawWatchlistRequests(ctx, rv, in.MediaType, ids); err != nil { + return nil, requestProblem(err) + } + } + return nil, nil +} + +// withdrawWatchlistRequests withdraws the viewer's watchlist request under +// each distinct TMDB ID. +func (reg *Registry) withdrawWatchlistRequests(ctx context.Context, rv mediarequests.Viewer, mediaType string, ids []int) error { + seen := make([]int, 0, len(ids)) + for _, id := range ids { + if slices.Contains(seen, id) { + continue + } + seen = append(seen, id) + if err := reg.deps.WatchlistRequests.WithdrawWatchlistRequest(ctx, rv, mediarequests.MediaType(mediaType), id); err != nil { + return err + } + } + return nil +} + +// requestTitleOf is the requests service's view of a stored title. +func requestTitleOf(s watchlist.Snapshot) mediarequests.WatchlistTitle { + return mediarequests.WatchlistTitle{ + MediaType: mediarequests.MediaType(s.MediaType), TMDBID: s.TMDBID, IMDbID: s.IMDbID, TVDBID: s.TVDBID, + Title: s.Title, Year: s.Year, PosterPath: s.PosterPath, FormerTMDBIDs: s.FormerTMDBIDs, + } +} + +// watchlistMark is one discovery result or detail whose in_watchlist a page +// hydrates. +type watchlistMark struct { + mediaType string + tmdbID int + itemID string + in *bool +} + +func watchlistMarksOf(results []RequestMediaResult) []watchlistMark { + marks := make([]watchlistMark, 0, len(results)) + for i := range results { + r := &results[i] + marks = append(marks, watchlistMark{mediaType: r.MediaType, tmdbID: r.TMDBID, itemID: r.LibraryContentID, in: &r.InWatchlist}) + } + return marks +} + +// markInWatchlist sets in_watchlist on a page of results from two reads: the +// watchlist titles by TMDB ID, and the library watchlist by catalog item. A +// failure is logged and leaves the flags false; it never fails the page. +func (reg *Registry) markInWatchlist(ctx context.Context, marks []watchlistMark) { + if reg.deps.WatchlistTitles == nil || len(marks) == 0 { + return + } + viewer, p := personalListViewer(ctx, "") + if p != nil { + return + } + keys := make([]watchlist.TitleKey, 0, len(marks)) + itemIDs := make([]string, 0, len(marks)) + for _, m := range marks { + if m.tmdbID > 0 { + keys = append(keys, watchlist.TitleKey{MediaType: m.mediaType, TMDBID: m.tmdbID}) + } + if m.itemID != "" { + itemIDs = append(itemIDs, m.itemID) + } + } + onTitles, onItems, err := reg.deps.WatchlistTitles.WatchlistMembership(ctx, viewer, keys, itemIDs) + if err != nil { + slog.WarnContext(ctx, "reading watchlist membership for request results failed", "component", "watchlist", "error", err) + return + } + for _, m := range marks { + *m.in = onTitles[watchlist.TitleKey{MediaType: m.mediaType, TMDBID: m.tmdbID}] || (m.itemID != "" && onItems[m.itemID]) + } +} diff --git a/internal/apiv2/watchlist_titles_test.go b/internal/apiv2/watchlist_titles_test.go new file mode 100644 index 0000000000..2fcdc13074 --- /dev/null +++ b/internal/apiv2/watchlist_titles_test.go @@ -0,0 +1,442 @@ +package apiv2 + +import ( + "context" + "errors" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + mediarequests "github.com/Silo-Server/silo-server/internal/requests" + "github.com/Silo-Server/silo-server/internal/watchlist" +) + +// fakeWatchlistTitles is a WatchlistTitleService over a fixed entry list, +// newest first. It records the calls it saw. +type fakeWatchlistTitles struct { + entries []watchlist.Entry + known map[watchlist.TitleKey]*watchlist.Title + itemID string + + viewers []handlers.PersonalListViewer + added []watchlist.Snapshot + removed []watchlist.TitleKey + // onList holds the titles the fake's adds put on the watchlist. + // removedConcurrently makes an add's entry vanish at once, as a delete + // of the same title running alongside it would. + onList map[watchlist.TitleKey]bool + removedConcurrently bool + // readdedConcurrently makes a delete see the title back on the + // watchlist after its removal, as an add running alongside it would. + readdedConcurrently bool +} + +func (f *fakeWatchlistTitles) ListWatchlistTitlesPage(_ context.Context, viewer handlers.PersonalListViewer, after *watchlist.PageKey, limit int) ([]watchlist.Entry, error) { + f.viewers = append(f.viewers, viewer) + out := []watchlist.Entry{} + for _, e := range f.entries { + if after != nil { + k := e.PageKey() + if k.AddedAt.After(after.AddedAt) || (k.AddedAt.Equal(after.AddedAt) && k.TitleID >= after.TitleID) { + continue + } + } + if len(out) == limit { + break + } + out = append(out, e) + } + return out, nil +} + +func (f *fakeWatchlistTitles) FindWatchlistTitle(_ context.Context, mediaType string, tmdbID int) (*watchlist.Title, error) { + return f.known[watchlist.TitleKey{MediaType: mediaType, TMDBID: tmdbID}], nil +} + +func (f *fakeWatchlistTitles) AddWatchlistTitle(_ context.Context, viewer handlers.PersonalListViewer, snap watchlist.Snapshot) (handlers.WatchlistTitleAdded, error) { + f.viewers = append(f.viewers, viewer) + f.added = append(f.added, snap) + if f.itemID == "" && !f.removedConcurrently { + if f.onList == nil { + f.onList = map[watchlist.TitleKey]bool{} + } + f.onList[watchlist.TitleKey{MediaType: snap.MediaType, TMDBID: snap.TMDBID}] = true + } + return handlers.WatchlistTitleAdded{ItemID: f.itemID, AddedAt: fixedTime()}, nil +} + +func (f *fakeWatchlistTitles) RemoveWatchlistTitle(_ context.Context, viewer handlers.PersonalListViewer, mediaType string, tmdbID int) (*watchlist.Title, error) { + f.viewers = append(f.viewers, viewer) + key := watchlist.TitleKey{MediaType: mediaType, TMDBID: tmdbID} + f.removed = append(f.removed, key) + delete(f.onList, key) + return f.known[key], nil +} + +func (f *fakeWatchlistTitles) WatchlistTitleOff(_ context.Context, _ handlers.PersonalListViewer, snap watchlist.Snapshot) (bool, error) { + key := watchlist.TitleKey{MediaType: snap.MediaType, TMDBID: snap.TMDBID} + if f.readdedConcurrently { + return false, nil + } + return !f.onList[key], nil +} + +func (f *fakeWatchlistTitles) WatchlistMembership(_ context.Context, _ handlers.PersonalListViewer, keys []watchlist.TitleKey, _ []string) (map[watchlist.TitleKey]bool, map[string]bool, error) { + on := map[watchlist.TitleKey]bool{} + for _, k := range keys { + if f.known[k] != nil || f.onList[k] { + on[k] = true + } + } + return on, map[string]bool{}, nil +} + +// fakeWatchlistRequests is a WatchlistRequestService with a fixed ceiling +// and TMDB catalog. +type fakeWatchlistRequests struct { + disabled bool + ceiling string + details map[int]*tmdb.MediaDetail + + requested []mediarequests.WatchlistTitle + withdrawn []int + withdrawErr map[int]error +} + +func (f *fakeWatchlistRequests) WatchlistCeiling(context.Context, mediarequests.Viewer) (string, error) { + if f.disabled { + return "", mediarequests.ErrRequestsDisabled + } + return f.ceiling, nil +} + +func (f *fakeWatchlistRequests) WatchlistTitleDetail(_ context.Context, _ mediarequests.Viewer, _ mediarequests.MediaType, tmdbID int) (*tmdb.MediaDetail, error) { + // As the service does: above the ceiling reads as not found. + if d := f.details[tmdbID]; d != nil && (f.ceiling == "" || access.RatingAllowed(d.USCertification, f.ceiling)) { + return d, nil + } + return nil, mediarequests.ErrNotFound +} + +func (f *fakeWatchlistRequests) RequestFromWatchlist(_ context.Context, _ mediarequests.Viewer, title mediarequests.WatchlistTitle) (mediarequests.RequestState, error) { + f.requested = append(f.requested, title) + return mediarequests.RequestState{Status: mediarequests.StatusPending, RequestedByViewer: true}, nil +} + +func (f *fakeWatchlistRequests) WithdrawWatchlistRequest(_ context.Context, _ mediarequests.Viewer, _ mediarequests.MediaType, tmdbID int) error { + f.withdrawn = append(f.withdrawn, tmdbID) + return f.withdrawErr[tmdbID] +} + +func (f *fakeWatchlistRequests) WatchlistRequestStates(_ context.Context, _ mediarequests.Viewer, titles []mediarequests.WatchlistTitle) (map[mediarequests.WatchlistKey]mediarequests.RequestState, error) { + out := map[mediarequests.WatchlistKey]mediarequests.RequestState{} + for _, t := range titles { + out[mediarequests.WatchlistKey{MediaType: t.MediaType, TMDBID: t.TMDBID}] = mediarequests.RequestState{Requestable: true} + } + return out, nil +} + +func watchlistTitleEntry(id int64, tmdbID int, cert string, added time.Time) watchlist.Entry { + return watchlist.Entry{ + Title: watchlist.Title{ID: id, MediaType: "movie", TMDBID: tmdbID, Title: "Title " + strings.Repeat("x", int(id)), Certification: cert, State: watchlist.TitleActive}, + AddedAt: added, + } +} + +func watchlistTitlesDeps(titles *fakeWatchlistTitles, reqs *fakeWatchlistRequests) Dependencies { + deps := favoritesDeps(&fakePersonalLists{watchlist: watchlistRows()}) + deps.WatchlistTitles = titles + deps.WatchlistRequests = reqs + return deps +} + +func TestListWatchlistTitlesPagesAndFiltersByCeiling(t *testing.T) { + base := fixedTime() + titles := &fakeWatchlistTitles{entries: []watchlist.Entry{ + watchlistTitleEntry(4, 104, "PG", base.Add(3*time.Hour)), + watchlistTitleEntry(3, 103, "R", base.Add(2*time.Hour)), + watchlistTitleEntry(2, 102, "PG-13", base.Add(time.Hour)), + watchlistTitleEntry(1, 101, "", base), + }} + h := newTestHandler(t, watchlistTitlesDeps(titles, &fakeWatchlistRequests{ceiling: "PG-13"})) + + var ids []int + cursor, pages := "", 0 + for { + path := "/api/v2/watchlist/titles?limit=2" + if cursor != "" { + path += "&cursor=" + cursor + } + rec := do(t, h, http.MethodGet, path, "", viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + var page WatchlistTitleCollection + decodeBody(t, rec.Body, &page) + pages++ + for _, it := range page.Items { + ids = append(ids, it.TMDBID) + } + if cursor = page.Page.NextCursor; cursor == "" { + break + } + } + // The R title and the uncertified one fail the PG-13 ceiling, and the + // filtered rows do not end the paging early. + if pages != 2 || !slices.Equal(ids, []int{104, 102}) { + t.Fatalf("ids = %v pages = %d", ids, pages) + } + if v := titles.viewers[0]; v.UserID != 1 || v.ProfileID != "p-owner" { + t.Fatalf("viewer = %+v", v) + } +} + +// A cursor is bound to its operation and to the acting account and profile. +func TestListWatchlistTitlesCursorScope(t *testing.T) { + base := fixedTime() + titles := &fakeWatchlistTitles{entries: []watchlist.Entry{ + watchlistTitleEntry(2, 102, "PG", base.Add(time.Hour)), + watchlistTitleEntry(1, 101, "PG", base), + }} + h := newTestHandler(t, watchlistTitlesDeps(titles, &fakeWatchlistRequests{})) + rec := do(t, h, http.MethodGet, "/api/v2/watchlist/titles?limit=1", "", viewerHeaders()) + var page WatchlistTitleCollection + decodeBody(t, rec.Body, &page) + if page.Page.NextCursor == "" { + t.Fatalf("no cursor: %s", rec.Body.String()) + } + // Another account on the same profile id. + other := with(bearer(adminToken), "X-Profile-Id", "p-owner") + requireProblem(t, do(t, h, http.MethodGet, "/api/v2/watchlist/titles?cursor="+page.Page.NextCursor, "", other), TypeInvalidCursor) + + rec = do(t, h, http.MethodGet, "/api/v2/watchlist?limit=1", "", viewerHeaders()) + foreign := decodeCards(t, rec.Body.String()).Page.NextCursor + if foreign == "" { + t.Fatalf("no library watchlist cursor: %s", rec.Body.String()) + } + requireProblem(t, do(t, h, http.MethodGet, "/api/v2/watchlist/titles?cursor="+foreign, "", viewerHeaders()), TypeInvalidCursor) +} + +// /watchlist/titles is its own route, not an item id on the library +// watchlist's /watchlist/{item_id}. +func TestWatchlistTitlesRoutePrecedence(t *testing.T) { + titles := &fakeWatchlistTitles{} + lists := &fakePersonalLists{watchlist: watchlistRows()} + deps := favoritesDeps(lists) + deps.WatchlistTitles, deps.WatchlistRequests = titles, &fakeWatchlistRequests{} + h := newTestHandler(t, deps) + rec := do(t, h, http.MethodGet, "/api/v2/watchlist/titles", "", viewerHeaders()) + if rec.Code != http.StatusOK || len(titles.viewers) != 1 { + t.Fatalf("%d %s (title list calls %d)", rec.Code, rec.Body.String(), len(titles.viewers)) + } + if !strings.Contains(rec.Body.String(), `"items":[]`) { + t.Fatalf("body = %s", rec.Body.String()) + } +} + +func TestAddWatchlistTitle(t *testing.T) { + titles := &fakeWatchlistTitles{} + reqs := &fakeWatchlistRequests{ceiling: "PG-13", details: map[int]*tmdb.MediaDetail{ + 949: {ID: 949, MediaType: "movie", Title: "Heat", ReleaseDate: "1995-12-15", USCertification: "PG-13"}, + 950: {ID: 950, MediaType: "movie", Title: "Casino", ReleaseDate: "1995-11-22", USCertification: "R"}, + }} + h := newTestHandler(t, watchlistTitlesDeps(titles, reqs)) + + rec := do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/949", "", viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + var entry WatchlistTitleEntry + decodeBody(t, rec.Body, &entry) + if entry.TMDBID != 949 || entry.ItemID != "" || entry.Request.Status != string(mediarequests.StatusPending) { + t.Fatalf("entry = %s", rec.Body.String()) + } + if len(titles.added) != 1 || titles.added[0].Title != "Heat" || len(reqs.requested) != 1 { + t.Fatalf("added = %+v requested = %+v", titles.added, reqs.requested) + } + + // Above the ceiling and unknown to TMDB are both 404, and nothing is saved. + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/950", "", viewerHeaders()), TypeNotFound) + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/951", "", viewerHeaders()), TypeNotFound) + // A title a watchlist already tracks is checked on its stored rating. + titles.known = map[watchlist.TitleKey]*watchlist.Title{ + {MediaType: "movie", TMDBID: 952}: {ID: 9, MediaType: "movie", TMDBID: 952, Title: "Stored", Certification: "NC-17"}, + } + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/952", "", viewerHeaders()), TypeNotFound) + if len(titles.added) != 1 || len(reqs.requested) != 1 { + t.Fatalf("a refused add saved: added = %+v requested = %+v", titles.added, reqs.requested) + } + + // A title the library has goes to the library watchlist and is not requested. + titles.itemID = "movie:heat-1995" + rec = do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/949", "", viewerHeaders()) + decodeBody(t, rec.Body, &entry) + if rec.Code != http.StatusOK || entry.ItemID != "movie:heat-1995" || len(reqs.requested) != 1 { + t.Fatalf("%d %s requested = %d", rec.Code, rec.Body.String(), len(reqs.requested)) + } +} + +// Deleting by a former TMDB ID withdraws the watchlist request under both +// the named ID and the title's current one. +func TestDeleteWatchlistTitleByFormerID(t *testing.T) { + titles := &fakeWatchlistTitles{known: map[watchlist.TitleKey]*watchlist.Title{ + {MediaType: "movie", TMDBID: 100}: {ID: 1, MediaType: "movie", TMDBID: 200, Title: "Repointed"}, + }} + reqs := &fakeWatchlistRequests{} + h := newTestHandler(t, watchlistTitlesDeps(titles, reqs)) + rec := do(t, h, http.MethodDelete, "/api/v2/watchlist/titles/movie/100", "", viewerHeaders()) + if rec.Code != http.StatusNoContent { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + // Once before the removal and once after it. + if !slices.Equal(reqs.withdrawn, []int{100, 200, 100, 200}) || len(titles.removed) != 1 { + t.Fatalf("withdrawn = %v removed = %v", reqs.withdrawn, titles.removed) + } + // An absent entry succeeds and withdraws under the named ID only (twice, + // around the removal). + reqs.withdrawn = nil + rec = do(t, h, http.MethodDelete, "/api/v2/watchlist/titles/series/5", "", viewerHeaders()) + if rec.Code != http.StatusNoContent || !slices.Equal(reqs.withdrawn, []int{5, 5}) { + t.Fatalf("%d %s withdrawn = %v", rec.Code, rec.Body.String(), reqs.withdrawn) + } +} + +// With requests off, all three operations answer 409 capability_disabled +// and touch nothing. +func TestWatchlistTitlesRequestsDisabled(t *testing.T) { + titles := &fakeWatchlistTitles{} + reqs := &fakeWatchlistRequests{disabled: true} + h := newTestHandler(t, watchlistTitlesDeps(titles, reqs)) + for _, op := range []struct{ method, path string }{ + {http.MethodGet, "/api/v2/watchlist/titles"}, + {http.MethodPut, "/api/v2/watchlist/titles/movie/949"}, + {http.MethodDelete, "/api/v2/watchlist/titles/movie/949"}, + } { + requireProblem(t, do(t, h, op.method, op.path, "", viewerHeaders()), TypeCapabilityDisabled) + } + if len(titles.viewers) != 0 || len(reqs.withdrawn) != 0 || len(reqs.requested) != 0 { + t.Fatalf("disabled operations reached the services: %+v %+v", titles, reqs) + } +} + +// The watchlist title operations answer 409 while requests are off, so the +// status must not advertise them then. +func TestRequestStatusWatchlistTitlesFollowRequestsEnabled(t *testing.T) { + for _, tc := range []struct { + name string + disabled bool + want bool + }{ + {"requests on", false, true}, + {"requests off", true, false}, + } { + deps := watchlistTitlesDeps(&fakeWatchlistTitles{}, &fakeWatchlistRequests{}) + deps.RequestLifecycle = &fakeLifecycle{requestsDisabled: tc.disabled} + rec := do(t, newTestHandler(t, deps), http.MethodGet, Prefix+"/requests/status", "", viewerHeaders()) + var got struct { + Supported *bool `json:"watchlist_titles_supported"` + } + decodeBody(t, rec.Body, &got) + if rec.Code != http.StatusOK || got.Supported == nil || *got.Supported != tc.want { + t.Fatalf("%s: %d %s", tc.name, rec.Code, rec.Body.String()) + } + } +} + +func TestWatchlistTitlesValidation(t *testing.T) { + h := newTestHandler(t, watchlistTitlesDeps(&fakeWatchlistTitles{}, &fakeWatchlistRequests{})) + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/watchlist/titles/person/949", "", viewerHeaders()), TypeValidationFailed) + requireProblem(t, do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/0", "", viewerHeaders()), TypeValidationFailed) + requireProblem(t, do(t, h, http.MethodGet, "/api/v2/watchlist/titles?cursor=garbage", "", viewerHeaders()), TypeInvalidCursor) +} + +// Search results carry in_watchlist from the viewer's watchlist titles. +func TestSearchRequestMediaMarksWatchlist(t *testing.T) { + deps := requestDeps(fixtureRequests()) + rec := do(t, newTestHandler(t, deps), http.MethodGet, "/api/v2/requests/search?q=heat", "", requestOwner) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"in_watchlist":false`) { + t.Fatalf("unwired: %d %s", rec.Code, rec.Body.String()) + } + deps.WatchlistTitles = &fakeWatchlistTitles{known: map[watchlist.TitleKey]*watchlist.Title{ + {MediaType: "movie", TMDBID: 949}: {ID: 1, MediaType: "movie", TMDBID: 949}, + }} + deps.WatchlistRequests = &fakeWatchlistRequests{} + rec = do(t, newTestHandler(t, deps), http.MethodGet, "/api/v2/requests/search?q=heat", "", requestOwner) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"in_watchlist":true`) { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } +} + +// The request withdrawal runs under every ID the title has had, before the +// entry goes: removing the last entry drops the title's former IDs, so a +// withdrawal that fails part way must leave the entry for a retry. +func TestDeleteWatchlistTitleWithdrawsFormerIDsBeforeRemoving(t *testing.T) { + title := &watchlist.Title{ID: 1, MediaType: "movie", TMDBID: 200, FormerTMDBIDs: []int{100, 150}, Title: "Repointed"} + titles := &fakeWatchlistTitles{known: map[watchlist.TitleKey]*watchlist.Title{{MediaType: "movie", TMDBID: 200}: title}} + reqs := &fakeWatchlistRequests{withdrawErr: map[int]error{150: errors.New("request store unavailable")}} + h := newTestHandler(t, watchlistTitlesDeps(titles, reqs)) + + rec := do(t, h, http.MethodDelete, "/api/v2/watchlist/titles/movie/200", "", viewerHeaders()) + if rec.Code == http.StatusNoContent { + t.Fatal("a failed withdrawal answered success") + } + if len(titles.removed) != 0 { + t.Fatalf("removed = %v, want the entry kept for a retry", titles.removed) + } + + reqs.withdrawErr, reqs.withdrawn = nil, nil + rec = do(t, h, http.MethodDelete, "/api/v2/watchlist/titles/movie/200", "", viewerHeaders()) + if rec.Code != http.StatusNoContent { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if !slices.Equal(reqs.withdrawn, []int{200, 100, 150, 200, 100, 150}) || len(titles.removed) != 1 { + t.Fatalf("withdrawn = %v removed = %v; want every ID withdrawn, then one removal", reqs.withdrawn, titles.removed) + } +} + +// An add whose entry a concurrent delete took before the request landed +// withdraws the request it just made, so no request outlives the entry. +func TestAddWatchlistTitleWithdrawsWhenDeletedConcurrently(t *testing.T) { + titles := &fakeWatchlistTitles{removedConcurrently: true} + reqs := &fakeWatchlistRequests{details: map[int]*tmdb.MediaDetail{949: {MediaType: "movie", ID: 949, Title: "Heat", Year: 1995}}} + h := newTestHandler(t, watchlistTitlesDeps(titles, reqs)) + rec := do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/949", "", viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if len(reqs.requested) != 1 || !slices.Equal(reqs.withdrawn, []int{949}) { + t.Fatalf("requested = %v withdrawn = %v; want the request withdrawn after the entry vanished", reqs.requested, reqs.withdrawn) + } + + // With the entry still there, nothing is withdrawn. + titles.removedConcurrently, reqs.withdrawn, reqs.requested = false, nil, nil + if rec := do(t, h, http.MethodPut, "/api/v2/watchlist/titles/movie/949", "", viewerHeaders()); rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if len(reqs.withdrawn) != 0 { + t.Fatalf("withdrawn = %v, want none", reqs.withdrawn) + } +} + +// A delete whose title an overlapping add already put back keeps the re-add's +// request: the second withdrawal runs only while the title is still off the +// watchlist. +func TestDeleteWatchlistTitleKeepsRequestOfConcurrentReadd(t *testing.T) { + titles := &fakeWatchlistTitles{readdedConcurrently: true, known: map[watchlist.TitleKey]*watchlist.Title{ + {MediaType: "movie", TMDBID: 949}: {ID: 1, MediaType: "movie", TMDBID: 949, Title: "Heat"}, + }} + reqs := &fakeWatchlistRequests{} + h := newTestHandler(t, watchlistTitlesDeps(titles, reqs)) + rec := do(t, h, http.MethodDelete, "/api/v2/watchlist/titles/movie/949", "", viewerHeaders()) + if rec.Code != http.StatusNoContent { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if !slices.Equal(reqs.withdrawn, []int{949}) { + t.Fatalf("withdrawn = %v, want only the withdrawal before the removal", reqs.withdrawn) + } +} diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index a08e29f3ff..408b6f1bc4 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -168,6 +168,24 @@ type CatalogResolver struct { // instead of the default queryExecutorForScope. Tests inject a stub here // to observe how many times the executor is asked for a result page. previewExecutorForScope func(scope string, snapshot *time.Time) previewExecutor + watchlistPromoter WatchlistPromoter +} + +// WatchlistPromoter moves a profile's watchlist entries for titles the +// library does not have yet onto the library watchlist once it has them, so a +// watchlist read shows arrivals. *watchlist.Titles implements it; a failure +// is its to log, and the read goes on. +type WatchlistPromoter interface { + PromoteWatchlist(ctx context.Context, access AccessFilter) +} + +// WithWatchlistPromoter makes watchlist-source reads promote first. +func (r *CatalogResolver) WithWatchlistPromoter(p WatchlistPromoter) *CatalogResolver { + if r == nil { + return nil + } + r.watchlistPromoter = p + return r } func NewCatalogResolver(browseRepo *BrowseRepository, itemRepo *ItemRepository) *CatalogResolver { @@ -829,6 +847,9 @@ func (r *CatalogResolver) resolveUserCollectionItems( } func (r *CatalogResolver) resolvePersonalSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) { + if req.Source == CatalogSourceWatchlist && r.watchlistPromoter != nil { + r.watchlistPromoter.PromoteWatchlist(ctx, access) + } if req.CursorPaging { return r.resolvePersonalCursor(ctx, req, access) } diff --git a/internal/catalog/provider_aliases.go b/internal/catalog/provider_aliases.go new file mode 100644 index 0000000000..e89dd96cb1 --- /dev/null +++ b/internal/catalog/provider_aliases.go @@ -0,0 +1,163 @@ +package catalog + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/Silo-Server/silo-server/internal/contentid" +) + +// resolvableAliasProviders are the providers ResolveProviderAliases matches. +var resolvableAliasProviders = []string{contentid.ProviderTMDB, contentid.ProviderIMDB, contentid.ProviderTVDB} + +// ProviderAlias is one provider ID of a title the caller tracks outside the +// catalog (a watchlisted title the library may not have yet). Key groups the +// aliases of one title; the catalog never interprets it. +type ProviderAlias struct { + Key int64 + MediaType string // "movie" or "series" + Provider string // "tmdb", "imdb" or "tvdb" + ProviderID string +} + +// resolveProviderAliasesSQL matches each alias against a catalog item's +// current provider columns, its media_item_provider_ids rows, and the values +// its providers rejected (stale_media_ids), so a library item that carries or +// once carried a since-deleted duplicate ID still matches. Each source is its +// own branch so every lookup uses its index. Only items of the alias's media +// type with a row in an enabled folder count. +func resolveProviderAliasesSQL() string { + return ` + WITH requested(key, media_type, provider, provider_id) AS ( + SELECT * FROM unnest($1::bigint[], $2::text[], $3::text[], $4::text[]) + ), + matches(key, media_type, content_id) AS ( + SELECT r.key, r.media_type, mi.content_id + FROM requested r + JOIN media_items mi ON r.provider = 'tmdb' AND mi.tmdb_id <> '' AND mi.tmdb_id = r.provider_id + UNION + SELECT r.key, r.media_type, mi.content_id + FROM requested r + JOIN media_items mi ON r.provider = 'imdb' AND mi.imdb_id <> '' AND mi.imdb_id = r.provider_id + UNION + SELECT r.key, r.media_type, mi.content_id + FROM requested r + JOIN media_items mi ON r.provider = 'tvdb' AND mi.tvdb_id <> '' AND mi.tvdb_id = r.provider_id + UNION + SELECT r.key, r.media_type, mip.content_id + FROM requested r + JOIN media_item_provider_ids mip + ON mip.provider = r.provider + AND mip.provider_id = r.provider_id + AND mip.item_type = r.media_type + UNION + SELECT r.key, r.media_type, s.content_id + FROM requested r + JOIN stale_media_ids s ON s.provider = r.provider AND s.provider_id = r.provider_id + ) + SELECT DISTINCT m.key, m.content_id + FROM matches m + JOIN media_items mi ON mi.content_id = m.content_id AND mi.type = m.media_type + WHERE EXISTS ( + SELECT 1 + FROM media_item_libraries mil + JOIN media_folders mf ON mf.id = mil.media_folder_id + WHERE mil.content_id = mi.content_id + AND mf.enabled = true + ) + ORDER BY m.key, m.content_id` +} + +// ResolveProviderAliases returns, per alias key, every distinct catalog item +// any of the key's aliases matches (see resolveProviderAliasesSQL). Unlike +// LookupExternalIDs, which keeps the best match per candidate, it returns all +// of them: a caller must see that the library holds a title twice rather than +// silently pick one copy. Keys with no match are absent from the result, and +// each key's content IDs are sorted. +func (r *ItemRepository) ResolveProviderAliases(ctx context.Context, aliases []ProviderAlias) (map[int64][]string, error) { + keys := make([]int64, 0, len(aliases)) + mediaTypes := make([]string, 0, len(aliases)) + providers := make([]string, 0, len(aliases)) + providerIDs := make([]string, 0, len(aliases)) + for _, alias := range aliases { + provider := strings.ToLower(strings.TrimSpace(alias.Provider)) + providerID := strings.TrimSpace(alias.ProviderID) + if providerID == "" || !slices.Contains(resolvableAliasProviders, provider) { + continue + } + keys = append(keys, alias.Key) + mediaTypes = append(mediaTypes, alias.MediaType) + providers = append(providers, provider) + providerIDs = append(providerIDs, providerID) + } + out := make(map[int64][]string) + if len(keys) == 0 { + return out, nil + } + rows, err := r.pool.Query(ctx, resolveProviderAliasesSQL(), keys, mediaTypes, providers, providerIDs) + if err != nil { + return nil, fmt.Errorf("resolving provider aliases: %w", err) + } + defer rows.Close() + for rows.Next() { + var key int64 + var contentID string + if err := rows.Scan(&key, &contentID); err != nil { + return nil, fmt.Errorf("scanning provider alias match: %w", err) + } + out[key] = append(out[key], contentID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating provider alias matches: %w", err) + } + return out, nil +} + +// ItemProviderAliases returns a movie or series item's media type and every +// provider ID it carries or once carried: its tmdb/imdb/tvdb columns, its +// media_item_provider_ids rows for those providers, and the values those +// providers rejected. Key is left zero. An item that does not exist, is not a +// movie or series, or carries no IDs has no aliases. +func (r *ItemRepository) ItemProviderAliases(ctx context.Context, contentID string) (string, []ProviderAlias, error) { + rows, err := r.pool.Query(ctx, ` + WITH item AS ( + SELECT content_id, type, tmdb_id, imdb_id, tvdb_id + FROM media_items + WHERE content_id = $1 AND type IN ('movie', 'series') + ) + SELECT item.type, v.provider, v.provider_id + FROM item + CROSS JOIN LATERAL ( + SELECT 'tmdb' AS provider, item.tmdb_id AS provider_id + UNION SELECT 'imdb', item.imdb_id + UNION SELECT 'tvdb', item.tvdb_id + UNION SELECT mip.provider, mip.provider_id + FROM media_item_provider_ids mip + WHERE mip.content_id = item.content_id AND mip.provider IN ('tmdb', 'imdb', 'tvdb') + UNION SELECT s.provider, s.provider_id + FROM stale_media_ids s + WHERE s.content_id = item.content_id AND s.provider IN ('tmdb', 'imdb', 'tvdb') + ) v + WHERE coalesce(v.provider_id, '') <> '' + ORDER BY v.provider, v.provider_id`, contentID) + if err != nil { + return "", nil, fmt.Errorf("reading item provider aliases: %w", err) + } + defer rows.Close() + var mediaType string + var aliases []ProviderAlias + for rows.Next() { + var alias ProviderAlias + if err := rows.Scan(&alias.MediaType, &alias.Provider, &alias.ProviderID); err != nil { + return "", nil, fmt.Errorf("scanning item provider alias: %w", err) + } + mediaType = alias.MediaType + aliases = append(aliases, alias) + } + if err := rows.Err(); err != nil { + return "", nil, fmt.Errorf("iterating item provider aliases: %w", err) + } + return mediaType, aliases, nil +} diff --git a/internal/catalog/provider_aliases_db_test.go b/internal/catalog/provider_aliases_db_test.go new file mode 100644 index 0000000000..fd653b568c --- /dev/null +++ b/internal/catalog/provider_aliases_db_test.go @@ -0,0 +1,159 @@ +package catalog + +import ( + "context" + "fmt" + "os" + "slices" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type providerAliasFixture struct { + pool *pgxpool.Pool + repo *ItemRepository + prefix string + enabled int + disabled int +} + +func newProviderAliasFixture(t *testing.T) *providerAliasFixture { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + f := &providerAliasFixture{pool: pool, repo: NewItemRepository(pool), prefix: fmt.Sprintf("alias-%d", time.Now().UnixNano())} + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('mixed', $1, TRUE) RETURNING id`, f.prefix+"-on").Scan(&f.enabled); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('mixed', $1, FALSE) RETURNING id`, f.prefix+"-off").Scan(&f.disabled); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id LIKE $1`, f.prefix+"%") + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = ANY($1)`, []int{f.enabled, f.disabled}) + }) + return f +} + +// item inserts a catalog item in the given folder (0 for none) and returns +// its content ID. +func (f *providerAliasFixture) item(t *testing.T, suffix, mediaType, tmdbID, imdbID, tvdbID string, folder int) string { + t.Helper() + ctx := t.Context() + contentID := f.prefix + "-" + suffix + if _, err := f.pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, genres, tmdb_id, imdb_id, tvdb_id) + VALUES ($1, $2, $1, '{}'::text[], $3, $4, $5)`, contentID, mediaType, tmdbID, imdbID, tvdbID); err != nil { + t.Fatal(err) + } + if folder != 0 { + if _, err := f.pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, contentID, folder); err != nil { + t.Fatal(err) + } + } + return contentID +} + +func TestResolveProviderAliasesDB(t *testing.T) { + f := newProviderAliasFixture(t) + ctx := t.Context() + p := f.prefix + // Unique provider values per run keep parallel fixtures apart. + v := func(s string) string { return p + "-" + s } + + byTMDB := f.item(t, "tmdb", "movie", v("tmdb-1"), "", "", f.enabled) + byIMDb := f.item(t, "imdb", "movie", "", v("tt-2"), "", f.enabled) + byTVDB := f.item(t, "tvdb", "series", "", "", v("tvdb-3"), f.enabled) + byProviderRow := f.item(t, "prov", "series", "", "", "", f.enabled) + if _, err := f.pool.Exec(ctx, `INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id) VALUES ($1, 'series', 'tmdb', $2)`, byProviderRow, v("tmdb-4")); err != nil { + t.Fatal(err) + } + byStale := f.item(t, "stale", "movie", v("tmdb-5-new"), "", "", f.enabled) + if _, err := f.pool.Exec(ctx, `INSERT INTO stale_media_ids (content_id, provider, provider_id) VALUES ($1, 'tmdb', $2)`, byStale, v("tmdb-5-dead")); err != nil { + t.Fatal(err) + } + dupA := f.item(t, "dup-a", "movie", v("tmdb-6"), "", "", f.enabled) + dupB := f.item(t, "dup-b", "movie", "", v("tt-6"), "", f.enabled) + f.item(t, "disabled", "movie", v("tmdb-7"), "", "", f.disabled) + f.item(t, "unlinked", "movie", v("tmdb-8"), "", "", 0) + f.item(t, "series-not-movie", "series", v("tmdb-9"), "", "", f.enabled) + // One item reached through two aliases counts once. + twice := f.item(t, "twice", "movie", v("tmdb-10"), v("tt-10"), "", f.enabled) + // An item in a disabled folder and an enabled one still resolves. + both := f.item(t, "both", "movie", v("tmdb-11"), "", "", f.disabled) + if _, err := f.pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, both, f.enabled); err != nil { + t.Fatal(err) + } + + aliases := []ProviderAlias{ + {Key: 1, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-1")}, + {Key: 2, MediaType: "movie", Provider: "imdb", ProviderID: v("tt-2")}, + {Key: 3, MediaType: "series", Provider: "tvdb", ProviderID: v("tvdb-3")}, + {Key: 4, MediaType: "series", Provider: "tmdb", ProviderID: v("tmdb-4")}, + {Key: 5, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-5-dead")}, + {Key: 6, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-6")}, + {Key: 6, MediaType: "movie", Provider: "imdb", ProviderID: v("tt-6")}, + {Key: 7, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-7")}, + {Key: 8, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-8")}, + {Key: 9, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-9")}, + {Key: 10, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-10")}, + {Key: 10, MediaType: "movie", Provider: "imdb", ProviderID: v("tt-10")}, + {Key: 11, MediaType: "movie", Provider: "tmdb", ProviderID: v("tmdb-11")}, + {Key: 12, MediaType: "movie", Provider: "tmdb", ProviderID: v("missing")}, + {Key: 13, MediaType: "movie", Provider: "trakt", ProviderID: v("tmdb-1")}, + } + got, err := f.repo.ResolveProviderAliases(ctx, aliases) + if err != nil { + t.Fatal(err) + } + want := map[int64][]string{ + 1: {byTMDB}, + 2: {byIMDb}, + 3: {byTVDB}, + 4: {byProviderRow}, + 5: {byStale}, + 6: {dupA, dupB}, + 10: {twice}, + 11: {both}, + } + if len(got) != len(want) { + t.Fatalf("resolved keys = %v, want %v", got, want) + } + for key, ids := range want { + if !slices.Equal(got[key], ids) { + t.Errorf("key %d resolved to %v, want %v", key, got[key], ids) + } + } + + empty, err := f.repo.ResolveProviderAliases(ctx, nil) + if err != nil || len(empty) != 0 { + t.Fatalf("no aliases = %v, %v", empty, err) + } + + mediaType, itemAliases, err := f.repo.ItemProviderAliases(ctx, byStale) + if err != nil { + t.Fatal(err) + } + gotAliases := make([]string, 0, len(itemAliases)) + for _, a := range itemAliases { + gotAliases = append(gotAliases, a.Provider+":"+a.ProviderID) + } + wantAliases := []string{"tmdb:" + v("tmdb-5-dead"), "tmdb:" + v("tmdb-5-new")} + if mediaType != "movie" || !slices.Equal(gotAliases, wantAliases) { + t.Fatalf("ItemProviderAliases = %q %v, want movie %v", mediaType, gotAliases, wantAliases) + } + if mediaType, itemAliases, err := f.repo.ItemProviderAliases(ctx, f.prefix+"-absent"); err != nil || mediaType != "" || len(itemAliases) != 0 { + t.Fatalf("absent item = %q %v %v", mediaType, itemAliases, err) + } +} diff --git a/internal/contractledger/ledger_test.go b/internal/contractledger/ledger_test.go index e1b7f69b76..8859e9cf06 100644 --- a/internal/contractledger/ledger_test.go +++ b/internal/contractledger/ledger_test.go @@ -1223,6 +1223,8 @@ var mutationWithoutLegacyRow = map[string]string{ "previewRequestRoute": "V2-only read-only route preview (POST for the request body). It reads TMDB and the rules and writes nothing, so a replay returns the same answer.", "followRequestMedia": "V2-only title follow (Requests acceptance AC1/AC5): v1 had no way to follow a title someone else requested. The follow row is keyed by title and profile, so a replay converges on the same follow.", "unfollowRequestMedia": "V2-only title unfollow, the inverse of followRequestMedia. Deleting an absent follow is a no-op, so a replay converges on no follow.", + "addWatchlistTitle": "V2-only watchlist entry for a title the library doesn't have: v1 had no such entries. The entry is keyed by title and profile, but the add can also create or follow a request and, for a title the library has, fires the library watchlist add's provider and refresh effects, so it is non-retryable like addToWatchlist.", + "deleteWatchlistTitle": "V2-only removal of a watchlist title entry, the inverse of addWatchlistTitle. An absent entry succeeds, but the removal can cancel a watchlist-made request and repeat the library watchlist removal's provider and refresh effects, so it is non-retryable like deleteWatchlistEntry.", "importAdminTMDBList": "V2-only administrator import of a public TMDB list: v1 had no TMDB list source. Like the other imports it creates a new collection per call and is non-retryable.", "importTMDBListCollection": "V2-only personal import of a public TMDB list: v1 had no TMDB list source. Like the other imports it creates a new collection per call and is non-retryable.", "createAdminUserPasswordReset": "V2-only password reset link issue (issue #1442): v1 had no reset links. Each call replaces the account's single live link, so a replay only supersedes the previous link; it is non-retryable because an emailed link may already have been delivered.", diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 062ce762f4..5cb77351b0 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -1607,6 +1607,66 @@ func pickUSTVRating(cr *contentRatingsResponse) string { return "" } +// External ID sources accepted by FindByExternalID. +const ( + ExternalSourceIMDb = "imdb_id" + ExternalSourceTVDB = "tvdb_id" +) + +// FindByExternalID looks a title up by another provider's ID through TMDB's +// /find/{external_id} endpoint. source is ExternalSourceIMDb or +// ExternalSourceTVDB. It returns every movie and series TMDB lists for the +// ID, with Silo-facing media types ("movie", "series"); no result is an empty +// slice, not an error. Results are not cached: callers use it to recover a +// title whose TMDB ID stopped resolving, which is rare and wants a fresh +// answer. +func (c *Client) FindByExternalID(ctx context.Context, source, externalID string) ([]MediaResult, error) { + externalID = strings.TrimSpace(externalID) + if externalID == "" { + return nil, fmt.Errorf("tmdb: external id must not be empty") + } + switch source { + case ExternalSourceIMDb, ExternalSourceTVDB: + default: + return nil, fmt.Errorf("tmdb: invalid external source %q", source) + } + path := "/find/" + url.PathEscape(externalID) + "?external_source=" + url.QueryEscape(source) + var resp findResponse + if err := c.doGet(ctx, path, &resp); err != nil { + return nil, err + } + out := make([]MediaResult, 0, len(resp.MovieResults)+len(resp.TVResults)) + for _, item := range resp.MovieResults { + out = append(out, MediaResult{ + ID: item.ID, + MediaType: "movie", + Title: item.Title, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.ReleaseDate, + Year: releaseYear(item.ReleaseDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + for _, item := range resp.TVResults { + out = append(out, MediaResult{ + ID: item.ID, + MediaType: "series", + Title: item.Name, + Overview: item.Overview, + PosterPath: item.PosterPath, + BackdropPath: item.BackdropPath, + ReleaseDate: item.FirstAirDate, + Year: releaseYear(item.FirstAirDate), + Popularity: item.Popularity, + VoteAverage: item.VoteAverage, + }) + } + return out, nil +} + func (c *Client) fetchExternalIDs(ctx context.Context, path string) (*ExternalIDs, error) { var resp ExternalIDs if err := c.doGet(ctx, path, &resp); err != nil { diff --git a/internal/metadata/tmdb/client_test.go b/internal/metadata/tmdb/client_test.go index 08cadcd24c..96f029bbbb 100644 --- a/internal/metadata/tmdb/client_test.go +++ b/internal/metadata/tmdb/client_test.go @@ -1573,3 +1573,56 @@ func TestCloneMediaDetailCopiesEveryReference(t *testing.T) { } } } + +func TestFindByExternalID(t *testing.T) { + var gotPath, gotSource string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotSource = r.URL.Query().Get("external_source") + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/find/tt0137523": + _, _ = w.Write([]byte(`{"movie_results":[{"id":550,"title":"Fight Club","release_date":"1999-10-15","poster_path":"/p.jpg"}],"tv_results":[{"id":77,"name":"Fight Club TV","first_air_date":"2001-01-01"}],"person_results":[{"id":1}]}`)) + case "/find/81189": + _, _ = w.Write([]byte(`{"movie_results":[],"tv_results":[]}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"status_code":34,"status_message":"The resource you requested could not be found."}`)) + } + })) + defer server.Close() + + client := NewClient("test-key", 1000) + defer client.Close() + client.SetBaseURL(server.URL) + + results, err := client.FindByExternalID(t.Context(), ExternalSourceIMDb, "tt0137523") + if err != nil { + t.Fatalf("FindByExternalID: %v", err) + } + if gotPath != "/find/tt0137523" || gotSource != "imdb_id" { + t.Fatalf("request = %s source %q", gotPath, gotSource) + } + want := []MediaResult{ + {ID: 550, MediaType: "movie", Title: "Fight Club", ReleaseDate: "1999-10-15", Year: 1999, PosterPath: "/p.jpg"}, + {ID: 77, MediaType: "series", Title: "Fight Club TV", ReleaseDate: "2001-01-01", Year: 2001}, + } + if !reflect.DeepEqual(results, want) { + t.Fatalf("results = %+v, want %+v", results, want) + } + + empty, err := client.FindByExternalID(t.Context(), ExternalSourceTVDB, "81189") + if err != nil || len(empty) != 0 || gotSource != "tvdb_id" { + t.Fatalf("empty find = %+v, %v (source %q)", empty, err, gotSource) + } + + if _, err := client.FindByExternalID(t.Context(), ExternalSourceIMDb, "tt-missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("404 error = %v, want ErrNotFound", err) + } + if _, err := client.FindByExternalID(t.Context(), "facebook_id", "x"); err == nil { + t.Fatal("unsupported source should fail before any request") + } + if _, err := client.FindByExternalID(t.Context(), ExternalSourceIMDb, " "); err == nil { + t.Fatal("empty id should fail before any request") + } +} diff --git a/internal/metadata/tmdb/types.go b/internal/metadata/tmdb/types.go index a1980e7a3e..fcc0d213af 100644 --- a/internal/metadata/tmdb/types.go +++ b/internal/metadata/tmdb/types.go @@ -156,6 +156,13 @@ type mediaTVResponse struct { VoteAverage float64 `json:"vote_average"` } +// findResponse is TMDB's /find/{external_id} payload. Only the movie and TV +// result lists matter here; people, episodes and seasons are ignored. +type findResponse struct { + MovieResults []mediaMovieResponse `json:"movie_results"` + TVResults []mediaTVResponse `json:"tv_results"` +} + type mediaMultiSearchResponse struct { ID int `json:"id"` MediaType string `json:"media_type"` diff --git a/internal/notifications/interest_hooks.go b/internal/notifications/interest_hooks.go index b53a6dec86..01e1e05ec0 100644 --- a/internal/notifications/interest_hooks.go +++ b/internal/notifications/interest_hooks.go @@ -371,6 +371,17 @@ func (s *interestTrackingStore) AddToWatchlist(ctx context.Context, profileID, m return err } +// AddToWatchlistAt is the add that keeps an earlier added_at: watch-provider +// and Plex imports, and promotion of a watchlisted title that has since +// reached the library, all write through it. +func (s *interestTrackingStore) AddToWatchlistAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error) { + inserted, err := s.UserStore.AddToWatchlistAt(ctx, profileID, mediaItemID, addedAt) + if err == nil && inserted { + s.updater.QueueItemMutation(s.userID, profileID, mediaItemID) + } + return inserted, err +} + func (s *interestTrackingStore) RemoveFromWatchlist(ctx context.Context, profileID, mediaItemID string) error { err := s.UserStore.RemoveFromWatchlist(ctx, profileID, mediaItemID) if err == nil { diff --git a/internal/notifications/interest_hooks_test.go b/internal/notifications/interest_hooks_test.go index c78610a0e6..f70641f12f 100644 --- a/internal/notifications/interest_hooks_test.go +++ b/internal/notifications/interest_hooks_test.go @@ -288,6 +288,45 @@ func TestInterestTrackingStoreQueuesMutationsOnBatchFallback(t *testing.T) { } } +// TestInterestTrackingStoreQueuesWatchlistAddAt covers the explicit-time add +// that imports and watchlist-title promotion use: a new row queues an interest +// recompute, and an add that changes nothing does not. +func TestInterestTrackingStoreQueuesWatchlistAddAt(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := userdb.InitSchema(db); err != nil { + t.Fatalf("init schema: %v", err) + } + updater := &InterestUpdater{pending: map[interestMutation]int{}} + provider := WrapUserStoreProvider(preferenceTransactionTestProvider{store: userdb.NewSQLiteUserStore(db)}, &System{Interest: updater}) + store, err := provider.ForUser(t.Context(), 7) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(t.Context(), userstore.Profile{ID: "p1", Name: "Test"}); err != nil { + t.Fatal(err) + } + addedAt := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + inserted, err := store.AddToWatchlistAt(t.Context(), "p1", "series-1", addedAt) + if err != nil || !inserted { + t.Fatalf("AddToWatchlistAt = %v, %v; want inserted", inserted, err) + } + key := interestMutation{userID: 7, profileID: "p1", itemID: "series-1"} + if _, queued := updater.pending[key]; !queued { + t.Fatal("watchlist add bypassed the interest hook") + } + clear(updater.pending) + if inserted, err := store.AddToWatchlistAt(t.Context(), "p1", "series-1", addedAt); err != nil || inserted { + t.Fatalf("repeat AddToWatchlistAt = %v, %v; want no insert", inserted, err) + } + if len(updater.pending) != 0 { + t.Fatalf("an add that inserted nothing queued %v", updater.pending) + } +} + // rollupCapableStore is a UserStore that also implements the series rollup, // standing in for the Postgres backend. type rollupCapableStore struct { diff --git a/internal/requests/editor_concurrency.go b/internal/requests/editor_concurrency.go index 71ed83e6e9..750d34b900 100644 --- a/internal/requests/editor_concurrency.go +++ b/internal/requests/editor_concurrency.go @@ -62,7 +62,7 @@ func (r *Repository) UpdateSettingsConditional(ctx context.Context, in Settings, if err = lockRevision(ctx, tx, `SELECT revision FROM request_settings WHERE id=true FOR UPDATE`, nil, expected, true); err != nil { return Settings{}, err } - out, err := r.updateSettings(ctx, tx, in, expected) + out, err := r.updateSettings(ctx, tx, in, expected, &in.WatchlistRequests) if errors.Is(err, pgx.ErrNoRows) { return Settings{}, ErrStaleRevision } diff --git a/internal/requests/repository.go b/internal/requests/repository.go index 1ffbefb8dd..817a52d8fc 100644 --- a/internal/requests/repository.go +++ b/internal/requests/repository.go @@ -42,10 +42,10 @@ func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { var s Settings err := r.pool.QueryRow(ctx, ` SELECT requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, force_dual_quality, updated_at, revision + global_auto_approval_enabled, force_dual_quality, watchlist_requests, updated_at, revision FROM request_settings WHERE id = true - `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt, &s.Revision) + `).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.WatchlistRequests, &s.UpdatedAt, &s.Revision) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Settings{ @@ -53,6 +53,7 @@ func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { GlobalMaxRequests: 5, GlobalWindowDays: 7, GlobalAutoApprovalEnabled: false, + WatchlistRequests: true, }, nil } return Settings{}, fmt.Errorf("get request settings: %w", err) @@ -60,11 +61,16 @@ func (r *Repository) GetSettings(ctx context.Context) (Settings, error) { return s, nil } +// UpdateSettings is the v1 settings write. The frozen v1 body does not carry +// watchlist_requests, so the stored value is kept. func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Settings, error) { - return r.updateSettings(ctx, r.pool, settings, -1) + return r.updateSettings(ctx, r.pool, settings, -1, nil) } -func (r *Repository) updateSettings(ctx context.Context, exec requestExecutor, settings Settings, expected int64) (Settings, error) { +// updateSettings writes the settings row when its revision still equals +// expected (-1 skips the check). A nil watchlist keeps the stored +// watchlist_requests value, true on a first write. +func (r *Repository) updateSettings(ctx context.Context, exec requestExecutor, settings Settings, expected int64, watchlist *bool) (Settings, error) { if settings.GlobalWindowDays <= 0 { settings.GlobalWindowDays = 7 } @@ -76,21 +82,22 @@ func (r *Repository) updateSettings(ctx context.Context, exec requestExecutor, s err := exec.QueryRow(ctx, ` INSERT INTO request_settings ( id, requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, force_dual_quality, updated_at + global_auto_approval_enabled, force_dual_quality, watchlist_requests, updated_at ) - VALUES (true, $1, $2, $3, $4, $5, now()) + VALUES (true, $1, $2, $3, $4, $5, COALESCE($7::boolean, true), now()) ON CONFLICT (id) DO UPDATE SET requests_enabled = EXCLUDED.requests_enabled, global_max_requests = EXCLUDED.global_max_requests, global_window_days = EXCLUDED.global_window_days, global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled, force_dual_quality = EXCLUDED.force_dual_quality, + watchlist_requests = COALESCE($7::boolean, request_settings.watchlist_requests), updated_at = now() WHERE $6::bigint = -1 OR request_settings.revision = $6 RETURNING requests_enabled, global_max_requests, global_window_days, - global_auto_approval_enabled, force_dual_quality, updated_at, revision - `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality, expected). - Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.UpdatedAt, &s.Revision) + global_auto_approval_enabled, force_dual_quality, watchlist_requests, updated_at, revision + `, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality, expected, watchlist). + Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &s.WatchlistRequests, &s.UpdatedAt, &s.Revision) if err != nil { return Settings{}, fmt.Errorf("update request settings: %w", err) } @@ -219,6 +226,32 @@ func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, return out, nil } +func (r *Repository) ListProfileWatchlistRequests(ctx context.Context, userID int, profileID string) ([]*Request, error) { + rows, err := r.pool.Query(ctx, requestSelectSQL()+` + WHERE requested_by_user_id = $1 + AND requested_by_profile_id = $2 + AND source = 'watchlist' + AND outcome = 'active' + AND status <> 'completed' + `, userID, profileID) + if err != nil { + return nil, fmt.Errorf("list profile watchlist requests: %w", err) + } + defer rows.Close() + var out []*Request + for rows.Next() { + req, err := scanRequest(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate profile watchlist requests: %w", err) + } + return out, nil +} + // quotaLockNamespace partitions advisory locks so request-quota locks do not // collide with advisory locks held elsewhere in the database. The value is // arbitrary; what matters is that it is stable. @@ -343,20 +376,20 @@ func (r *Repository) insertRequest( id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year, overview, poster_path, backdrop_path, status, outcome, requested_by_user_id, requested_by_profile_id, is_anime, created_at, updated_at, approved_at, - routing_facts, seasons + routing_facts, seasons, source ) VALUES ( $1, 'tmdb', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $16, $17, - $18, $19 + $18, $19, $20 ) RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID, strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year, strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath), strings.TrimSpace(input.Input.BackdropPath), status, outcome, input.Requester.UserID, input.Requester.ProfileID, input.IsAnime, now, approvedAt, - facts, nonNilSeasons(input.Input.Seasons)) + facts, nonNilSeasons(input.Input.Seasons), requestSource(input.Input.Source)) req, err := scanRequest(row) if err != nil { return nil, fmt.Errorf("insert request: %w", err) @@ -1392,6 +1425,14 @@ func nonNilSeasons(seasons []int) []int { return seasons } +// requestSource stores an unset source as a direct request. +func requestSource(source Source) Source { + if source == "" { + return SourceDirect + } + return source +} + func requestSelectSQL() string { return "SELECT " + requestColumns() + " FROM media_requests " } @@ -1401,7 +1442,7 @@ func requestColumns() string { overview, poster_path, backdrop_path, status, outcome, requested_by_user_id, requested_by_profile_id, is_anime, last_error, created_at, updated_at, approved_at, completed_at, - submit_attempts, submit_lease_until, next_submit_at, outcome_reason, routing_facts, seasons` + submit_attempts, submit_lease_until, next_submit_at, outcome_reason, routing_facts, seasons, source` } type requestScanner interface { @@ -1441,6 +1482,7 @@ func scanRequest(row requestScanner) (*Request, error) { &req.OutcomeReason, &rawFacts, &req.Seasons, + &req.Source, ); err != nil { return nil, err } diff --git a/internal/requests/repository_page_test.go b/internal/requests/repository_page_test.go index ff97000f7a..44042b4e53 100644 --- a/internal/requests/repository_page_test.go +++ b/internal/requests/repository_page_test.go @@ -29,7 +29,8 @@ func TestRequestListKeysetDatabase(t *testing.T) { requested_by_user_id int, requested_by_profile_id text, is_anime bool, last_error text, created_at timestamptz, updated_at timestamptz, approved_at timestamptz, completed_at timestamptz, submit_attempts int NOT NULL DEFAULT 0, submit_lease_until timestamptz, next_submit_at timestamptz, - outcome_reason text NOT NULL DEFAULT '', routing_facts jsonb NOT NULL DEFAULT '{}', seasons integer[] NOT NULL DEFAULT '{}'); + outcome_reason text NOT NULL DEFAULT '', routing_facts jsonb NOT NULL DEFAULT '{}', seasons integer[] NOT NULL DEFAULT '{}', + source text NOT NULL DEFAULT 'direct'); CREATE INDEX ON media_requests (requested_by_user_id, created_at DESC, id DESC)`) if err != nil { t.Fatal(err) @@ -37,7 +38,7 @@ func TestRequestListKeysetDatabase(t *testing.T) { stamp := time.Date(2026, 1, 2, 3, 4, 5, 123456000, time.UTC) insert := func(id string, user int, at time.Time) { t.Helper() - _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL,'','{}','{}')`, id, user, at) + _, err := pool.Exec(t.Context(), `INSERT INTO media_requests VALUES ($1,'tmdb','movie',1,NULL,'','',NULL,'','','','pending','active',$2,'profile',false,'',$3,$3,NULL,NULL,0,NULL,NULL,'','{}','{}','direct')`, id, user, at) if err != nil { t.Fatal(err) } diff --git a/internal/requests/service.go b/internal/requests/service.go index 36a68caad5..bc760e9b50 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -88,6 +88,8 @@ type Service struct { tvdbResolver TVDBIDResolver notifier FulfillmentNotifier lifecycle LifecycleNotifier + watchlistPref WatchlistPreference + titleObserver TitleObserver Now func() time.Time } @@ -711,6 +713,8 @@ func (s *Service) GetDetail(ctx context.Context, viewer Viewer, mediaType MediaT } raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID) + // A watchlist title tracking this TMDB ID refreshes from the read. + s.observeDetail(ctx, mediaType, tmdbID, raw, err) if err != nil { return nil, err } @@ -912,6 +916,17 @@ func (s *Service) CreateRequest(ctx context.Context, viewer Viewer, input Create year := detail.Year normalized.Year = &year } + // A caller without the display fields (a watchlist add keeps only + // its own snapshot) gets TMDB's; one that sent them keeps its own. + if normalized.Overview == "" { + normalized.Overview = strings.TrimSpace(detail.Overview) + } + if normalized.PosterPath == "" { + normalized.PosterPath = strings.TrimSpace(detail.PosterPath) + } + if normalized.BackdropPath == "" { + normalized.BackdropPath = strings.TrimSpace(detail.BackdropPath) + } } facts := s.routingFacts(ctx, detail) if normalized.MediaType == MediaTypeSeries && !normalized.WholeSeries { @@ -1346,7 +1361,7 @@ func (s *Service) GetSettings(ctx context.Context, viewer Viewer) (Settings, err return s.store.GetSettings(ctx) } -func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus, error) { +func (s *Service) GetFeatureStatus(ctx context.Context, viewer Viewer) (FeatureStatus, error) { settings, err := s.store.GetSettings(ctx) if err != nil { return FeatureStatus{}, err @@ -1364,6 +1379,9 @@ func (s *Service) GetFeatureStatus(ctx context.Context, _ Viewer) (FeatureStatus if status.MissingSeasonsRequestable, err = s.moreSeasonsRequestable(ctx); err != nil { return FeatureStatus{}, err } + // The account's own permission is left to the caller, which reads + // it for the request capability anyway. + status.WatchlistRequests = settings.WatchlistRequests && viewer.UserID != 0 && s.watchlistAutoRequest(ctx, viewer) } return status, nil } diff --git a/internal/requests/service_test.go b/internal/requests/service_test.go index 68b95f3dd7..dc52dc0f7d 100644 --- a/internal/requests/service_test.go +++ b/internal/requests/service_test.go @@ -1999,6 +1999,9 @@ type fakeStore struct { groupLimits map[int64]*GroupLimit // userLimitReads counts policy resolutions (each reads the account's limit once). userLimitReads int + // trackActive makes CreateRequest record the new request as the title's + // open one, the way ListActiveByTMDB reads the repository. + trackActive bool setExternalIDsErr error @@ -2106,6 +2109,19 @@ func (f *fakeStore) ListActiveByTMDB(_ context.Context, mediaType MediaType, ids return out, nil } +func (f *fakeStore) ListProfileWatchlistRequests(_ context.Context, userID int, profileID string) ([]*Request, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []*Request + for _, req := range f.requests { + if req.RequestedByUserID == userID && req.RequestedByProfileID == profileID && + req.Source == SourceWatchlist && req.Outcome == OutcomeActive && req.Status != StatusCompleted { + out = append(out, req) + } + } + return out, nil +} + func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) (*Request, error) { f.mu.Lock() defer f.mu.Unlock() @@ -2136,10 +2152,14 @@ func (f *fakeStore) CreateRequest(_ context.Context, input CreateRequestRecord) Seasons: input.Input.Seasons, RequestedByUserID: input.Requester.UserID, RequestedByProfileID: input.Requester.ProfileID, + Source: requestSource(input.Input.Source), CreatedAt: input.Now, UpdatedAt: input.Now, } f.requests[input.ID] = req + if f.trackActive && req.Outcome == OutcomeActive { + f.active[req.MediaType][req.TMDBID] = req + } copy := *req return ©, nil } diff --git a/internal/requests/store.go b/internal/requests/store.go index 8614183417..f4accc2b52 100644 --- a/internal/requests/store.go +++ b/internal/requests/store.go @@ -12,6 +12,9 @@ type Store interface { UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) + // ListProfileWatchlistRequests returns the active requests a profile's + // watchlist made. + ListProfileWatchlistRequests(ctx context.Context, userID int, profileID string) ([]*Request, error) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) GetRequest(ctx context.Context, id string) (*Request, error) // ListReconciliationCandidates returns in-flight requests (approved, diff --git a/internal/requests/types.go b/internal/requests/types.go index 2ba5223941..3df84ac25f 100644 --- a/internal/requests/types.go +++ b/internal/requests/types.go @@ -12,6 +12,16 @@ const ( MediaTypeAll MediaType = "all" ) +// Source is what created a request. +type Source string + +const ( + // SourceDirect is the Request button, or any API create. + SourceDirect Source = "direct" + // SourceWatchlist is a watchlist add of a title not in the library. + SourceWatchlist Source = "watchlist" +) + type Status string const ( @@ -189,13 +199,17 @@ type Viewer struct { } type Settings struct { - Revision int64 `json:"-"` - RequestsEnabled bool `json:"requests_enabled"` - GlobalMaxRequests int `json:"global_max_requests"` - GlobalWindowDays int `json:"global_window_days"` - GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"` - ForceDualQuality bool `json:"force_dual_quality"` - UpdatedAt time.Time `json:"updated_at"` + Revision int64 `json:"-"` + RequestsEnabled bool `json:"requests_enabled"` + GlobalMaxRequests int `json:"global_max_requests"` + GlobalWindowDays int `json:"global_window_days"` + GlobalAutoApprovalEnabled bool `json:"global_auto_approval_enabled"` + ForceDualQuality bool `json:"force_dual_quality"` + // WatchlistRequests lets adding a title that is not in the library to a + // watchlist request it too. v2 only; the frozen v1 shape does not carry + // it, and a v1 settings write keeps the stored value. + WatchlistRequests bool `json:"-"` + UpdatedAt time.Time `json:"updated_at"` } type FeatureStatus struct { @@ -208,6 +222,11 @@ type FeatureStatus struct { // MissingSeasonsRequestable reports whether a series already in the // library can be requested for its missing seasons (v2 only). MissingSeasonsRequestable bool `json:"-"` + // WatchlistRequests reports that adding a title that is not in the + // library to the viewer's watchlist also requests it: the server and + // profile settings are on. The caller adds the account's permission + // (v2 only). + WatchlistRequests bool `json:"-"` } type UserLimit struct { @@ -252,7 +271,9 @@ type Request struct { RequesterUsername string `json:"-"` // OutcomeReason is why the request was declined or withdrawn, when a // reason was given. v2 only; the frozen v1 shape does not carry it. - OutcomeReason string `json:"-"` + OutcomeReason string `json:"-"` + // Source is what created the request. v2 only. + Source Source `json:"-"` IntegrationKind string `json:"integration_kind,omitempty"` IsAnime bool `json:"is_anime"` Targets []Target `json:"targets,omitempty"` @@ -502,6 +523,9 @@ type CreateRequestInput struct { // series request asks for the whole series and is refused once the // series is in the library. WholeSeries bool `json:"-"` + // Source records what created the request; empty means SourceDirect. + // Set by the watchlist path, never read from a client body. + Source Source `json:"-"` } // RequestPageKey identifies the last emitted request in descending creation order. diff --git a/internal/requests/watchlist.go b/internal/requests/watchlist.go new file mode 100644 index 0000000000..0e2a6dfa47 --- /dev/null +++ b/internal/requests/watchlist.go @@ -0,0 +1,484 @@ +package requests + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/settingskeys" + "github.com/Silo-Server/silo-server/internal/settingsresolve" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// Watchlist requests: adding a title the library doesn't have to a watchlist +// also requests it, or follows the request someone else already made. The +// watchlist entry is written first and is kept whatever happens here; these +// calls only report the request state the entry's card shows. + +// withdrawnFromWatchlist is the outcome reason on a request canceled because +// its title left the watchlist that created it. +const withdrawnFromWatchlist = "Removed from the watchlist" + +// WatchlistPreference reads a profile's own opt-out, +// requests.watchlist_auto_request. +type WatchlistPreference interface { + WatchlistAutoRequest(ctx context.Context, userID int, profileID string) bool +} + +// TitleObserver hears about TMDB detail fetches, so a tracked watchlist title +// refreshes its IDs at no extra TMDB cost. watchlist.Titles implements it. +type TitleObserver interface { + ObservedDetail(ctx context.Context, mediaType string, tmdbID int, detail *tmdb.MediaDetail) + ObservedNotFound(ctx context.Context, mediaType string, tmdbID int) +} + +// SetWatchlistPreference wires the profile opt-out. Without one every profile +// counts as opted in. +func (s *Service) SetWatchlistPreference(p WatchlistPreference) { s.watchlistPref = p } + +// SetTitleObserver wires the observer GetDetail reports its TMDB reads to. +func (s *Service) SetTitleObserver(o TitleObserver) { s.titleObserver = o } + +// StoreWatchlistPreference resolves the opt-out from the profile's stored +// settings. A read failure counts as opted out: a request is a side effect +// the profile may have turned off. +type StoreWatchlistPreference struct { + Stores userstore.UserStoreProvider +} + +// WatchlistAutoRequest implements WatchlistPreference. +func (p StoreWatchlistPreference) WatchlistAutoRequest(ctx context.Context, userID int, profileID string) bool { + if p.Stores == nil || profileID == "" { + return false + } + fail := func(msg string, err error) bool { + slog.WarnContext(ctx, "watchlist request preference unavailable", + "component", "requests", "step", msg, "profile_id", profileID, "error", err) + return false + } + store, err := p.Stores.ForUser(ctx, userID) + if err != nil { + return fail("opening user store failed", err) + } + contract, err := settingscontract.Load() + if err != nil { + return fail("loading settings contract failed", err) + } + resolved, err := settingsresolve.New(contract).Resolve(ctx, store, + settingsresolve.Context{ProfileID: profileID}, + []string{settingskeys.RequestsWatchlistAutoRequest}, nil) + if err != nil { + return fail("reading setting values failed", err) + } + if len(resolved) != 1 { + return false + } + var enabled bool + if err := json.Unmarshal(resolved[0].Value, &enabled); err != nil { + return fail("decoding the setting failed", err) + } + return enabled +} + +func (s *Service) watchlistAutoRequest(ctx context.Context, viewer Viewer) bool { + if s.watchlistPref == nil { + return true + } + return s.watchlistPref.WatchlistAutoRequest(ctx, viewer.UserID, viewer.ProfileID) +} + +// WatchlistRequestsEnabled reports whether a watchlist add requests for the +// viewer: requests and watchlist requests are on for the server, the profile +// has not opted out, and the account may request. +func (s *Service) WatchlistRequestsEnabled(ctx context.Context, viewer Viewer) (bool, error) { + settings, err := s.store.GetSettings(ctx) + if err != nil { + return false, err + } + if !settings.RequestsEnabled || !settings.WatchlistRequests { + return false, nil + } + if !s.watchlistAutoRequest(ctx, viewer) { + return false, nil + } + return s.RequestCapabilityAllowed(ctx, viewer) +} + +// WatchlistCeiling gates the watchlist-title operations, which live on the +// requests surface: it answers ErrRequestsDisabled while requests are off, +// and otherwise the viewer's parental rating ceiling ("" = unrestricted). +// A title above the ceiling is hidden from the viewer's watchlist the way +// Discover hides it. +func (s *Service) WatchlistCeiling(ctx context.Context, viewer Viewer) (string, error) { + if err := validateViewer(viewer); err != nil { + return "", err + } + if err := s.ensureRequestsEnabled(ctx); err != nil { + return "", err + } + return s.viewerContentCeiling(ctx, viewer) +} + +// WatchlistTitleDetail fetches the TMDB detail of a title being added to the +// viewer's watchlist. It answers ErrNotFound when TMDB has no such title or +// the title is above the viewer's rating ceiling, as GetDetail does, and +// reports the read to the title observer. +func (s *Service) WatchlistTitleDetail(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) (*tmdb.MediaDetail, error) { + if s == nil || s.store == nil || s.tmdb == nil { + return nil, fmt.Errorf("request service is not configured") + } + ceiling, err := s.WatchlistCeiling(ctx, viewer) + if err != nil { + return nil, err + } + mediaType, err = normalizeMediaType(mediaType) + if err != nil { + return nil, err + } + if tmdbID <= 0 { + return nil, fmt.Errorf("%w: tmdb id is required", ErrInvalidInput) + } + raw, err := s.tmdb.GetMediaDetail(ctx, string(mediaType), tmdbID) + s.observeDetail(ctx, mediaType, tmdbID, raw, err) + switch { + case errors.Is(err, tmdb.ErrNotFound): + return nil, ErrNotFound + case err != nil: + return nil, err + case raw == nil: + return nil, ErrNotFound + } + // The US certification alone, as the discovery filter and the stored + // watchlist snapshot use it; a missing one fails closed. + if ceiling != "" && !access.RatingAllowed(raw.USCertification, ceiling) { + return nil, ErrNotFound + } + return raw, nil +} + +// WatchlistTitle is what the watchlist knows about a title it asks to +// request: its IDs and the stored display fields. +type WatchlistTitle struct { + MediaType MediaType + TMDBID int + IMDbID string + TVDBID int + Title string + Year int + PosterPath string + BackdropPath string + Overview string + // FormerTMDBIDs are IDs TMDB replaced; a request made under one of them + // is still the title's request. + FormerTMDBIDs []int +} + +// tmdbIDs returns the title's current TMDB ID followed by its former ones. +func (t WatchlistTitle) tmdbIDs() []int { + return append([]int{t.TMDBID}, t.FormerTMDBIDs...) +} + +// requestOf returns the title's active request: the one under its current +// TMDB ID, else one under a former ID. +func (t WatchlistTitle) requestOf(active map[int]*Request) *Request { + for _, id := range t.tmdbIDs() { + if req := active[id]; req != nil { + return req + } + } + return nil +} + +func (t WatchlistTitle) presenceCandidate() PresenceCandidate { + candidate := PresenceCandidate{TMDBID: t.TMDBID, IMDbID: strings.TrimSpace(t.IMDbID)} + if t.TVDBID > 0 { + tvdb := t.TVDBID + candidate.TVDBID = &tvdb + } + return candidate +} + +// WatchlistKey names a title in the WatchlistRequestStates answer. +type WatchlistKey struct { + MediaType MediaType + TMDBID int +} + +// RequestFromWatchlist applies watchlist requests to a title just added to +// the viewer's watchlist, and returns the title's request state. +// +// - No open request: request it as the viewer, recorded as source watchlist. +// A series gets the default seasons, every aired season still missing. +// - Someone else's open request: follow it. +// - The viewer's own open request: nothing. +// +// A refused request (quota, blocked, requests disabled for the account) is +// logged and reported through the state's reason; it never fails the call, +// so the caller keeps the entry. Repeating the call is a no-op once the +// request or follow exists. +func (s *Service) RequestFromWatchlist(ctx context.Context, viewer Viewer, title WatchlistTitle) (RequestState, error) { + if err := validateViewer(viewer); err != nil { + return RequestState{}, err + } + ctx = withPolicyCache(ctx) + if err := s.ensureRequestsEnabled(ctx); err != nil { + return RequestState{}, err + } + mediaType, err := normalizeMediaType(title.MediaType) + if err != nil { + return RequestState{}, err + } + title.MediaType = mediaType + enabled, err := s.WatchlistRequestsEnabled(ctx, viewer) + if err != nil { + return RequestState{}, err + } + refusal := "" + if enabled { + refusal = s.applyWatchlistRequest(ctx, viewer, title) + } + states, err := s.WatchlistRequestStates(ctx, viewer, []WatchlistTitle{title}) + if err != nil { + return RequestState{}, err + } + state := states[WatchlistKey{MediaType: mediaType, TMDBID: title.TMDBID}] + if refusal != "" && state.RequestID == "" && state.Status == "" { + state.Requestable = false + state.Reason = refusal + } + return state, nil +} + +// applyWatchlistRequest creates or follows the title's request and returns +// the reason a refused request gives, if any. +func (s *Service) applyWatchlistRequest(ctx context.Context, viewer Viewer, title WatchlistTitle) string { + active, err := s.store.ListActiveByTMDB(ctx, title.MediaType, title.tmdbIDs()) + if err != nil { + s.logWatchlistRequest(ctx, "reading the title's request failed", viewer, title, err) + return "" + } + if req := title.requestOf(active); req != nil { + s.followFromWatchlist(ctx, viewer, title, req) + return "" + } + input := CreateRequestInput{ + MediaType: title.MediaType, + TMDBID: title.TMDBID, + IMDbID: title.IMDbID, + Title: title.Title, + Overview: title.Overview, + PosterPath: title.PosterPath, + BackdropPath: title.BackdropPath, + Source: SourceWatchlist, + } + if title.TVDBID > 0 { + tvdb := title.TVDBID + input.TVDBID = &tvdb + } + if title.Year > 0 { + year := title.Year + input.Year = &year + } + _, err = s.CreateRequest(ctx, viewer, input) + switch { + case err == nil: + return "" + case errors.Is(err, ErrAlreadyRequested): + // Another request landed between the read and the create. + active, err := s.store.ListActiveByTMDB(ctx, title.MediaType, title.tmdbIDs()) + if err != nil { + s.logWatchlistRequest(ctx, "reading the title's request failed", viewer, title, err) + return "" + } + if req := title.requestOf(active); req != nil { + s.followFromWatchlist(ctx, viewer, title, req) + } + return "" + } + s.logWatchlistRequest(ctx, "watchlist request refused", viewer, title, err) + return watchlistRefusalReason(err) +} + +func (s *Service) followFromWatchlist(ctx context.Context, viewer Viewer, title WatchlistTitle, req *Request) { + if req.requestedBy(viewer) { + return + } + // Follow under the request's own ID, which differs from the title's once + // TMDB repointed the title. + if _, err := s.Follow(ctx, viewer, title.MediaType, req.TMDBID); err != nil && !errors.Is(err, ErrNotRequested) { + s.logWatchlistRequest(ctx, "following the title's request failed", viewer, title, err) + } +} + +func (s *Service) logWatchlistRequest(ctx context.Context, msg string, viewer Viewer, title WatchlistTitle, err error) { + slog.InfoContext(ctx, "watchlist request not made", "component", "requests", "outcome", msg, + "user_id", viewer.UserID, "profile_id", viewer.ProfileID, + "media_type", title.MediaType, "tmdb_id", title.TMDBID, "error", err) +} + +// Request-state reasons a refused watchlist request reports. +const ( + reasonQuotaExceeded = "quota_exceeded" + reasonBlocked = "blocked" + reasonRequestsDisabled = "requests_disabled" + reasonAlreadyAvailable = "already_available" +) + +// watchlistRefusalReason maps a refused create onto the request-state reasons +// clients already know. An error without one (a transient failure) leaves the +// state as computed. +func watchlistRefusalReason(err error) string { + var quota QuotaError + switch { + case errors.As(err, "a), errors.Is(err, ErrQuotaExceeded): + return reasonQuotaExceeded + case errors.Is(err, ErrUserBlocked): + return reasonBlocked + case errors.Is(err, ErrRequestsDisabled), errors.Is(err, ErrForbidden): + return reasonRequestsDisabled + case errors.Is(err, ErrAlreadyAvailable): + return reasonAlreadyAvailable + default: + return "" + } +} + +// WithdrawWatchlistRequest undoes watchlist requests for a title leaving the +// viewer's watchlist: it cancels the viewer's open request for it when the +// watchlist created that request and nothing has been sent for it yet, then +// drops the viewer's follows. Any other request is left alone. +func (s *Service) WithdrawWatchlistRequest(ctx context.Context, viewer Viewer, mediaType MediaType, tmdbID int) error { + if err := validateViewer(viewer); err != nil { + return err + } + mediaType, err := normalizeMediaType(mediaType) + if err != nil { + return err + } + active, err := s.store.ListActiveByTMDB(ctx, mediaType, []int{tmdbID}) + if err != nil { + return err + } + if req := active[tmdbID]; req != nil && req.Source == SourceWatchlist && req.requestedBy(viewer) { + // The guard decides under the row lock: a request sent meanwhile + // stays in the pipeline. + if _, err := s.store.SetOutcome(ctx, req.ID, guardWithdrawable, OutcomeCancelled, viewer, withdrawnFromWatchlist); err != nil && + !errors.Is(err, ErrInvalidState) && !errors.Is(err, ErrNotFound) { + return err + } + } + return s.store.UnfollowTitle(ctx, mediaType, tmdbID, viewer) +} + +// WithdrawProfileWatchlistRequests cancels the requests a profile's watchlist +// made that nothing has been sent for yet, for a profile being deleted. It +// needs no watchlist entries, which the profile delete removes: a request +// names the profile that made it. Requests already sent stay in the pipeline, +// as they do when the profile removes a title. +func (s *Service) WithdrawProfileWatchlistRequests(ctx context.Context, userID int, profileID string) error { + reqs, err := s.store.ListProfileWatchlistRequests(ctx, userID, profileID) + if err != nil { + return err + } + actor := Viewer{UserID: userID, ProfileID: profileID} + var errs []error + for _, req := range reqs { + if _, err := s.store.SetOutcome(ctx, req.ID, guardWithdrawable, OutcomeCancelled, actor, withdrawnFromWatchlist); err != nil && + !errors.Is(err, ErrInvalidState) && !errors.Is(err, ErrNotFound) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// WatchlistRequestStates returns the request state of each watchlist title for +// the viewer, the way Discover hydrates a page, plus the download progress of +// titles being downloaded from one read of the page's targets. It uses the +// stored IDs and makes no TMDB call. +func (s *Service) WatchlistRequestStates(ctx context.Context, viewer Viewer, titles []WatchlistTitle) (map[WatchlistKey]RequestState, error) { + out := make(map[WatchlistKey]RequestState, len(titles)) + if len(titles) == 0 { + return out, nil + } + ctx = withPolicyCache(ctx) + policy, err := s.EffectivePolicy(ctx, viewer.UserID) + if err != nil { + return nil, err + } + byType := map[MediaType][]WatchlistTitle{} + for _, t := range titles { + mediaType, err := normalizeMediaType(t.MediaType) + if err != nil || t.TMDBID <= 0 { + continue + } + t.MediaType = mediaType + byType[mediaType] = append(byType[mediaType], t) + } + downloading := map[string]WatchlistKey{} + for mediaType, group := range byType { + ids := make([]int, 0, len(group)) + candidates := make([]PresenceCandidate, len(group)) + for i, t := range group { + ids = append(ids, t.tmdbIDs()...) + candidates[i] = t.presenceCandidate() + } + presence, err := s.lookupPresence(ctx, mediaType, candidates) + if err != nil { + return nil, err + } + active, err := s.store.ListActiveByTMDB(ctx, mediaType, ids) + if err != nil { + return nil, err + } + following, err := s.followedTitles(ctx, viewer, mediaType, active) + if err != nil { + return nil, err + } + for _, t := range group { + req := t.requestOf(active) + state := requestStateFor(viewer, policy, presence[t.TMDBID].Available, req) + state.Following = req != nil && following[req.TMDBID] + key := WatchlistKey{MediaType: mediaType, TMDBID: t.TMDBID} + out[key] = state + if req != nil && req.Outcome == OutcomeActive && (req.Status == StatusQueued || req.Status == StatusDownloading) { + downloading[req.ID] = key + } + } + } + if len(downloading) == 0 { + return out, nil + } + ids := make([]string, 0, len(downloading)) + for id := range downloading { + ids = append(ids, id) + } + targets, err := s.store.ListTargetsForRequests(ctx, ids) + if err != nil { + return nil, err + } + for id, key := range downloading { + state := out[key] + state.Download = (&Request{Targets: targets[id]}).Download() + out[key] = state + } + return out, nil +} + +// observeDetail reports a GetDetail TMDB read to the title observer. +func (s *Service) observeDetail(ctx context.Context, mediaType MediaType, tmdbID int, detail *tmdb.MediaDetail, err error) { + if s.titleObserver == nil { + return + } + switch { + case errors.Is(err, tmdb.ErrNotFound): + s.titleObserver.ObservedNotFound(ctx, string(mediaType), tmdbID) + case err == nil && detail != nil: + s.titleObserver.ObservedDetail(ctx, string(mediaType), tmdbID, detail) + } +} diff --git a/internal/requests/watchlist_db_test.go b/internal/requests/watchlist_db_test.go new file mode 100644 index 0000000000..4fafcf19da --- /dev/null +++ b/internal/requests/watchlist_db_test.go @@ -0,0 +1,96 @@ +package requests + +import "testing" + +// The request's source survives the store, and an unset source is direct. +func TestRequestSourceRoundTripDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + direct := insertLifecycleRequest(t, repo, "direct", 1, 101, StatusPending) + if direct.Source != SourceDirect { + t.Fatalf("unset source = %q, want direct", direct.Source) + } + created, err := repo.CreateRequest(ctx, CreateRequestRecord{ + ID: "watchlist", + Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: 102, Title: "Heat", Source: SourceWatchlist}, + Status: StatusPending, + Outcome: OutcomeActive, + Requester: Viewer{UserID: 1, ProfileID: "profile"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if created.Source != SourceWatchlist { + t.Fatalf("created source = %q, want watchlist", created.Source) + } + active, err := repo.ListActiveByTMDB(ctx, MediaTypeMovie, []int{102}) + if err != nil || active[102] == nil || active[102].Source != SourceWatchlist { + t.Fatalf("ListActiveByTMDB = %+v, %v; want the watchlist source", active[102], err) + } +} + +// The v1 settings write has no watchlist_requests field and keeps the stored +// value; the v2 conditional write sets it. +func TestWatchlistRequestsSettingDatabase(t *testing.T) { + repo := editorTestRepository(t) + ctx := t.Context() + settings, err := repo.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + if !settings.WatchlistRequests { + t.Fatal("watchlist requests default off, want on") + } + settings.WatchlistRequests = false + if settings, err = repo.UpdateSettingsConditional(ctx, settings, settings.Revision); err != nil { + t.Fatalf("conditional update: %v", err) + } + if settings.WatchlistRequests { + t.Fatal("conditional update did not store watchlist_requests=false") + } + settings.RequestsEnabled = true + settings.WatchlistRequests = true // v1 decodes no such field; the write must ignore it + if _, err = repo.UpdateSettings(ctx, settings); err != nil { + t.Fatalf("v1 update: %v", err) + } + stored, err := repo.GetSettings(ctx) + if err != nil { + t.Fatal(err) + } + if stored.WatchlistRequests || !stored.RequestsEnabled { + t.Fatalf("after the v1 write: %+v, want requests enabled and watchlist requests still off", stored) + } +} + +// A profile's watchlist requests are found by the profile that made them; +// direct requests, other profiles' requests and closed requests are not. +func TestListProfileWatchlistRequestsDatabase(t *testing.T) { + repo, _ := lifecycleTestRepository(t) + ctx := t.Context() + create := func(id string, tmdbID int, profileID string, source Source) { + t.Helper() + if _, err := repo.CreateRequest(ctx, CreateRequestRecord{ + ID: id, + Input: CreateRequestInput{MediaType: MediaTypeMovie, TMDBID: tmdbID, Title: id, Source: source}, + Status: StatusPending, + Outcome: OutcomeActive, + Requester: Viewer{UserID: 1, ProfileID: profileID}, + }); err != nil { + t.Fatalf("create %s: %v", id, err) + } + } + create("mine-watchlist", 201, "kids", SourceWatchlist) + create("mine-direct", 202, "kids", SourceDirect) + create("other-profile", 203, "parent", SourceWatchlist) + create("mine-canceled", 204, "kids", SourceWatchlist) + if _, err := repo.SetOutcome(ctx, "mine-canceled", guardWithdrawable, OutcomeCancelled, Viewer{UserID: 1, ProfileID: "kids"}, ""); err != nil { + t.Fatalf("cancel: %v", err) + } + got, err := repo.ListProfileWatchlistRequests(ctx, 1, "kids") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "mine-watchlist" { + t.Fatalf("got %d requests (%+v), want only mine-watchlist", len(got), got) + } +} diff --git a/internal/requests/watchlist_test.go b/internal/requests/watchlist_test.go new file mode 100644 index 0000000000..96b32cd4ae --- /dev/null +++ b/internal/requests/watchlist_test.go @@ -0,0 +1,484 @@ +package requests + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// watchlistPrefFunc stands in for the profile's requests.watchlist_auto_request. +type watchlistPrefFunc func(userID int, profileID string) bool + +func (f watchlistPrefFunc) WatchlistAutoRequest(_ context.Context, userID int, profileID string) bool { + return f(userID, profileID) +} + +func newWatchlistTestService(store *fakeStore) *Service { + store.settings.WatchlistRequests = true + store.trackActive = true + return newTestService(store) +} + +func heatTitle() WatchlistTitle { + return WatchlistTitle{MediaType: MediaTypeMovie, TMDBID: 949, Title: "Heat", Year: 1995} +} + +func TestWatchlistAddRequestsTitleWithWatchlistSource(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + + state, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()) + if err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.created) != 1 || store.created[0].Input.Source != SourceWatchlist { + t.Fatalf("created = %+v, want one request with source watchlist", store.created) + } + req := store.active[MediaTypeMovie][949] + if req == nil || req.Source != SourceWatchlist { + t.Fatalf("stored request = %+v, want source watchlist", req) + } + if state.RequestID != req.ID || !state.RequestedByViewer || !state.Following || state.Requestable { + t.Fatalf("state = %+v, want the viewer's own new request", state) + } +} + +func TestWatchlistAddFollowsSomeoneElsesRequest(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + svc := newWatchlistTestService(store) + + state, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()) + if err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.created) != 0 { + t.Fatalf("created = %+v, want no second request", store.created) + } + followed, _ := store.FollowedRequests(context.Background(), []string{"req-owner"}, testViewer(1)) + if !followed["req-owner"] || !state.Following || state.RequestedByViewer { + t.Fatalf("state = %+v, follows = %v; want the viewer following the other account's request", state, followed) + } +} + +func TestWatchlistAddWithOwnRequestDoesNothing(t *testing.T) { + store := newFakeStore() + req := activeRequestFor(store, 949) + req.RequestedByUserID, req.RequestedByProfileID = 1, "profile-1" + svc := newWatchlistTestService(store) + + state, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()) + if err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.created) != 0 || len(store.follows) != 0 { + t.Fatalf("created = %+v, follows = %v; want nothing", store.created, store.follows) + } + if !state.RequestedByViewer || state.RequestID != "req-owner" { + t.Fatalf("state = %+v, want the viewer's own request", state) + } +} + +func TestWatchlistAddRefusalReportsReason(t *testing.T) { + cases := []struct { + name string + setup func(*fakeStore) + reason string + }{ + {"quota", func(s *fakeStore) { s.count = s.settings.GlobalMaxRequests }, "quota_exceeded"}, + {"blocked", func(s *fakeStore) { s.limit = &UserLimit{UserID: 1, LimitMode: LimitModeBlocked} }, "blocked"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + tc.setup(store) + + state, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()) + if err != nil { + t.Fatalf("RequestFromWatchlist: %v, want the refusal in the state so the entry is kept", err) + } + if len(store.created) != 0 { + t.Fatalf("created = %+v, want none", store.created) + } + if state.Requestable || state.Reason != tc.reason { + t.Fatalf("state = %+v, want not requestable with reason %q", state, tc.reason) + } + }) + } +} + +// A refusal the policy read cannot predict (here the store's own quota check +// under the requester's lock) still reaches the state. +func TestWatchlistAddStoreRefusalReportsReason(t *testing.T) { + if got := watchlistRefusalReason(QuotaError{Used: 5, Limit: 5}); got != "quota_exceeded" { + t.Fatalf("quota reason = %q", got) + } + if got := watchlistRefusalReason(fmt.Errorf("wrapped: %w", ErrUserBlocked)); got != "blocked" { + t.Fatalf("blocked reason = %q", got) + } + if got := watchlistRefusalReason(fmt.Errorf("tmdb unreachable")); got != "" { + t.Fatalf("transient failure reason = %q, want none", got) + } +} + +func TestWatchlistAddWithSettingOffRequestsNothing(t *testing.T) { + cases := []struct { + name string + setup func(*fakeStore, *Service) + }{ + {"server", func(s *fakeStore, _ *Service) { s.settings.WatchlistRequests = false }}, + {"profile", func(_ *fakeStore, svc *Service) { + svc.SetWatchlistPreference(watchlistPrefFunc(func(int, string) bool { return false })) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 950) + svc := newWatchlistTestService(store) + tc.setup(store, svc) + + state, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()) + if err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.created) != 0 { + t.Fatalf("created = %+v, want none", store.created) + } + if !state.Requestable || state.Reason != "" { + t.Fatalf("state = %+v, want requestable so the card offers the Request button", state) + } + other := heatTitle() + other.TMDBID = 950 + if _, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), other); err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.follows) != 0 { + t.Fatalf("follows = %v, want none while the setting is off", store.follows) + } + }) + } +} + +func TestWatchlistAddRefusesWhenRequestsDisabled(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + store.settings.RequestsEnabled = false + if _, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()); !errors.Is(err, ErrRequestsDisabled) { + t.Fatalf("err = %v, want ErrRequestsDisabled", err) + } +} + +func TestWatchlistAddRepeatedCreatesOneRequest(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + for range 3 { + if _, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()); err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + } + if len(store.created) != 1 { + t.Fatalf("created %d requests, want 1", len(store.created)) + } +} + +func TestWatchlistRemoveCancelsOnlyUnsentWatchlistRequests(t *testing.T) { + viewer := testViewer(1) + seed := func(store *fakeStore, id string, tmdbID int, status Status, source Source) *Request { + req := &Request{ + ID: id, MediaType: MediaTypeMovie, TMDBID: tmdbID, Title: "Heat", + Status: status, Outcome: OutcomeActive, Source: source, + RequestedByUserID: viewer.UserID, RequestedByProfileID: viewer.ProfileID, + } + store.requests[id] = req + store.active[MediaTypeMovie][tmdbID] = req + return req + } + store := newFakeStore() + svc := newWatchlistTestService(store) + pending := seed(store, "watchlist-pending", 1, StatusPending, SourceWatchlist) + direct := seed(store, "direct-pending", 2, StatusPending, SourceDirect) + sent := seed(store, "watchlist-sent", 3, StatusApproved, SourceWatchlist) + store.targets = map[string][]Target{sent.ID: {{ID: 1, RequestID: sent.ID, Status: StatusQueued}}} + other := seed(store, "other-profile", 4, StatusPending, SourceWatchlist) + other.RequestedByProfileID = "another-profile" + + for tmdbID := 1; tmdbID <= 4; tmdbID++ { + if err := svc.WithdrawWatchlistRequest(context.Background(), viewer, MediaTypeMovie, tmdbID); err != nil { + t.Fatalf("WithdrawWatchlistRequest(%d): %v", tmdbID, err) + } + } + if pending.Outcome != OutcomeCancelled || pending.OutcomeReason != withdrawnFromWatchlist { + t.Fatalf("watchlist-created pending request = %+v, want canceled", pending) + } + for _, req := range []*Request{direct, sent, other} { + if req.Outcome != OutcomeActive { + t.Fatalf("request %s outcome = %s, want left alone", req.ID, req.Outcome) + } + } +} + +func TestWatchlistRemoveUnfollows(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + svc := newWatchlistTestService(store) + if _, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()); err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if err := svc.WithdrawWatchlistRequest(context.Background(), testViewer(1), MediaTypeMovie, 949); err != nil { + t.Fatalf("WithdrawWatchlistRequest: %v", err) + } + if len(store.follows) != 0 { + t.Fatalf("follows = %v, want none", store.follows) + } + if store.requests["req-owner"].Outcome != OutcomeActive { + t.Fatal("the other account's request was touched") + } +} + +// The watchlisting profile hears that the title arrived through the existing +// fulfilled notification: as the requester of a request the watchlist made, +// and as a follower of someone else's. The watchlist entry then promotes on +// the next read (covered in internal/watchlist). +func TestWatchlistRequestsReceiveFulfilledNotification(t *testing.T) { + store := newFakeStore() + owner := activeRequestFor(store, 42) + svc := newWatchlistTestService(store) + follower := Viewer{UserID: 3, ProfileID: "watchlisting-profile"} + title := heatTitle() + title.TMDBID = 42 + if _, err := svc.RequestFromWatchlist(context.Background(), follower, title); err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + created, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()) + if err != nil || !created.RequestedByViewer { + t.Fatalf("RequestFromWatchlist = %+v, %v", created, err) + } + + owner.Status = StatusCompleted + own := store.active[MediaTypeMovie][949] + own.Status = StatusCompleted + store.unnotified = []string{owner.ID, own.ID} + notifier := &fakeNotifier{} + svc.presence = &fakePresence{available: map[MediaType]map[int]bool{MediaTypeMovie: {42: true, 949: true}}} + svc.SetFulfillmentNotifier(notifier) + + svc.notifyFulfilledPending(context.Background()) + + if len(notifier.requestIDs) != 2 { + t.Fatalf("notified requests = %v, want both", notifier.requestIDs) + } + var heard bool + for _, followers := range notifier.followers { + for _, f := range followers { + heard = heard || f == Follower{UserID: follower.UserID, ProfileID: follower.ProfileID} + } + } + if !heard { + t.Fatalf("followers = %+v, want the watchlisting profile", notifier.followers) + } +} + +func TestWatchlistRequestStatesFillDownloadFromOneRead(t *testing.T) { + store := newFakeStore() + req := activeRequestFor(store, 949) + req.Status = StatusDownloading + store.targets = map[string][]Target{req.ID: {{ + ID: 1, RequestID: req.ID, Quality: Quality1080p, Status: StatusDownloading, + Download: &DownloadProgress{Phase: DownloadPhaseDownloading, BytesTotal: 100, BytesLeft: 40, Downloads: 1}, + }}} + svc := newWatchlistTestService(store) + + states, err := svc.WatchlistRequestStates(context.Background(), testViewer(1), []WatchlistTitle{heatTitle(), {MediaType: MediaTypeSeries, TMDBID: 1399, Title: "GoT"}}) + if err != nil { + t.Fatalf("WatchlistRequestStates: %v", err) + } + heat := states[WatchlistKey{MediaType: MediaTypeMovie, TMDBID: 949}] + if heat.Download == nil || heat.Download.BytesLeft != 40 || heat.Status != StatusDownloading { + t.Fatalf("heat = %+v, want downloading with progress", heat) + } + got := states[WatchlistKey{MediaType: MediaTypeSeries, TMDBID: 1399}] + if !got.Requestable || got.Download != nil { + t.Fatalf("series = %+v, want requestable without download", got) + } +} + +type recordingTitleObserver struct { + details []int + notFound []int +} + +func (o *recordingTitleObserver) ObservedDetail(_ context.Context, _ string, tmdbID int, _ *tmdb.MediaDetail) { + o.details = append(o.details, tmdbID) +} + +func (o *recordingTitleObserver) ObservedNotFound(_ context.Context, _ string, tmdbID int) { + o.notFound = append(o.notFound, tmdbID) +} + +func TestGetDetailReportsToTitleObserver(t *testing.T) { + client := &fakeTMDBClient{detailErr: fmt.Errorf("fetching: %w", tmdb.ErrNotFound)} + svc := newTestServiceWithTMDB(newFakeStore(), client) + observer := &recordingTitleObserver{} + svc.SetTitleObserver(observer) + + if _, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 949); err == nil { + t.Fatal("GetDetail succeeded on a TMDB 404") + } + if len(observer.notFound) != 1 || observer.notFound[0] != 949 || len(observer.details) != 0 { + t.Fatalf("observer = %+v, want one not-found for 949", observer) + } + + client.detailErr = nil + client.detail = &tmdb.MediaDetail{ID: 949, MediaType: "movie", Title: "Heat"} + if _, err := svc.GetDetail(context.Background(), testViewer(1), MediaTypeMovie, 949); err != nil { + t.Fatalf("GetDetail: %v", err) + } + if len(observer.details) != 1 || observer.details[0] != 949 { + t.Fatalf("observer = %+v, want one detail for 949", observer) + } +} + +func TestFeatureStatusReportsWatchlistRequests(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + status, err := svc.GetFeatureStatus(context.Background(), testViewer(1)) + if err != nil || !status.WatchlistRequests { + t.Fatalf("status = %+v, %v; want watchlist requests on", status, err) + } + svc.SetWatchlistPreference(watchlistPrefFunc(func(int, string) bool { return false })) + if status, _ = svc.GetFeatureStatus(context.Background(), testViewer(1)); status.WatchlistRequests { + t.Fatal("watchlist requests on after the profile opted out") + } +} + +// A request made under a TMDB ID that TMDB later replaced still belongs to the +// title: the card shows it, a repeat add neither duplicates nor refollows it +// under the new ID, and the viewer's own watchlist request is withdrawn +// through the title's former ID. +func TestWatchlistRequestsFollowTheTitleAcrossTMDBRepoints(t *testing.T) { + viewer := testViewer(1) + repointed := WatchlistTitle{MediaType: MediaTypeMovie, TMDBID: 5000, FormerTMDBIDs: []int{949}, Title: "Heat", Year: 1995} + + t.Run("someone else's request under the old ID is followed", func(t *testing.T) { + store := newFakeStore() + activeRequestFor(store, 949) + svc := newWatchlistTestService(store) + state, err := svc.RequestFromWatchlist(context.Background(), viewer, repointed) + if err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.created) != 0 { + t.Fatalf("created = %+v, want no duplicate under the new ID", store.created) + } + followed, _ := store.FollowedRequests(context.Background(), []string{"req-owner"}, viewer) + // Another account's request ID stays hidden; its status shows. + if !followed["req-owner"] || state.Status != StatusPending || !state.Following { + t.Fatalf("state = %+v, follows = %v; want the old-ID request followed and reported", state, followed) + } + }) + + t.Run("the viewer's own watchlist request under the old ID", func(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + if _, err := svc.RequestFromWatchlist(context.Background(), viewer, heatTitle()); err != nil { + t.Fatalf("first add: %v", err) + } + req := store.active[MediaTypeMovie][949] + states, err := svc.WatchlistRequestStates(context.Background(), viewer, []WatchlistTitle{repointed}) + if err != nil { + t.Fatalf("WatchlistRequestStates: %v", err) + } + state := states[WatchlistKey{MediaType: MediaTypeMovie, TMDBID: 5000}] + if state.RequestID != req.ID || !state.RequestedByViewer { + t.Fatalf("state = %+v, want the request made under the former ID", state) + } + if _, err := svc.RequestFromWatchlist(context.Background(), viewer, repointed); err != nil { + t.Fatalf("repeat add: %v", err) + } + if len(store.created) != 1 { + t.Fatalf("created %d requests, want 1", len(store.created)) + } + // The API withdraws under every ID the title has had. + for _, id := range []int{5000, 949} { + if err := svc.WithdrawWatchlistRequest(context.Background(), viewer, MediaTypeMovie, id); err != nil { + t.Fatalf("WithdrawWatchlistRequest(%d): %v", id, err) + } + } + if req.Outcome != OutcomeCancelled { + t.Fatalf("request outcome = %s, want canceled", req.Outcome) + } + }) +} + +// Deleting a profile cancels the requests its watchlist made that nothing has +// been sent for, and leaves direct, sent and other profiles' requests alone. +func TestWithdrawProfileWatchlistRequests(t *testing.T) { + store := newFakeStore() + svc := newWatchlistTestService(store) + seed := func(id, profileID string, status Status, source Source) *Request { + req := &Request{ + ID: id, MediaType: MediaTypeMovie, TMDBID: len(store.requests) + 1, Title: id, + Status: status, Outcome: OutcomeActive, Source: source, + RequestedByUserID: 1, RequestedByProfileID: profileID, + } + store.requests[id] = req + store.active[MediaTypeMovie][req.TMDBID] = req + return req + } + pending := seed("watchlist-pending", "kids", StatusPending, SourceWatchlist) + direct := seed("direct-pending", "kids", StatusPending, SourceDirect) + sent := seed("watchlist-sent", "kids", StatusApproved, SourceWatchlist) + store.targets = map[string][]Target{sent.ID: {{ID: 1, RequestID: sent.ID, Status: StatusQueued}}} + other := seed("other-profile", "parent", StatusPending, SourceWatchlist) + + if err := svc.WithdrawProfileWatchlistRequests(context.Background(), 1, "kids"); err != nil { + t.Fatal(err) + } + if pending.Outcome != OutcomeCancelled { + t.Fatalf("watchlist request of the deleted profile = %s, want canceled", pending.Outcome) + } + for _, req := range []*Request{direct, sent, other} { + if req.Outcome != OutcomeActive { + t.Fatalf("request %s = %s, want left alone", req.ID, req.Outcome) + } + } +} + +// A watchlist add keeps only its own snapshot, without an overview or +// backdrop; the request takes them from the TMDB detail it reads anyway, so +// the admin queue shows the same artwork as for a direct request. A caller +// that sent its own keeps them. +func TestWatchlistRequestTakesDisplayFieldsFromTMDB(t *testing.T) { + store := newFakeStore() + store.settings.WatchlistRequests = true + store.trackActive = true + svc := newTestServiceWithTMDB(store, &fakeTMDBClient{detail: &tmdb.MediaDetail{ + Title: "Heat", Year: 1995, Overview: "A heist.", PosterPath: "/poster.jpg", BackdropPath: "/backdrop.jpg", + }}) + if _, err := svc.RequestFromWatchlist(context.Background(), testViewer(1), heatTitle()); err != nil { + t.Fatalf("RequestFromWatchlist: %v", err) + } + if len(store.created) != 1 { + t.Fatalf("created %d requests, want 1", len(store.created)) + } + in := store.created[0].Input + if in.Overview != "A heist." || in.PosterPath != "/poster.jpg" || in.BackdropPath != "/backdrop.jpg" { + t.Fatalf("request input = %+v, want TMDB's overview, poster and backdrop", in) + } + + own := newFakeStore() + svc = newTestServiceWithTMDB(own, &fakeTMDBClient{detail: &tmdb.MediaDetail{Title: "Heat", Overview: "TMDB text", BackdropPath: "/tmdb.jpg"}}) + if _, err := svc.CreateRequest(context.Background(), testViewer(1), CreateRequestInput{ + MediaType: MediaTypeMovie, TMDBID: 949, Title: "Heat", Overview: "Client text", BackdropPath: "/client.jpg", + }); err != nil { + t.Fatalf("CreateRequest: %v", err) + } + if got := own.created[0].Input; got.Overview != "Client text" || got.BackdropPath != "/client.jpg" { + t.Fatalf("request input = %+v, want the caller's own fields kept", got) + } +} diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index fb9cc73d36..87bf8cdec8 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -91,6 +91,11 @@ type Fetcher struct { // never calls the upstream provider. TrendingSnapshots trendingSnapshotGetter + // WatchlistPromoter moves the profile's entries for titles the library + // now has onto the library watchlist before the watchlist section reads + // it. Nil skips promotion. + WatchlistPromoter catalog.WatchlistPromoter + candidateCacheMu sync.Mutex candidateCache *editorialCandidateCache candidateGroup singleflight.Group @@ -1481,6 +1486,11 @@ func (f *Fetcher) fetchPersonalListSection(ctx context.Context, s ResolvedSectio var listed []catalog.PersonalListEntry switch s.SectionType { case SectionWatchlist: + if f.WatchlistPromoter != nil { + promoteAccess := filter + promoteAccess.UserID, promoteAccess.ProfileID = userID, profileID + f.WatchlistPromoter.PromoteWatchlist(ctx, promoteAccess) + } entries, err := store.ListWatchlist(ctx, profileID, personalListFetchLimit, 0) if err != nil { return nil, 0, fmt.Errorf("listing watchlist: %w", err) diff --git a/internal/settingskeys/keys.go b/internal/settingskeys/keys.go index 85a7d90a3f..3abcb966af 100644 --- a/internal/settingskeys/keys.go +++ b/internal/settingskeys/keys.go @@ -9,7 +9,7 @@ package settingskeys // Revision is the manifest revision these bindings were generated from. -const Revision = 14 +const Revision = 15 // Setting keys, one constant per definition. const ( @@ -97,6 +97,8 @@ const ( PlayerVideoSkipBackSeconds = "player.video_skip_back_seconds" // Video fast-forward interval PlayerVideoSkipForwardSeconds = "player.video_skip_forward_seconds" + // Request titles I add to my watchlist + RequestsWatchlistAutoRequest = "requests.watchlist_auto_request" // Search scope SearchMediaScope = "search.media_scope" // Match device caption settings @@ -182,6 +184,7 @@ var Remote = []string{ PlayerVideoGravity, PlayerVideoSkipBackSeconds, PlayerVideoSkipForwardSeconds, + RequestsWatchlistAutoRequest, SearchMediaScope, UiCardOverlays, UiCardOverlaysEnabled, diff --git a/internal/settingsmigrate/plan.go b/internal/settingsmigrate/plan.go index 657ce4b511..0a6acc509a 100644 --- a/internal/settingsmigrate/plan.go +++ b/internal/settingsmigrate/plan.go @@ -1031,9 +1031,9 @@ var cardOverlayIDs = map[string]bool{ "edition": true, "multi_audio": true, "multi_sub": true, "rating_imdb": true, "rating_tmdb": true, "rating_rt": true, "rating_rt_audience": true, "content_rating": true, "advisory_age": true, - "year": true, "runtime": true, "original_language": true, - "studio": true, "network": true, "show_status": true, - "imdb_top_250": true, "rt_certified_fresh": true, + "request_status": true, "year": true, "runtime": true, + "original_language": true, "studio": true, "network": true, + "show_status": true, "imdb_top_250": true, "rt_certified_fresh": true, } // cardOverlayPositions and cardOverlayAccent mirror the per-item constraints in diff --git a/internal/userstore/pgstore/profiles.go b/internal/userstore/pgstore/profiles.go index a148d43604..17ac0383f8 100644 --- a/internal/userstore/pgstore/profiles.go +++ b/internal/userstore/pgstore/profiles.go @@ -341,6 +341,9 @@ func (s *PostgresUserStore) DeleteProfile(ctx context.Context, id string) error "user_library_playback_preferences", "user_setting_values", "user_dropped_series", + // Leaves titles no profile watchlists any more; the profile + // handler's watchlist-title purge sweeps those under the title lock. + "user_watchlist_titles", } for _, table := range cascadeTables { if _, err := tx.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE user_id = $1 AND profile_id = $2", table), s.userID, id); err != nil { diff --git a/internal/watchlist/maintainer.go b/internal/watchlist/maintainer.go index 3bbad28e7c..69b34b4939 100644 --- a/internal/watchlist/maintainer.go +++ b/internal/watchlist/maintainer.go @@ -102,7 +102,7 @@ func (m *Maintainer) process(ctx context.Context, userID int, profileID string, return err } for _, item := range items { - if item == nil || item.Type != "movie" { + if item == nil || item.Type != mediaTypeMovie { continue } if err := m.removeFromWatchlist(ctx, store, userID, profileID, item); err != nil { diff --git a/internal/watchlist/titles.go b/internal/watchlist/titles.go new file mode 100644 index 0000000000..78bb6503ef --- /dev/null +++ b/internal/watchlist/titles.go @@ -0,0 +1,592 @@ +package watchlist + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/contentid" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// Media types a watchlist title can have, as TMDB details report them. +const ( + mediaTypeMovie = "movie" + mediaTypeSeries = "series" +) + +// TitleState is a watchlist title's standing on TMDB. +type TitleState string + +const ( + // TitleActive is a title TMDB lists under its current ID. + TitleActive TitleState = "active" + // TitleNeedsReview is a title whose TMDB ID was deleted and whose other + // IDs led to several candidates. The user picks one. + TitleNeedsReview TitleState = "needs_review" + // TitleRemoved is a title whose TMDB ID was deleted and whose other IDs + // led nowhere. The entry stays until the user removes it. + TitleRemoved TitleState = "removed" +) + +// Title is a movie or series some profile watchlisted before the library had +// it, with the display snapshot from its last TMDB check. +type Title struct { + ID int64 + MediaType string // "movie" or "series" + TMDBID int + IMDbID string + TVDBID int + Title string + Year int + ReleaseDate *time.Time + PosterPath string + Certification string // US certification + VoteAverage *float64 + State TitleState + NotFoundCount int + LastNotFoundAt *time.Time + CheckedAt *time.Time + NextCheckAt time.Time + CreatedAt time.Time + UpdatedAt time.Time + // FormerTMDBIDs are the TMDB IDs the title held before TMDB repointed + // it. Requests made under one of them still belong to the title. + FormerTMDBIDs []int +} + +// Key returns the title's current TMDB identity. +func (t Title) Key() TitleKey { return TitleKey{MediaType: t.MediaType, TMDBID: t.TMDBID} } + +// Snapshot returns the title's stored IDs and display fields, as a TMDB +// detail last reported them. +func (t Title) Snapshot() Snapshot { + return Snapshot{ + MediaType: t.MediaType, + TMDBID: t.TMDBID, + IMDbID: t.IMDbID, + TVDBID: t.TVDBID, + Title: t.Title, + Year: t.Year, + ReleaseDate: t.ReleaseDate, + PosterPath: t.PosterPath, + Certification: t.Certification, + VoteAverage: t.VoteAverage, + FormerTMDBIDs: t.FormerTMDBIDs, + } +} + +// Entry is one profile's watchlist entry for a title. +type Entry struct { + Title Title + AddedAt time.Time +} + +// PageKey is the keyset position of an entry in a profile's list, which is +// ordered by added_at then title id, both descending. +func (e Entry) PageKey() PageKey { return PageKey{AddedAt: e.AddedAt, TitleID: e.Title.ID} } + +// PageKey is a keyset cursor position. TitleID is internal and must be +// wrapped in an opaque cursor before it reaches a client. +type PageKey struct { + AddedAt time.Time + TitleID int64 +} + +// TitleKey names a title by media type and a TMDB ID it has held. +type TitleKey struct { + MediaType string + TMDBID int +} + +// Snapshot is what a TMDB detail says about a title: its current IDs and the +// fields a watchlist page displays. +type Snapshot struct { + MediaType string + TMDBID int + IMDbID string + TVDBID int + Title string + Year int + ReleaseDate *time.Time + PosterPath string + Certification string + VoteAverage *float64 + // FormerTMDBIDs is filled from a stored title; a TMDB detail has none. + FormerTMDBIDs []int +} + +// SnapshotFromDetail converts a TMDB movie or series detail. A title without +// votes has no rating rather than a zero one. +func SnapshotFromDetail(d *tmdb.MediaDetail) (Snapshot, error) { + if d == nil { + return Snapshot{}, errors.New("watchlist: nil tmdb detail") + } + if d.MediaType != mediaTypeMovie && d.MediaType != mediaTypeSeries { + return Snapshot{}, fmt.Errorf("watchlist: unsupported media type %q", d.MediaType) + } + if d.ID <= 0 { + return Snapshot{}, fmt.Errorf("watchlist: invalid tmdb id %d", d.ID) + } + snap := Snapshot{ + MediaType: d.MediaType, + TMDBID: d.ID, + IMDbID: strings.TrimSpace(d.IMDbID), + TVDBID: max(d.TVDBID, 0), + Title: d.Title, + Year: d.Year, + PosterPath: d.PosterPath, + Certification: d.USCertification, + } + date := d.ReleaseDate + if date == "" { + date = d.FirstAirDate + } + if parsed, err := time.Parse(time.DateOnly, date); err == nil { + snap.ReleaseDate = &parsed + } + if d.VoteCount > 0 { + vote := d.VoteAverage + snap.VoteAverage = &vote + } + return snap, nil +} + +// Viewer is the profile a watchlist call acts for, with the access filter +// that decides which library items it can see. +type Viewer struct { + UserID int + ProfileID string + Access catalog.AccessFilter +} + +// Effects runs the side effects of a manual watchlist add when promotion moves +// an entry onto the library watchlist: the provider export event, the +// recommendations refresh and the realtime user_state.changed event. +// handlers.PersonalDataHandler implements it. +type Effects interface { + WatchlistPromoted(ctx context.Context, userID int, profileID, contentID string) +} + +// TitleCatalog is the slice of the catalog titles need. +type TitleCatalog interface { + ResolveProviderAliases(ctx context.Context, aliases []catalog.ProviderAlias) (map[int64][]string, error) + ItemProviderAliases(ctx context.Context, contentID string) (string, []catalog.ProviderAlias, error) + EnsureAccessibleIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) (map[string]bool, error) +} + +// TitleTMDB is the slice of the TMDB client ID repair needs. +type TitleTMDB interface { + GetMediaDetail(ctx context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) + FindByExternalID(ctx context.Context, source, externalID string) ([]tmdb.MediaResult, error) +} + +// promotionStore is the slice of the user store promotion writes through. +// Pass the notification-wrapped provider so a promoted series queues an +// interest recompute. +type promotionStore interface { + AddToWatchlistAt(ctx context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error) + GetWatchlistEntry(ctx context.Context, profileID, mediaItemID string) (*userstore.WatchlistEntry, error) +} + +const ( + // maxChecksPerRead bounds the TMDB checks one list read starts. + maxChecksPerRead = 3 + // maxConcurrentChecks bounds the checks running on this node. + maxConcurrentChecks = 4 + // maxConcurrentPromotions bounds the promotions on this node that hold a + // title lock while the library add takes a second pool connection. + maxConcurrentPromotions = 4 + checkTimeout = 30 * time.Second + // claimLease is how long a claimed check is reserved for its node; a node + // that dies mid-check releases it by letting it expire. + claimLease = time.Hour + // notFoundConfirmGap separates the two 404s that confirm a deletion. + notFoundConfirmGap = 24 * time.Hour + errorBackoff = 6 * time.Hour + recentRelease = 180 * 24 * time.Hour + checkRecent = 24 * time.Hour + checkSettled = 30 * 24 * time.Hour +) + +// nextCheckAfterSuccess is the cadence for a title TMDB answered for: daily +// while its release is unknown, upcoming or recent (IDs still settle), then +// monthly. +func nextCheckAfterSuccess(now time.Time, releaseDate *time.Time) time.Time { + if releaseDate == nil || releaseDate.After(now) || now.Sub(*releaseDate) <= recentRelease { + return now.Add(checkRecent) + } + return now.Add(checkSettled) +} + +// Titles keeps profiles' watchlist entries for titles the library doesn't +// have: adding and removing them, moving them onto the library watchlist once +// the library has the title (promotion), and keeping their TMDB IDs current +// (repair). Repair has no scheduled task: detail views and list reads drive +// it. +type Titles struct { + repo *titlesRepo + catalog TitleCatalog + storeFor func(ctx context.Context, userID int) (promotionStore, error) + tmdb TitleTMDB + effects Effects + now func() time.Time + + checkSlots chan struct{} + checks sync.WaitGroup + // addUnderLock runs the library watchlist write while the title lock is + // held. A one-connection pool can't, so it writes first and then locks; + // a remove racing that promotion can leave the item on the library + // watchlist. + addUnderLock bool + // promoteSlots bounds the promotions holding a transaction while they + // wait for a second connection, so they can never take the whole pool. + promoteSlots chan struct{} +} + +// NewTitles builds the service. stores should be the notification-wrapped +// provider. tmdbClient may be nil, which disables repair; effects may be nil. +func NewTitles(pool *pgxpool.Pool, items TitleCatalog, stores userstore.UserStoreProvider, tmdbClient TitleTMDB, effects Effects) *Titles { + t := &Titles{ + repo: &titlesRepo{pool: pool}, + catalog: items, + tmdb: tmdbClient, + effects: effects, + now: func() time.Time { return time.Now().UTC() }, + checkSlots: make(chan struct{}, maxConcurrentChecks), + } + promotions := maxConcurrentPromotions + t.addUnderLock = true + if pool != nil { + // Each promotion holds up to two connections; half the pool leaves the + // rest for everything else. + maxConns := int(pool.Config().MaxConns) + promotions = min(maxConcurrentPromotions, max(1, maxConns/2)) + // With one connection the library write could never get the second + // connection it needs while the lock's transaction holds the first. + t.addUnderLock = maxConns >= 2 + } + t.promoteSlots = make(chan struct{}, promotions) + if stores != nil { + t.storeFor = func(ctx context.Context, userID int) (promotionStore, error) { + return stores.ForUser(ctx, userID) + } + } + return t +} + +// SetEffects wires the promotion side effects after construction, for callers +// whose Effects implementation is built later. +func (s *Titles) SetEffects(effects Effects) { + if s != nil { + s.effects = effects + } +} + +// Add puts the title from a TMDB detail on the viewer's watchlist. Adding a +// title already there keeps its original added_at; inserted reports whether +// this call created the entry. +func (s *Titles) Add(ctx context.Context, v Viewer, detail *tmdb.MediaDetail) (Entry, bool, error) { + snap, err := SnapshotFromDetail(detail) + if err != nil { + return Entry{}, false, err + } + now := s.now() + return s.repo.add(ctx, v.UserID, v.ProfileID, snap, now, now) +} + +// AddSnapshot is Add for callers that already hold a snapshot, such as an +// import that keeps the source's added_at. +func (s *Titles) AddSnapshot(ctx context.Context, v Viewer, snap Snapshot, addedAt time.Time) (Entry, bool, error) { + if snap.MediaType != mediaTypeMovie && snap.MediaType != mediaTypeSeries || snap.TMDBID <= 0 { + return Entry{}, false, fmt.Errorf("watchlist: invalid title %s/%d", snap.MediaType, snap.TMDBID) + } + return s.repo.add(ctx, v.UserID, v.ProfileID, snap, addedAt, s.now()) +} + +// Remove takes the title holding the TMDB ID, current or former, off the +// viewer's watchlist. It returns the title (nil when no title holds the ID) +// and whether an entry was removed. +func (s *Titles) Remove(ctx context.Context, v Viewer, mediaType string, tmdbID int) (*Title, bool, error) { + return s.repo.removeByTMDB(ctx, v.UserID, v.ProfileID, mediaType, tmdbID) +} + +// Find returns the title holding the TMDB ID, current or former, or nil. +func (s *Titles) Find(ctx context.Context, mediaType string, tmdbID int) (*Title, error) { + return s.repo.titleByTMDB(ctx, mediaType, tmdbID) +} + +// ListPage returns up to limit of the viewer's entries after the cursor, +// newest first, from stored snapshots. It starts background checks for a few +// overdue titles on the page and never waits for them. Call PromoteProfile +// first so titles the library now has leave the list. +func (s *Titles) ListPage(ctx context.Context, v Viewer, after *PageKey, limit int) ([]Entry, error) { + if limit <= 0 { + return nil, nil + } + entries, err := s.repo.listPage(ctx, v.UserID, v.ProfileID, after, limit) + if err != nil { + return nil, err + } + titles := make([]Title, len(entries)) + for i, e := range entries { + titles[i] = e.Title + } + s.ScheduleChecks(ctx, titles) + return entries, nil +} + +// OnWatchlist reports which keys the viewer has an entry for, matching any +// TMDB ID the entry's title has held. Discover uses it to mark results. +func (s *Titles) OnWatchlist(ctx context.Context, v Viewer, keys []TitleKey) (map[TitleKey]bool, error) { + return s.repo.onWatchlist(ctx, v.UserID, v.ProfileID, keys) +} + +// PurgeProfile removes a deleted profile's entries and the titles left with +// none. It implements the profile handler's purge hook. +func (s *Titles) PurgeProfile(ctx context.Context, userID int, profileID string) error { + return s.repo.purgeProfile(ctx, userID, profileID) +} + +// LibraryMatches returns every library item (enabled folders, same media +// type) that carries or once carried any of the IDs. The add path uses it to +// send a title the library already has to the library watchlist instead. +func (s *Titles) LibraryMatches(ctx context.Context, mediaType string, tmdbIDs []int, imdbID string, tvdbID int) ([]string, error) { + aliases := make([]catalog.ProviderAlias, 0, len(tmdbIDs)+2) + for _, id := range tmdbIDs { + aliases = append(aliases, catalog.ProviderAlias{MediaType: mediaType, Provider: contentid.ProviderTMDB, ProviderID: strconv.Itoa(id)}) + } + if imdbID != "" { + aliases = append(aliases, catalog.ProviderAlias{MediaType: mediaType, Provider: contentid.ProviderIMDB, ProviderID: imdbID}) + } + if tvdbID > 0 { + aliases = append(aliases, catalog.ProviderAlias{MediaType: mediaType, Provider: contentid.ProviderTVDB, ProviderID: strconv.Itoa(tvdbID)}) + } + matches, err := s.catalog.ResolveProviderAliases(ctx, aliases) + if err != nil { + return nil, err + } + return matches[0], nil +} + +// PromoteProfile moves every entry of the viewer whose title the library now +// has onto the library watchlist, keeping its added_at. It returns the +// content IDs this call put on the library watchlist, the ones it ran the +// side effects for. A profile with no entries costs one index +// probe. +func (s *Titles) PromoteProfile(ctx context.Context, v Viewer) ([]string, error) { + addedAt, aliases, err := s.repo.profileEntries(ctx, v.UserID, v.ProfileID) + if err != nil || len(addedAt) == 0 { + return nil, err + } + return s.promote(ctx, v, addedAt, aliases) +} + +// PromoteItem promotes the viewer's entries whose title carries any of the +// catalog item's current or former provider IDs. Item detail calls it before +// reporting in_watchlist. +func (s *Titles) PromoteItem(ctx context.Context, v Viewer, contentID string) ([]string, error) { + mediaType, itemAliases, err := s.catalog.ItemProviderAliases(ctx, contentID) + if err != nil || len(itemAliases) == 0 { + return nil, err + } + providers := make([]string, len(itemAliases)) + providerIDs := make([]string, len(itemAliases)) + for i, a := range itemAliases { + providers[i], providerIDs[i] = a.Provider, a.ProviderID + } + addedAt, aliases, err := s.repo.profileEntriesMatching(ctx, v.UserID, v.ProfileID, mediaType, providers, providerIDs) + if err != nil || len(addedAt) == 0 { + return nil, err + } + return s.promote(ctx, v, addedAt, aliases) +} + +// promote moves each resolved entry onto the library watchlist. Under the +// title lock, and only while the entry still exists, it adds the item to the +// library watchlist and then deletes the entry, so a concurrent remove either +// runs first (and nothing is added) or finds the library entry to remove. Only +// the caller whose delete removed the entry runs the side effects, and only +// when the library entry is new to the user: its add inserted it, or it +// carries the entry's added_at because a node died between the add and the +// delete. An item that was already on the library watchlist fires nothing. +func (s *Titles) promote(ctx context.Context, v Viewer, addedAt map[int64]time.Time, aliases []titleAlias) ([]string, error) { + if s.catalog == nil || s.storeFor == nil { + return nil, nil + } + requested := make([]catalog.ProviderAlias, len(aliases)) + for i, a := range aliases { + requested[i] = catalog.ProviderAlias{Key: a.TitleID, MediaType: a.MediaType, Provider: a.Provider, ProviderID: a.ProviderID} + } + matches, err := s.catalog.ResolveProviderAliases(ctx, requested) + if err != nil || len(matches) == 0 { + return nil, err + } + titleIDs := make([]int64, 0, len(matches)) + for id := range matches { + titleIDs = append(titleIDs, id) + } + slices.Sort(titleIDs) + + // One access check for every matched copy, rather than one per copy. + var candidates []string + for _, ids := range matches { + for _, id := range ids { + if !slices.Contains(candidates, id) { + candidates = append(candidates, id) + } + } + } + accessible, err := s.catalog.EnsureAccessibleIDs(ctx, candidates, v.Access) + if err != nil { + return nil, fmt.Errorf("checking access to watchlist titles' library items: %w", err) + } + + store, err := s.storeFor(ctx, v.UserID) + if err != nil { + return nil, fmt.Errorf("opening user store for watchlist promotion: %w", err) + } + var promoted []string + var errs []error + for _, titleID := range titleIDs { + // Only the copies this viewer may see count: duplicates in libraries + // the viewer can't open don't make the title ambiguous for them. + var contentIDs []string + for _, id := range matches[titleID] { + if accessible[id] { + contentIDs = append(contentIDs, id) + } + } + if len(contentIDs) == 0 { + continue + } + if len(contentIDs) > 1 { + slog.WarnContext(ctx, "watchlist title matches several library items; not promoting until they are merged", + "component", "watchlist", "title_id", titleID, "content_ids", contentIDs) + continue + } + contentID := contentIDs[0] + moved, newToUser, err := s.promoteOne(ctx, store, v, titleID, contentID, addedAt[titleID]) + if err != nil { + errs = append(errs, err) + continue + } + if !moved || !newToUser || slices.Contains(promoted, contentID) { + continue + } + promoted = append(promoted, contentID) + if s.effects != nil { + s.effects.WatchlistPromoted(ctx, v.UserID, v.ProfileID, contentID) + } + } + return promoted, errors.Join(errs...) +} + +// promoteOne moves one entry under its title lock. moved reports that this +// call deleted the entry; newToUser that the library entry is the promotion's +// own rather than one the profile already had. +func (s *Titles) promoteOne(ctx context.Context, store promotionStore, v Viewer, titleID int64, contentID string, entryAddedAt time.Time) (moved, newToUser bool, err error) { + select { + case s.promoteSlots <- struct{}{}: + case <-ctx.Done(): + return false, false, ctx.Err() + } + defer func() { <-s.promoteSlots }() + addToLibrary := func(ctx context.Context) error { + inserted, err := store.AddToWatchlistAt(ctx, v.ProfileID, contentID, entryAddedAt) + if err != nil { + return fmt.Errorf("adding promoted watchlist item: %w", err) + } + if inserted { + newToUser = true + return nil + } + existing, err := store.GetWatchlistEntry(ctx, v.ProfileID, contentID) + if err != nil { + return fmt.Errorf("reading promoted watchlist item: %w", err) + } + newToUser = existing != nil && sameStoredTime(existing.AddedAt, entryAddedAt) + return nil + } + if !s.addUnderLock { + // Skip an entry a remove already took, so the window in which a + // remove can race the write shrinks to the moments before the lock. + if present, err := s.repo.entryExists(ctx, v.UserID, v.ProfileID, titleID); err != nil || !present { + return false, false, err + } + if err := addToLibrary(ctx); err != nil { + return false, false, err + } + moved, err = s.repo.promoteEntry(ctx, v.UserID, v.ProfileID, titleID, func(context.Context) error { return nil }) + return moved, newToUser, err + } + moved, err = s.repo.promoteEntry(ctx, v.UserID, v.ProfileID, titleID, addToLibrary) + return moved, newToUser, err +} + +// sameStoredTime reports whether a user store timestamp is t. Stores keep +// whole seconds, so both sides are compared at that precision. +func sameStoredTime(stored string, t time.Time) bool { + parsed, err := time.Parse(time.RFC3339Nano, stored) + if err != nil { + return false + } + return parsed.Truncate(time.Second).Equal(t.Truncate(time.Second)) +} + +// PromoteWatchlist runs PromoteProfile ahead of a library watchlist read for +// the profile the access filter names. A failure is logged, not returned: the +// read is still right about the entries it has, and the next read retries. +// It implements catalog.WatchlistPromoter. +func (s *Titles) PromoteWatchlist(ctx context.Context, access catalog.AccessFilter) { + v, ok := viewerFromAccess(access) + if s == nil || !ok { + return + } + if _, err := s.PromoteProfile(ctx, v); err != nil && ctx.Err() == nil { + slog.WarnContext(ctx, "watchlist promotion failed; the read continues without it", + "component", "watchlist", "user_id", v.UserID, "profile_id", v.ProfileID, "error", err) + } +} + +// PromoteWatchlistItem runs PromoteItem ahead of a read that reports whether +// the item is on the profile's watchlist, logging a failure like +// PromoteWatchlist. +func (s *Titles) PromoteWatchlistItem(ctx context.Context, access catalog.AccessFilter, contentID string) { + v, ok := viewerFromAccess(access) + if s == nil || !ok || contentID == "" { + return + } + if _, err := s.PromoteItem(ctx, v, contentID); err != nil && ctx.Err() == nil { + slog.WarnContext(ctx, "watchlist promotion failed; the read continues without it", + "component", "watchlist", "user_id", v.UserID, "profile_id", v.ProfileID, "content_id", contentID, "error", err) + } +} + +func viewerFromAccess(access catalog.AccessFilter) (Viewer, bool) { + profileID := strings.TrimSpace(access.ProfileID) + if access.UserID <= 0 || profileID == "" { + return Viewer{}, false + } + return Viewer{UserID: access.UserID, ProfileID: profileID, Access: access}, true +} + +// SweepOrphanTitles deletes up to a batch of titles no entry references. User +// deletion removes entries through the users foreign key without the title +// lock; the admin delete paths call this afterwards. +func (s *Titles) SweepOrphanTitles(ctx context.Context) error { + if s == nil { + return nil + } + return s.repo.sweepOrphans(ctx) +} diff --git a/internal/watchlist/titles_db_test.go b/internal/watchlist/titles_db_test.go new file mode 100644 index 0000000000..c1e89b55a6 --- /dev/null +++ b/internal/watchlist/titles_db_test.go @@ -0,0 +1,872 @@ +package watchlist + +import ( + "context" + "fmt" + "os" + "slices" + "strconv" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// titleTMDBStub answers detail and find calls from maps and counts them. +type titleTMDBStub struct { + mu sync.Mutex + details map[string]*tmdb.MediaDetail + detailErrs map[string]error + finds map[string][]tmdb.MediaResult + findErr error + detailCalls int +} + +func newTitleTMDBStub() *titleTMDBStub { + return &titleTMDBStub{ + details: map[string]*tmdb.MediaDetail{}, + detailErrs: map[string]error{}, + finds: map[string][]tmdb.MediaResult{}, + } +} + +func (s *titleTMDBStub) GetMediaDetail(_ context.Context, mediaType string, id int) (*tmdb.MediaDetail, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.detailCalls++ + key := mediaType + ":" + strconv.Itoa(id) + if err := s.detailErrs[key]; err != nil { + return nil, err + } + if d := s.details[key]; d != nil { + return d, nil + } + return nil, fmt.Errorf("%w: %s", tmdb.ErrNotFound, key) +} + +func (s *titleTMDBStub) FindByExternalID(_ context.Context, source, externalID string) ([]tmdb.MediaResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.findErr != nil { + return nil, s.findErr + } + return s.finds[source+":"+externalID], nil +} + +func (s *titleTMDBStub) calls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.detailCalls +} + +// promotionStoreStub is an in-memory library watchlist. Like the Postgres +// user store it reports added_at in whole seconds. +type promotionStoreStub struct { + mu sync.Mutex + entries map[string]time.Time + // beforeAdd, when set, runs at the start of every add. + beforeAdd func() +} + +func (s *promotionStoreStub) AddToWatchlistAt(_ context.Context, profileID, mediaItemID string, addedAt time.Time) (bool, error) { + if s.beforeAdd != nil { + s.beforeAdd() + } + s.mu.Lock() + defer s.mu.Unlock() + key := profileID + "/" + mediaItemID + if _, ok := s.entries[key]; ok { + return false, nil + } + s.entries[key] = addedAt + return true, nil +} + +func (s *promotionStoreStub) GetWatchlistEntry(_ context.Context, profileID, mediaItemID string) (*userstore.WatchlistEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + at, ok := s.entries[profileID+"/"+mediaItemID] + if !ok { + return nil, nil + } + return &userstore.WatchlistEntry{ProfileID: profileID, MediaItemID: mediaItemID, AddedAt: at.UTC().Format(time.RFC3339)}, nil +} + +func (s *promotionStoreStub) set(profileID, mediaItemID string, addedAt time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.entries[profileID+"/"+mediaItemID] = addedAt +} + +func (s *promotionStoreStub) get(profileID, mediaItemID string) (time.Time, bool) { + s.mu.Lock() + defer s.mu.Unlock() + at, ok := s.entries[profileID+"/"+mediaItemID] + return at, ok +} + +type effectsRecorder struct { + mu sync.Mutex + calls []string +} + +func (e *effectsRecorder) WatchlistPromoted(_ context.Context, userID int, profileID, contentID string) { + e.mu.Lock() + defer e.mu.Unlock() + e.calls = append(e.calls, fmt.Sprintf("%d/%s/%s", userID, profileID, contentID)) +} + +func (e *effectsRecorder) snapshot() []string { + e.mu.Lock() + defer e.mu.Unlock() + return slices.Clone(e.calls) +} + +type titlesFixture struct { + pool *pgxpool.Pool + svc *Titles + tmdb *titleTMDBStub + store *promotionStoreStub + effects *effectsRecorder + prefix string + userID int + folder int + base int + now time.Time +} + +func newTitlesFixture(t *testing.T) *titlesFixture { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + stamp := time.Now().UnixNano() + f := &titlesFixture{ + pool: pool, + tmdb: newTitleTMDBStub(), + store: &promotionStoreStub{entries: map[string]time.Time{}}, + effects: &effectsRecorder{}, + prefix: fmt.Sprintf("wlt-%d", stamp), + // TMDB IDs unique to this run, below the integer column's limit. + base: 1_000_000_000 + int(stamp/1000%100_000)*10_000, + now: time.Now().UTC().Truncate(time.Second), + } + if err := pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, f.prefix).Scan(&f.userID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('mixed', $1, TRUE) RETURNING id`, f.prefix).Scan(&f.folder); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx := context.Background() + f.svc.waitChecks() + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, f.userID) + _, _ = pool.Exec(ctx, `DELETE FROM watchlist_titles WHERE tmdb_id BETWEEN $1 AND $2`, f.base, f.base+9_999) + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id LIKE $1`, f.prefix+"%") + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, f.folder) + }) + f.svc = NewTitles(pool, catalog.NewItemRepository(pool), nil, f.tmdb, f.effects) + f.svc.storeFor = func(context.Context, int) (promotionStore, error) { return f.store, nil } + f.svc.now = func() time.Time { return f.now } + return f +} + +func (f *titlesFixture) id(n int) int { return f.base + n } + +func (f *titlesFixture) viewer(profileID string) Viewer { + return Viewer{UserID: f.userID, ProfileID: profileID} +} + +func (f *titlesFixture) snap(mediaType string, n int, imdbID string, tvdbID int) Snapshot { + release := time.Date(2001, 5, 4, 0, 0, 0, 0, time.UTC) + vote := 7.25 + return Snapshot{ + MediaType: mediaType, TMDBID: f.id(n), IMDbID: imdbID, TVDBID: tvdbID, + Title: fmt.Sprintf("Title %d", n), Year: 2001, ReleaseDate: &release, + PosterPath: "/p.jpg", Certification: "PG", VoteAverage: &vote, + } +} + +func (f *titlesFixture) detail(snap Snapshot) *tmdb.MediaDetail { + d := &tmdb.MediaDetail{ + MediaType: snap.MediaType, ID: snap.TMDBID, IMDbID: snap.IMDbID, TVDBID: snap.TVDBID, + Title: snap.Title, Year: snap.Year, PosterPath: snap.PosterPath, USCertification: snap.Certification, + } + if snap.ReleaseDate != nil { + d.ReleaseDate = snap.ReleaseDate.Format(time.DateOnly) + } + if snap.VoteAverage != nil { + d.VoteAverage, d.VoteCount = *snap.VoteAverage, 10 + } + return d +} + +func (f *titlesFixture) add(t *testing.T, profileID string, snap Snapshot, addedAt time.Time) Entry { + t.Helper() + entry, _, err := f.svc.AddSnapshot(t.Context(), f.viewer(profileID), snap, addedAt) + if err != nil { + t.Fatal(err) + } + return entry +} + +func (f *titlesFixture) title(t *testing.T, id int64) *Title { + t.Helper() + title, err := f.svc.repo.titleByID(t.Context(), id) + if err != nil { + t.Fatal(err) + } + return title +} + +func (f *titlesFixture) aliases(t *testing.T, id int64) []string { + t.Helper() + aliases, err := f.svc.repo.aliasesOf(t.Context(), id) + if err != nil { + t.Fatal(err) + } + out := make([]string, len(aliases)) + for i, a := range aliases { + out[i] = a.Provider + ":" + a.ProviderID + } + return out +} + +func (f *titlesFixture) entryAddedAt(t *testing.T, profileID string, titleID int64) (time.Time, bool) { + t.Helper() + var at time.Time + err := f.pool.QueryRow(t.Context(), ` + SELECT added_at FROM user_watchlist_titles WHERE user_id = $1 AND profile_id = $2 AND title_id = $3`, + f.userID, profileID, titleID).Scan(&at) + if err != nil { + return time.Time{}, false + } + return at.UTC(), true +} + +// item creates a library item in the fixture's enabled folder. +func (f *titlesFixture) item(t *testing.T, suffix, mediaType, tmdbID, imdbID string) string { + t.Helper() + contentID := f.prefix + "-" + suffix + if _, err := f.pool.Exec(t.Context(), ` + INSERT INTO media_items (content_id, type, title, genres, tmdb_id, imdb_id) + VALUES ($1, $2, $1, '{}'::text[], $3, $4)`, contentID, mediaType, tmdbID, imdbID); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(t.Context(), `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, contentID, f.folder); err != nil { + t.Fatal(err) + } + return contentID +} + +// confirmable marks a title as having had one 404 more than a day ago, so +// the next 404 confirms the deletion. +func (f *titlesFixture) confirmable(t *testing.T, id int64) { + t.Helper() + if _, err := f.pool.Exec(t.Context(), ` + UPDATE watchlist_titles SET not_found_count = 1, last_not_found_at = $2, next_check_at = $3 WHERE id = $1`, + id, f.now.Add(-25*time.Hour), f.now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } +} + +func TestTitlesAddListRemove(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + t0 := f.now.Add(-3 * time.Hour) + a := f.add(t, "p1", f.snap("movie", 1, "tt-a", 0), t0) + b := f.add(t, "p1", f.snap("movie", 2, "", 0), t0.Add(time.Hour)) + c := f.add(t, "p1", f.snap("series", 3, "", 77), t0.Add(2*time.Hour)) + if got := f.aliases(t, c.Title.ID); !slices.Equal(got, []string{"tmdb:" + strconv.Itoa(f.id(3)), "tvdb:77"}) { + t.Fatalf("series aliases = %v", got) + } + if a.Title.VoteAverage == nil || *a.Title.VoteAverage != 7.3 { + t.Fatalf("vote average = %v, want 7.3", a.Title.VoteAverage) + } + + // A repeat add keeps the original added_at. + again, inserted, err := f.svc.AddSnapshot(ctx, f.viewer("p1"), f.snap("movie", 1, "tt-a", 0), f.now) + if err != nil || inserted || !again.AddedAt.Equal(t0) || again.Title.ID != a.Title.ID { + t.Fatalf("repeat add = %+v inserted=%v err=%v", again, inserted, err) + } + + page, err := f.svc.ListPage(ctx, f.viewer("p1"), nil, 2) + if err != nil || len(page) != 2 || page[0].Title.ID != c.Title.ID || page[1].Title.ID != b.Title.ID { + t.Fatalf("first page = %+v err=%v", page, err) + } + after := page[1].PageKey() + page, err = f.svc.ListPage(ctx, f.viewer("p1"), &after, 2) + if err != nil || len(page) != 1 || page[0].Title.ID != a.Title.ID { + t.Fatalf("second page = %+v err=%v", page, err) + } + + on, err := f.svc.OnWatchlist(ctx, f.viewer("p1"), []TitleKey{a.Title.Key(), {MediaType: "series", TMDBID: f.id(1)}, c.Title.Key()}) + if err != nil || !on[a.Title.Key()] || !on[c.Title.Key()] || len(on) != 2 { + t.Fatalf("OnWatchlist = %v err=%v", on, err) + } + + // A title two profiles keep survives one removal and goes with the last. + f.add(t, "p2", f.snap("movie", 1, "tt-a", 0), f.now) + title, removed, err := f.svc.Remove(ctx, f.viewer("p1"), "movie", f.id(1)) + if err != nil || !removed || title == nil || title.ID != a.Title.ID { + t.Fatalf("remove = %+v %v %v", title, removed, err) + } + if f.title(t, a.Title.ID) == nil { + t.Fatal("title removed while another profile keeps it") + } + if _, removed, err := f.svc.Remove(ctx, f.viewer("p1"), "movie", f.id(1)); err != nil || removed { + t.Fatalf("second remove = %v %v", removed, err) + } + if _, removed, err := f.svc.Remove(ctx, f.viewer("p2"), "movie", f.id(1)); err != nil || !removed { + t.Fatalf("last remove = %v %v", removed, err) + } + if f.title(t, a.Title.ID) != nil { + t.Fatal("orphaned title kept") + } + if title, removed, err := f.svc.Remove(ctx, f.viewer("p1"), "movie", f.id(99)); err != nil || removed || title != nil { + t.Fatalf("unknown remove = %v %v %v", title, removed, err) + } +} + +func TestTitlesAddRacesRemovalOfLastEntry(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + for i := range 40 { + snap := f.snap("movie", 100+i, "", 0) + f.add(t, "leaver", snap, f.now) + var wg sync.WaitGroup + var addErr, removeErr error + wg.Add(2) + go func() { + defer wg.Done() + _, _, removeErr = f.svc.Remove(ctx, f.viewer("leaver"), "movie", snap.TMDBID) + }() + go func() { + defer wg.Done() + _, _, addErr = f.svc.AddSnapshot(ctx, f.viewer("joiner"), snap, f.now) + }() + wg.Wait() + if addErr != nil || removeErr != nil { + t.Fatalf("round %d: add err %v, remove err %v", i, addErr, removeErr) + } + title, err := f.svc.Find(ctx, "movie", snap.TMDBID) + if err != nil || title == nil { + t.Fatalf("round %d: title gone after concurrent add: %v", i, err) + } + if _, ok := f.entryAddedAt(t, "joiner", title.ID); !ok { + t.Fatalf("round %d: joiner's entry missing", i) + } + if _, ok := f.entryAddedAt(t, "leaver", title.ID); ok { + t.Fatalf("round %d: leaver's entry kept", i) + } + } +} + +func TestTitlesConcurrentAddsCreateOneTitle(t *testing.T) { + f := newTitlesFixture(t) + snap := f.snap("movie", 200, "tt-200", 0) + var wg sync.WaitGroup + errs := make([]error, 8) + for i := range errs { + wg.Add(1) + go func() { + defer wg.Done() + _, _, errs[i] = f.svc.AddSnapshot(t.Context(), f.viewer("p"+strconv.Itoa(i)), snap, f.now) + }() + } + wg.Wait() + for _, err := range errs { + if err != nil { + t.Fatal(err) + } + } + var titles, entries int + if err := f.pool.QueryRow(t.Context(), ` + SELECT count(DISTINCT t.id), count(e.*) FROM watchlist_titles t + LEFT JOIN user_watchlist_titles e ON e.title_id = t.id WHERE t.tmdb_id = $1`, snap.TMDBID).Scan(&titles, &entries); err != nil { + t.Fatal(err) + } + if titles != 1 || entries != 8 { + t.Fatalf("titles=%d entries=%d, want 1 and 8", titles, entries) + } +} + +func TestTitlesPurgeProfileAndUserDelete(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + shared := f.add(t, "gone", f.snap("movie", 300, "", 0), f.now) + f.add(t, "stays", f.snap("movie", 300, "", 0), f.now) + own := f.add(t, "gone", f.snap("movie", 301, "", 0), f.now) + if err := f.svc.PurgeProfile(ctx, f.userID, "gone"); err != nil { + t.Fatal(err) + } + if f.title(t, own.Title.ID) != nil || f.title(t, shared.Title.ID) == nil { + t.Fatal("purge removed the wrong titles") + } + if _, ok := f.entryAddedAt(t, "gone", shared.Title.ID); ok { + t.Fatal("purged profile's entry kept") + } + + // The Postgres user store deletes entries without the title lock; the + // sweep that follows removes the orphan. + cascaded := f.add(t, "cascaded", f.snap("movie", 302, "", 0), f.now) + if _, err := f.pool.Exec(ctx, `DELETE FROM user_watchlist_titles WHERE user_id = $1 AND profile_id = 'cascaded'`, f.userID); err != nil { + t.Fatal(err) + } + if err := f.svc.PurgeProfile(ctx, f.userID, "cascaded"); err != nil { + t.Fatal(err) + } + if f.title(t, cascaded.Title.ID) != nil { + t.Fatal("sweep kept an orphaned title") + } + + if _, err := f.pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, f.userID); err != nil { + t.Fatal(err) + } + if _, ok := f.entryAddedAt(t, "stays", shared.Title.ID); ok { + t.Fatal("user delete kept an entry") + } +} + +func TestTitlesPromoteProfile(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + addedAt := f.now.Add(-48 * time.Hour) + byTMDB := f.add(t, "p1", f.snap("movie", 400, "", 0), addedAt) + byIMDb := f.add(t, "p1", f.snap("movie", 401, "tt-401", 0), addedAt.Add(time.Minute)) + ambiguous := f.add(t, "p1", f.snap("movie", 402, "tt-402", 0), f.now) + missing := f.add(t, "p1", f.snap("movie", 403, "", 0), f.now) + movieA := f.item(t, "a", "movie", strconv.Itoa(f.id(400)), "") + movieB := f.item(t, "b", "movie", "", "tt-401") + f.item(t, "dup-1", "movie", strconv.Itoa(f.id(402)), "") + f.item(t, "dup-2", "movie", "", "tt-402") + + // A viewer who can see no library keeps every entry external. + hidden := f.viewer("p1") + hidden.Access = catalog.AccessFilter{AllowedLibraryIDs: []int{}} + if promoted, err := f.svc.PromoteProfile(ctx, hidden); err != nil || len(promoted) != 0 { + t.Fatalf("inaccessible promotion = %v %v", promoted, err) + } + + promoted, err := f.svc.PromoteProfile(ctx, f.viewer("p1")) + if err != nil { + t.Fatal(err) + } + slices.Sort(promoted) + if !slices.Equal(promoted, []string{movieA, movieB}) { + t.Fatalf("promoted = %v", promoted) + } + if at, ok := f.store.get("p1", movieA); !ok || !at.Equal(addedAt) { + t.Fatalf("library entry added_at = %v %v, want %v", at, ok, addedAt) + } + if at, ok := f.store.get("p1", movieB); !ok || !at.Equal(addedAt.Add(time.Minute)) { + t.Fatalf("library entry added_at = %v %v", at, ok) + } + if f.title(t, byTMDB.Title.ID) != nil || f.title(t, byIMDb.Title.ID) != nil { + t.Fatal("promoted titles kept") + } + if f.title(t, ambiguous.Title.ID) == nil || f.title(t, missing.Title.ID) == nil { + t.Fatal("unresolved titles removed") + } + if got := f.effects.snapshot(); len(got) != 2 { + t.Fatalf("effects = %v", got) + } + // Nothing left to do: no further effects. + if promoted, err := f.svc.PromoteProfile(ctx, f.viewer("p1")); err != nil || len(promoted) != 0 { + t.Fatalf("second promotion = %v %v", promoted, err) + } +} + +func TestTitlesConcurrentPromotionFiresEffectsOnce(t *testing.T) { + f := newTitlesFixture(t) + f.add(t, "p1", f.snap("series", 500, "", 0), f.now) + contentID := f.item(t, "s", "series", strconv.Itoa(f.id(500)), "") + var wg sync.WaitGroup + errs := make([]error, 8) + for i := range errs { + wg.Add(1) + go func() { + defer wg.Done() + _, errs[i] = f.svc.PromoteProfile(t.Context(), f.viewer("p1")) + }() + } + wg.Wait() + for _, err := range errs { + if err != nil { + t.Fatal(err) + } + } + want := []string{fmt.Sprintf("%d/p1/%s", f.userID, contentID)} + if got := f.effects.snapshot(); !slices.Equal(got, want) { + t.Fatalf("effects = %v, want %v", got, want) + } +} + +func TestTitlesPromoteItemThroughDeadAlias(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + // The entry was added under a TMDB ID the library item has since + // rejected as a duplicate. + entry := f.add(t, "p1", f.snap("movie", 600, "", 0), f.now) + contentID := f.item(t, "m", "movie", strconv.Itoa(f.id(601)), "") + if _, err := f.pool.Exec(ctx, `INSERT INTO stale_media_ids (content_id, provider, provider_id) VALUES ($1, 'tmdb', $2)`, contentID, strconv.Itoa(f.id(600))); err != nil { + t.Fatal(err) + } + other := f.add(t, "p1", f.snap("movie", 602, "", 0), f.now) + promoted, err := f.svc.PromoteItem(ctx, f.viewer("p1"), contentID) + if err != nil || !slices.Equal(promoted, []string{contentID}) { + t.Fatalf("PromoteItem = %v %v", promoted, err) + } + if f.title(t, entry.Title.ID) != nil || f.title(t, other.Title.ID) == nil { + t.Fatal("PromoteItem touched the wrong titles") + } + if promoted, err := f.svc.PromoteItem(ctx, f.viewer("p1"), f.prefix+"-absent"); err != nil || len(promoted) != 0 { + t.Fatalf("absent item = %v %v", promoted, err) + } +} + +func TestTitlesCheckCadence(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + old := f.add(t, "p1", f.snap("movie", 700, "", 0), f.now) + recentSnap := f.snap("movie", 701, "", 0) + recentDate := f.now.AddDate(0, -1, 0).Truncate(24 * time.Hour) + recentSnap.ReleaseDate = &recentDate + recent := f.add(t, "p1", recentSnap, f.now) + unknownSnap := f.snap("movie", 702, "", 0) + unknownSnap.ReleaseDate = nil + unknown := f.add(t, "p1", unknownSnap, f.now) + + for _, tc := range []struct { + entry Entry + snap Snapshot + want time.Duration + }{ + {old, f.snap("movie", 700, "", 0), checkSettled}, + {recent, recentSnap, checkRecent}, + {unknown, unknownSnap, checkRecent}, + } { + f.tmdb.details["movie:"+strconv.Itoa(tc.snap.TMDBID)] = f.detail(tc.snap) + if err := f.svc.checkTitle(ctx, tc.entry.Title.ID); err != nil { + t.Fatal(err) + } + got := f.title(t, tc.entry.Title.ID) + if !got.NextCheckAt.Equal(f.now.Add(tc.want)) || got.CheckedAt == nil { + t.Errorf("tmdb %d next check = %v, want now+%v", tc.snap.TMDBID, got.NextCheckAt, tc.want) + } + } +} + +func TestTitlesRepairFailureTable(t *testing.T) { + t.Run("new imdb id is recorded, one another title holds is skipped", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 800, "", 0), f.now) + holder := f.add(t, "p1", f.snap("series", 801, "", 55), f.now) + updated := f.snap("movie", 800, "tt-800", 0) + updated.Title = "Renamed" + f.tmdb.details["movie:"+strconv.Itoa(f.id(800))] = f.detail(updated) + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + got := f.title(t, title.Title.ID) + if got.IMDbID != "tt-800" || got.Title != "Renamed" { + t.Fatalf("refreshed title = %+v", got) + } + if aliases := f.aliases(t, title.Title.ID); !slices.Contains(aliases, "imdb:tt-800") { + t.Fatalf("aliases = %v", aliases) + } + // Invariant 4: a live observation never takes another title's ID. + seriesSnap := f.snap("series", 802, "", 55) + stolen := f.add(t, "p2", f.snap("series", 802, "", 0), f.now) + f.svc.ObservedDetail(t.Context(), "series", f.id(802), f.detail(seriesSnap)) + if aliases := f.aliases(t, stolen.Title.ID); slices.Contains(aliases, "tvdb:55") { + t.Fatalf("observation took another title's alias: %v", aliases) + } + if aliases := f.aliases(t, holder.Title.ID); !slices.Contains(aliases, "tvdb:55") { + t.Fatalf("holder lost its alias: %v", aliases) + } + if f.title(t, stolen.Title.ID) == nil || f.title(t, holder.Title.ID) == nil { + t.Fatal("observation merged titles") + } + }) + + t.Run("a single 404 only schedules the confirming check", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 810, "tt-810", 0), f.now) + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + got := f.title(t, title.Title.ID) + if got.State != TitleActive || got.NotFoundCount != 1 || !got.NextCheckAt.Equal(f.now.Add(24*time.Hour)) { + t.Fatalf("after one 404 = %+v", got) + } + // A second 404 within the day does not confirm. + f.now = f.now.Add(2 * time.Hour) + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + got = f.title(t, title.Title.ID) + if got.State != TitleActive || got.NotFoundCount != 1 || !got.NextCheckAt.Equal(f.now.Add(22*time.Hour)) { + t.Fatalf("after an early second 404 = %+v", got) + } + }) + + t.Run("confirmed 404 with one imdb candidate repoints", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 820, "tt-820", 0), f.now) + f.confirmable(t, title.Title.ID) + f.tmdb.finds["imdb_id:tt-820"] = []tmdb.MediaResult{{ID: f.id(821), MediaType: "movie"}, {ID: 9, MediaType: "series"}} + fresh := f.snap("movie", 821, "tt-820", 0) + fresh.Title = "Kept Duplicate" + f.tmdb.details["movie:"+strconv.Itoa(f.id(821))] = f.detail(fresh) + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + got := f.title(t, title.Title.ID) + if got.TMDBID != f.id(821) || got.State != TitleActive || got.NotFoundCount != 0 || got.Title != "Kept Duplicate" { + t.Fatalf("repointed title = %+v", got) + } + aliases := f.aliases(t, title.Title.ID) + if !slices.Contains(aliases, "tmdb:"+strconv.Itoa(f.id(820))) || !slices.Contains(aliases, "tmdb:"+strconv.Itoa(f.id(821))) { + t.Fatalf("aliases = %v", aliases) + } + // Removing by the dead ID still finds the entry. + if _, removed, err := f.svc.Remove(t.Context(), f.viewer("p1"), "movie", f.id(820)); err != nil || !removed { + t.Fatalf("remove by old id = %v %v", removed, err) + } + }) + + t.Run("recovery falls back to tvdb", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("series", 825, "tt-825", 825), f.now) + f.confirmable(t, title.Title.ID) + f.tmdb.finds["tvdb_id:825"] = []tmdb.MediaResult{{ID: f.id(826), MediaType: "series"}} + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + if got := f.title(t, title.Title.ID); got.TMDBID != f.id(826) { + t.Fatalf("tvdb recovery = %+v", got) + } + }) + + t.Run("recovery onto another title's id merges, keeping the earliest added_at", func(t *testing.T) { + f := newTitlesFixture(t) + early := f.now.Add(-72 * time.Hour) + late := f.now.Add(-time.Hour) + dead := f.add(t, "both", f.snap("movie", 830, "tt-830", 0), early) + f.add(t, "dead-only", f.snap("movie", 830, "tt-830", 0), late) + live := f.add(t, "both", f.snap("movie", 831, "", 0), late) + f.confirmable(t, dead.Title.ID) + f.tmdb.finds["imdb_id:tt-830"] = []tmdb.MediaResult{{ID: f.id(831), MediaType: "movie"}} + if err := f.svc.checkTitle(t.Context(), dead.Title.ID); err != nil { + t.Fatal(err) + } + if f.title(t, dead.Title.ID) != nil { + t.Fatal("merged title kept") + } + if at, ok := f.entryAddedAt(t, "both", live.Title.ID); !ok || !at.Equal(early) { + t.Fatalf("merged entry added_at = %v %v, want %v", at, ok, early) + } + if _, ok := f.entryAddedAt(t, "dead-only", live.Title.ID); !ok { + t.Fatal("entry not moved to the surviving title") + } + var entries int + if err := f.pool.QueryRow(t.Context(), `SELECT count(*) FROM user_watchlist_titles WHERE user_id = $1 AND profile_id = 'both'`, f.userID).Scan(&entries); err != nil { + t.Fatal(err) + } + if entries != 1 { + t.Fatalf("profile that had both duplicates has %d entries, want 1", entries) + } + aliases := f.aliases(t, live.Title.ID) + for _, want := range []string{"tmdb:" + strconv.Itoa(f.id(830)), "tmdb:" + strconv.Itoa(f.id(831)), "imdb:tt-830"} { + if !slices.Contains(aliases, want) { + t.Fatalf("survivor aliases = %v, missing %s", aliases, want) + } + } + }) + + t.Run("several candidates need review", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 840, "tt-840", 0), f.now) + f.confirmable(t, title.Title.ID) + f.tmdb.finds["imdb_id:tt-840"] = []tmdb.MediaResult{{ID: f.id(841), MediaType: "movie"}, {ID: f.id(842), MediaType: "movie"}} + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + if got := f.title(t, title.Title.ID); got.State != TitleNeedsReview || got.TMDBID != f.id(840) { + t.Fatalf("title = %+v", got) + } + }) + + t.Run("no candidates mark the title removed and keep the entry", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 850, "", 0), f.now) + f.confirmable(t, title.Title.ID) + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err != nil { + t.Fatal(err) + } + got := f.title(t, title.Title.ID) + if got.State != TitleRemoved || !got.NextCheckAt.Equal(f.now.Add(30*24*time.Hour)) || got.NotFoundCount != 2 { + t.Fatalf("title = %+v", got) + } + if _, ok := f.entryAddedAt(t, "p1", title.Title.ID); !ok { + t.Fatal("removed title lost its entry") + } + }) + + t.Run("an outage backs off and leaves the state alone", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 860, "tt-860", 0), f.now) + f.tmdb.detailErrs["movie:"+strconv.Itoa(f.id(860))] = fmt.Errorf("tmdb: status 503") + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err == nil { + t.Fatal("expected the outage to be reported") + } + got := f.title(t, title.Title.ID) + if got.State != TitleActive || got.NotFoundCount != 0 || !got.NextCheckAt.Equal(f.now.Add(6*time.Hour)) { + t.Fatalf("title = %+v", got) + } + // An outage during recovery backs off too, without confirming. + f.confirmable(t, title.Title.ID) + delete(f.tmdb.detailErrs, "movie:"+strconv.Itoa(f.id(860))) + f.tmdb.findErr = fmt.Errorf("tmdb: status 503") + if err := f.svc.checkTitle(t.Context(), title.Title.ID); err == nil { + t.Fatal("expected the find outage to be reported") + } + got = f.title(t, title.Title.ID) + if got.State != TitleActive || got.NotFoundCount != 1 || !got.NextCheckAt.Equal(f.now.Add(6*time.Hour)) { + t.Fatalf("title after find outage = %+v", got) + } + }) + + t.Run("the library imports the title under the dead id", func(t *testing.T) { + f := newTitlesFixture(t) + title := f.add(t, "p1", f.snap("movie", 870, "", 0), f.now) + // Repointed: the entry's current ID is 871; the library item carries 870. + if _, err := f.svc.repo.repoint(t.Context(), title.Title.ID, f.id(870), f.id(871), f.now); err != nil { + t.Fatal(err) + } + contentID := f.item(t, "dead", "movie", strconv.Itoa(f.id(870)), "") + promoted, err := f.svc.PromoteProfile(t.Context(), f.viewer("p1")) + if err != nil || !slices.Equal(promoted, []string{contentID}) { + t.Fatalf("promotion = %v %v", promoted, err) + } + }) +} + +func TestTitlesScheduleChecksClaimsAndBounds(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + var titles []Title + for i := range 5 { + snap := f.snap("movie", 900+i, "", 0) + titles = append(titles, f.add(t, "p1", snap, f.now).Title) + f.tmdb.details["movie:"+strconv.Itoa(snap.TMDBID)] = f.detail(snap) + } + // Titles are due at creation only once their next check passes. + f.now = f.now.Add(2 * checkSettled) + for i := range titles { + titles[i].NextCheckAt = f.title(t, titles[i].ID).NextCheckAt + } + f.svc.ScheduleChecks(ctx, titles) + f.svc.waitChecks() + if got := f.tmdb.calls(); got != maxChecksPerRead { + t.Fatalf("checks = %d, want %d", got, maxChecksPerRead) + } + if len(f.svc.checkSlots) != 0 { + t.Fatalf("%d check slots leaked", len(f.svc.checkSlots)) + } + // A claim is won once. + due := []int64{titles[3].ID} + first, err := f.svc.repo.claimDue(ctx, due, f.now, claimLease) + if err != nil || len(first) != 1 { + t.Fatalf("first claim = %v %v", first, err) + } + second, err := f.svc.repo.claimDue(ctx, due, f.now, claimLease) + if err != nil || len(second) != 0 { + t.Fatalf("second claim = %v %v", second, err) + } + // An expired claim can be taken again. + if again, err := f.svc.repo.claimDue(ctx, due, f.now.Add(claimLease), claimLease); err != nil || len(again) != 1 { + t.Fatalf("claim after lease = %v %v", again, err) + } +} + +func TestTitlesObserver(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + snap := f.snap("movie", 950, "tt-950", 0) + title := f.add(t, "p1", snap, f.now) + before := f.title(t, title.Title.ID) + + // Unchanged detail: no write. + f.now = f.now.Add(time.Hour) + f.svc.ObservedDetail(ctx, "movie", snap.TMDBID, f.detail(snap)) + if got := f.title(t, title.Title.ID); !got.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf("unchanged observation wrote: %v -> %v", before.UpdatedAt, got.UpdatedAt) + } + // Untracked titles are ignored. + f.svc.ObservedDetail(ctx, "movie", f.id(951), f.detail(f.snap("movie", 951, "", 0))) + f.svc.ObservedNotFound(ctx, "movie", f.id(951)) + if other, _ := f.svc.Find(ctx, "movie", f.id(951)); other != nil { + t.Fatal("observation created a title") + } + + changed := snap + changed.TVDBID = 0 + changed.PosterPath = "/new.jpg" + f.svc.ObservedDetail(ctx, "movie", snap.TMDBID, f.detail(changed)) + if got := f.title(t, title.Title.ID); got.PosterPath != "/new.jpg" { + t.Fatalf("changed observation not stored: %+v", got) + } + + f.svc.ObservedNotFound(ctx, "movie", snap.TMDBID) + got := f.title(t, title.Title.ID) + if got.NotFoundCount != 1 || got.LastNotFoundAt == nil || !got.NextCheckAt.Equal(f.now.Add(24*time.Hour)) { + t.Fatalf("observed 404 = %+v", got) + } + // A repeat within the day writes nothing. + f.svc.ObservedNotFound(ctx, "movie", snap.TMDBID) + if again := f.title(t, title.Title.ID); !again.UpdatedAt.Equal(got.UpdatedAt) || again.NotFoundCount != 1 { + t.Fatalf("repeat 404 wrote: %+v", again) + } + // Once the confirming check is due, a 404 starts it in the background. + f.now = f.now.Add(25 * time.Hour) + f.svc.ObservedNotFound(ctx, "movie", snap.TMDBID) + f.svc.waitChecks() + if confirmed := f.title(t, title.Title.ID); confirmed.State != TitleRemoved { + t.Fatalf("confirmed 404 = %+v", confirmed) + } +} + +func TestSnapshotFromDetail(t *testing.T) { + snap, err := SnapshotFromDetail(&tmdb.MediaDetail{MediaType: "series", ID: 5, FirstAirDate: "2020-02-03", VoteAverage: 8, VoteCount: 0}) + if err != nil || snap.VoteAverage != nil || snap.ReleaseDate == nil || snap.ReleaseDate.Format(time.DateOnly) != "2020-02-03" { + t.Fatalf("snapshot = %+v %v", snap, err) + } + if _, err := SnapshotFromDetail(&tmdb.MediaDetail{MediaType: "person", ID: 5}); err == nil { + t.Fatal("expected an unsupported media type error") + } + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(-1, 0, 0) + future := now.AddDate(0, 1, 0) + for _, tc := range []struct { + date *time.Time + want time.Duration + }{{nil, checkRecent}, {&future, checkRecent}, {&old, checkSettled}} { + if got := nextCheckAfterSuccess(now, tc.date).Sub(now); got != tc.want { + t.Errorf("next check for %v = %v, want %v", tc.date, got, tc.want) + } + } +} diff --git a/internal/watchlist/titles_race_db_test.go b/internal/watchlist/titles_race_db_test.go new file mode 100644 index 0000000000..3a2c83b72d --- /dev/null +++ b/internal/watchlist/titles_race_db_test.go @@ -0,0 +1,393 @@ +package watchlist + +import ( + "context" + "fmt" + "os" + "slices" + "strconv" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// waitForLockWait blocks until a statement matching the pattern waits on a +// row lock, or fails the test when done closes first or the deadline passes. +func (f *titlesFixture) waitForLockWait(t *testing.T, pattern string, done <-chan struct{}) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + var waiting bool + if err := f.pool.QueryRow(context.Background(), ` + SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE wait_event_type = 'Lock' AND query LIKE $1)`, pattern).Scan(&waiting); err != nil { + t.Error(err) + return + } + if waiting { + return + } + select { + case <-done: + t.Error("the statement finished instead of waiting on the title lock") + return + default: + } + if time.Now().After(deadline) { + t.Error("timed out waiting for the statement to block on the title lock") + return + } + time.Sleep(10 * time.Millisecond) + } +} + +// A remove that runs while promotion is adding the item to the library +// watchlist must wait for that add, so the caller's follow-up library check +// sees the item and takes it off. +func TestTitlesPromotionHoldsTitleLockAcrossLibraryAdd(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + snap := f.snap("movie", 800, "", 0) + entry := f.add(t, "p1", snap, f.now.Add(-time.Hour)) + contentID := f.item(t, "race", "movie", strconv.Itoa(snap.TMDBID), "") + + removeDone := make(chan struct{}) + var removed bool + var removeErr error + var onLibraryAtRemove bool + f.store.beforeAdd = func() { + f.store.beforeAdd = nil + go func() { + defer close(removeDone) + _, removed, removeErr = f.svc.Remove(context.Background(), f.viewer("p1"), "movie", snap.TMDBID) + _, onLibraryAtRemove = f.store.get("p1", contentID) + }() + f.waitForLockWait(t, "%FOR UPDATE OF t%", removeDone) + } + promoted, err := f.svc.PromoteProfile(ctx, f.viewer("p1")) + if err != nil || !slices.Equal(promoted, []string{contentID}) { + t.Fatalf("PromoteProfile = %v %v", promoted, err) + } + <-removeDone + if removeErr != nil { + t.Fatal(removeErr) + } + if removed { + t.Fatal("remove deleted the entry promotion was moving") + } + if !onLibraryAtRemove { + t.Fatal("remove returned before the promoted library entry existed; the caller's library check would miss it") + } + if f.title(t, entry.Title.ID) != nil { + t.Fatal("promoted title kept") + } + + // A remove that commits first leaves promotion nothing to add. + second := f.snap("movie", 801, "", 0) + f.add(t, "p1", second, f.now) + secondItem := f.item(t, "race-2", "movie", strconv.Itoa(second.TMDBID), "") + if _, removed, err := f.svc.Remove(ctx, f.viewer("p1"), "movie", second.TMDBID); err != nil || !removed { + t.Fatalf("remove = %v %v", removed, err) + } + if promoted, err := f.svc.PromoteProfile(ctx, f.viewer("p1")); err != nil || len(promoted) != 0 { + t.Fatalf("promotion after remove = %v %v", promoted, err) + } + if _, ok := f.store.get("p1", secondItem); ok { + t.Fatal("promotion added an item whose entry was removed") + } +} + +// An item the profile already had on its library watchlist absorbs the entry +// without firing the add effects again; one whose library entry carries the +// entry's added_at is a promotion a dead node left half done, and fires them. +func TestTitlesPromotionEffectsOnlyForNewLibraryEntries(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + entryAt := f.now.Add(-48 * time.Hour).Add(123 * time.Millisecond) + + listed := f.add(t, "p1", f.snap("movie", 810, "", 0), entryAt) + listedItem := f.item(t, "listed", "movie", strconv.Itoa(f.id(810)), "") + manualAt := f.now.Add(-time.Hour) + f.store.set("p1", listedItem, manualAt) + + halfDone := f.add(t, "p1", f.snap("movie", 811, "", 0), entryAt) + halfDoneItem := f.item(t, "half", "movie", strconv.Itoa(f.id(811)), "") + f.store.set("p1", halfDoneItem, entryAt) + + promoted, err := f.svc.PromoteProfile(ctx, f.viewer("p1")) + if err != nil || !slices.Equal(promoted, []string{halfDoneItem}) { + t.Fatalf("PromoteProfile = %v %v, want only %s", promoted, err, halfDoneItem) + } + want := []string{fmt.Sprintf("%d/p1/%s", f.userID, halfDoneItem)} + if got := f.effects.snapshot(); !slices.Equal(got, want) { + t.Fatalf("effects = %v, want %v", got, want) + } + if f.title(t, listed.Title.ID) != nil || f.title(t, halfDone.Title.ID) != nil { + t.Fatal("promoted entries kept") + } + if at, _ := f.store.get("p1", listedItem); !at.Equal(manualAt) { + t.Fatalf("existing library entry added_at = %v, want %v", at, manualAt) + } +} + +// A merge that deletes the title a remove is waiting on moves the TMDB alias +// to the survivor; the remove must follow it rather than report no title. +func TestTitlesRemoveFollowsAliasMergedWhileWaiting(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + loser := f.add(t, "p1", f.snap("movie", 820, "", 0), f.now) + survivor := f.add(t, "p2", f.snap("movie", 821, "", 0), f.now) + + merger, err := f.pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = merger.Rollback(context.Background()) }() + if _, err := merger.Exec(ctx, `SELECT id FROM watchlist_titles WHERE id = ANY($1) ORDER BY id FOR UPDATE`, + []int64{loser.Title.ID, survivor.Title.ID}); err != nil { + t.Fatal(err) + } + + removeDone := make(chan struct{}) + var title *Title + var removed bool + var removeErr error + go func() { + defer close(removeDone) + title, removed, removeErr = f.svc.Remove(context.Background(), f.viewer("p1"), "movie", f.id(820)) + }() + f.waitForLockWait(t, "%FOR UPDATE OF t%", removeDone) + if t.Failed() { + t.FailNow() + } + + // The merge body, as titlesRepo.merge runs it once both rows are locked. + for _, stmt := range []string{ + `INSERT INTO user_watchlist_titles (user_id, profile_id, title_id, added_at) + SELECT user_id, profile_id, $2, added_at FROM user_watchlist_titles WHERE title_id = $1 + ON CONFLICT (user_id, profile_id, title_id) DO NOTHING`, + `UPDATE watchlist_title_aliases SET title_id = $2 WHERE title_id = $1`, + `DELETE FROM watchlist_titles WHERE id = $1 AND $2::bigint IS NOT NULL`, + } { + if _, err := merger.Exec(ctx, stmt, loser.Title.ID, survivor.Title.ID); err != nil { + t.Fatal(err) + } + } + if err := merger.Commit(ctx); err != nil { + t.Fatal(err) + } + <-removeDone + if removeErr != nil { + t.Fatal(removeErr) + } + if !removed || title == nil || title.ID != survivor.Title.ID { + t.Fatalf("remove = %+v removed=%v, want the survivor removed", title, removed) + } + if _, ok := f.entryAddedAt(t, "p1", survivor.Title.ID); ok { + t.Fatal("the merged entry stayed on the watchlist") + } + if _, ok := f.entryAddedAt(t, "p2", survivor.Title.ID); !ok { + t.Fatal("another profile's entry was removed") + } +} + +// User deletion drops entries through the users foreign key and leaves +// orphaned titles. Reads skip them, an add refreshes the stale snapshot, and +// the sweep deletes them. +func TestTitlesOrphansAfterUserDelete(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + var other int + if err := f.pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, f.prefix+"-other").Scan(&other); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, other) }) + otherViewer := Viewer{UserID: other, ProfileID: "p1"} + + stale := f.snap("movie", 830, "", 0) + stale.Certification = "PG" + gone, _, err := f.svc.AddSnapshot(ctx, otherViewer, stale, f.now) + if err != nil { + t.Fatal(err) + } + reused, _, err := f.svc.AddSnapshot(ctx, otherViewer, f.snap("movie", 831, "", 0), f.now) + if err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, other); err != nil { + t.Fatal(err) + } + if f.title(t, gone.Title.ID) == nil { + t.Fatal("setup: expected the user delete to leave an orphaned title") + } + if found, err := f.svc.Find(ctx, "movie", f.id(830)); err != nil || found != nil { + t.Fatalf("Find returned an orphaned title: %+v %v", found, err) + } + + fresh := f.snap("movie", 831, "", 0) + fresh.Title, fresh.Certification = "Renamed", "R" + entry, inserted, err := f.svc.AddSnapshot(ctx, f.viewer("p1"), fresh, f.now) + if err != nil || !inserted { + t.Fatalf("add = %v %v", inserted, err) + } + if entry.Title.ID != reused.Title.ID || entry.Title.Title != "Renamed" || entry.Title.Certification != "R" { + t.Fatalf("add attached to an orphan without refreshing it: %+v", entry.Title) + } + + if err := f.svc.SweepOrphanTitles(ctx); err != nil { + t.Fatal(err) + } + if f.title(t, gone.Title.ID) != nil { + t.Fatal("sweep kept the orphaned title") + } + if f.title(t, reused.Title.ID) == nil { + t.Fatal("sweep deleted a title with an entry") + } +} + +// A one-connection pool can't give the library watchlist write (which, in the +// Postgres user store, takes its own connection) a second connection while +// the title lock's transaction holds the first. Promotion writes first there +// instead of waiting until the request times out. +func TestTitlesPromotionCompletesOnOneConnectionPool(t *testing.T) { + f := newTitlesFixture(t) + cfg, err := pgxpool.ParseConfig(os.Getenv("SILO_TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + cfg.MaxConns = 1 + single, err := pgxpool.NewWithConfig(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(single.Close) + svc := NewTitles(single, catalog.NewItemRepository(f.pool), nil, f.tmdb, f.effects) + svc.storeFor = func(context.Context, int) (promotionStore, error) { return f.store, nil } + svc.now = func() time.Time { return f.now } + if svc.addUnderLock { + t.Fatal("a one-connection pool must not hold the title lock across the library write") + } + + snap := f.snap("movie", 810, "", 0) + f.add(t, "p1", snap, f.now.Add(-time.Hour)) + contentID := f.item(t, "single", "movie", strconv.Itoa(snap.TMDBID), "") + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + f.store.beforeAdd = func() { + f.store.beforeAdd = nil + // Stands in for the Postgres user store's write on the same pool. + if _, err := single.Exec(ctx, `SELECT 1`); err != nil { + t.Errorf("library write could not get a connection: %v", err) + } + } + promoted, err := svc.PromoteProfile(ctx, f.viewer("p1")) + if err != nil || !slices.Equal(promoted, []string{contentID}) { + t.Fatalf("PromoteProfile = %v %v, want %s promoted without waiting on the pool", promoted, err, contentID) + } +} + +// A library item known only by a TMDB ID the title has since lost is still +// the title's copy: matching uses every TMDB ID the caller passes. +func TestTitlesLibraryMatchesThroughFormerTMDBID(t *testing.T) { + f := newTitlesFixture(t) + former := f.id(820) + contentID := f.item(t, "former", "movie", strconv.Itoa(former), "") + current := f.id(821) + only, err := f.svc.LibraryMatches(t.Context(), "movie", []int{current}, "", 0) + if err != nil || len(only) != 0 { + t.Fatalf("current ID alone = %v %v, want no match", only, err) + } + all, err := f.svc.LibraryMatches(t.Context(), "movie", []int{current, former}, "", 0) + if err != nil || !slices.Equal(all, []string{contentID}) { + t.Fatalf("with the former ID = %v %v, want %s", all, err, contentID) + } +} + +// On a one-connection pool the library write runs before the lock, so it +// first checks the entry is still there: an entry a remove already took is +// not written back onto the library watchlist. +func TestTitlesOneConnectionPromotionSkipsRemovedEntry(t *testing.T) { + f := newTitlesFixture(t) + f.svc.addUnderLock = false + snap := f.snap("movie", 830, "", 0) + entry := f.add(t, "p1", snap, f.now.Add(-time.Hour)) + contentID := f.item(t, "gone", "movie", strconv.Itoa(snap.TMDBID), "") + if _, _, err := f.svc.Remove(t.Context(), f.viewer("p1"), "movie", snap.TMDBID); err != nil { + t.Fatal(err) + } + moved, _, err := f.svc.promoteOne(t.Context(), f.store, f.viewer("p1"), entry.Title.ID, contentID, entry.AddedAt) + if err != nil || moved { + t.Fatalf("promoteOne = %v %v, want nothing moved", moved, err) + } + if _, ok := f.store.get("p1", contentID); ok { + t.Fatal("a removed entry was written onto the library watchlist") + } +} + +// A provider ID another title already owns is never stored in a title's own +// IMDb or TVDB field: library matching and request presence read those +// fields, and would match the other title's copy. A new title leaves the +// field empty; a refresh keeps the ID the title already owns. +func TestTitlesKeepProviderIDsToOwnedAliases(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + owner := f.add(t, "p1", f.snap("movie", 840, "tt84000001", 8400001), f.now) + + // A new title reporting the owner's IDs keeps neither. + dup := f.add(t, "p1", f.snap("movie", 841, "tt84000001", 8400001), f.now) + got := f.title(t, dup.Title.ID) + if got.IMDbID != "" || got.TVDBID != 0 { + t.Fatalf("new title stored imdb %q tvdb %d that the other title owns", got.IMDbID, got.TVDBID) + } + + // A title with its own IDs that a refresh reports as the owner's keeps its own. + own := f.add(t, "p1", f.snap("movie", 842, "tt84000042", 8400042), f.now) + if err := f.svc.repo.applyDetail(ctx, own.Title.ID, f.snap("movie", 842, "tt84000001", 8400001), f.now); err != nil { + t.Fatal(err) + } + got = f.title(t, own.Title.ID) + if got.IMDbID != "tt84000042" || got.TVDBID != 8400042 { + t.Fatalf("refreshed title imdb %q tvdb %d, want its own tt84000042 / 8400042", got.IMDbID, got.TVDBID) + } + // The owner is untouched. + if o := f.title(t, owner.Title.ID); o.IMDbID != "tt84000001" || o.TVDBID != 8400001 { + t.Fatalf("owner imdb %q tvdb %d changed", o.IMDbID, o.TVDBID) + } +} + +// A duplicate copy in a library the viewer can't open doesn't make the title +// ambiguous for that viewer: only the copies they may see count, so the entry +// promotes onto the one they can see. +func TestTitlesPromotionCountsOnlyAccessibleCopies(t *testing.T) { + f := newTitlesFixture(t) + ctx := t.Context() + snap := f.snap("movie", 850, "", 0) + f.add(t, "p1", snap, f.now.Add(-time.Hour)) + visible := f.item(t, "visible", "movie", strconv.Itoa(snap.TMDBID), "") + + var other int + if err := f.pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('mixed', $1, TRUE) RETURNING id`, f.prefix+"-other").Scan(&other); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM media_folders WHERE id = $1`, other) }) + hidden := f.prefix + "-hidden" + if _, err := f.pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, genres, tmdb_id) VALUES ($1, 'movie', $1, '{}'::text[], $2)`, + hidden, strconv.Itoa(snap.TMDBID)); err != nil { + t.Fatal(err) + } + if _, err := f.pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, hidden, other); err != nil { + t.Fatal(err) + } + + viewer := f.viewer("p1") + viewer.Access = catalog.AccessFilter{AllowedLibraryIDs: []int{f.folder}} + promoted, err := f.svc.PromoteProfile(ctx, viewer) + if err != nil || !slices.Equal(promoted, []string{visible}) { + t.Fatalf("PromoteProfile = %v %v, want the one copy the viewer can see (%s)", promoted, err, visible) + } +} diff --git a/internal/watchlist/titles_repair.go b/internal/watchlist/titles_repair.go new file mode 100644 index 0000000000..1ddb01c303 --- /dev/null +++ b/internal/watchlist/titles_repair.go @@ -0,0 +1,265 @@ +package watchlist + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math" + "sync" + + "github.com/Silo-Server/silo-server/internal/contentid" + "github.com/Silo-Server/silo-server/internal/metadata/tmdb" +) + +// TitleObserver receives the outcome of TMDB detail fetches made for other +// reasons (a Discover detail view), so tracked titles refresh their IDs at no +// extra TMDB cost. requests.Service calls it; *Titles implements it. +type TitleObserver interface { + ObservedDetail(ctx context.Context, mediaType string, tmdbID int, detail *tmdb.MediaDetail) + ObservedNotFound(ctx context.Context, mediaType string, tmdbID int) +} + +var _ TitleObserver = (*Titles)(nil) + +// ScheduleChecks starts background checks for up to maxChecksPerRead overdue +// titles, bounded per node by maxConcurrentChecks. Each title is claimed first +// so only one node checks it. It returns at once; the checks outlive ctx. +func (s *Titles) ScheduleChecks(ctx context.Context, titles []Title) { + if s == nil || s.tmdb == nil { + return + } + now := s.now() + var due []int64 + for _, t := range titles { + if len(due) == maxChecksPerRead { + break + } + if t.NextCheckAt.After(now) { + continue + } + // Hold a slot before claiming so a busy node never claims a check it + // cannot run; the claim would otherwise idle for the full lease. + select { + case s.checkSlots <- struct{}{}: + due = append(due, t.ID) + default: + } + } + if len(due) == 0 { + return + } + s.checks.Add(1) + go func() { + defer s.checks.Done() + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + claimed, err := s.repo.claimDue(ctx, due, now, claimLease) + if err != nil { + slog.WarnContext(ctx, "claiming watchlist title checks failed", "component", "watchlist", "error", err) + } + release := len(due) - len(claimed) + for range release { + <-s.checkSlots + } + var wg sync.WaitGroup + for _, id := range claimed { + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-s.checkSlots }() + checkCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + if err := s.checkTitle(checkCtx, id); err != nil { + slog.WarnContext(checkCtx, "watchlist title check failed", "component", "watchlist", "title_id", id, "error", err) + } + }() + } + wg.Wait() + }() +} + +// waitChecks blocks until background checks finish. Tests use it. +func (s *Titles) waitChecks() { s.checks.Wait() } + +// checkTitle asks TMDB about the title's current ID and records the outcome. +func (s *Titles) checkTitle(ctx context.Context, titleID int64) error { + t, err := s.repo.titleByID(ctx, titleID) + if err != nil || t == nil { + return err + } + detail, err := s.tmdb.GetMediaDetail(ctx, t.MediaType, t.TMDBID) + now := s.now() + switch { + case err == nil: + snap, err := SnapshotFromDetail(detail) + if err != nil { + return errors.Join(err, s.repo.backoff(ctx, t.ID, now.Add(errorBackoff))) + } + return s.repo.applyDetail(ctx, t.ID, snap, now) + case errors.Is(err, tmdb.ErrNotFound): + return s.handleNotFound(ctx, *t) + default: + return errors.Join(fmt.Errorf("fetching tmdb detail: %w", err), s.repo.backoff(ctx, t.ID, now.Add(errorBackoff))) + } +} + +// handleNotFound records a 404 for the title's current ID. The first one only +// schedules a confirming check a day later; a second one at least a day after +// the first confirms the deletion and runs recovery. +func (s *Titles) handleNotFound(ctx context.Context, t Title) error { + now := s.now() + if t.NotFoundCount == 0 || t.LastNotFoundAt == nil { + return s.repo.recordNotFound(ctx, t.ID, t.TMDBID, 1, now, now.Add(notFoundConfirmGap)) + } + if confirmAt := t.LastNotFoundAt.Add(notFoundConfirmGap); now.Before(confirmAt) { + return s.repo.backoff(ctx, t.ID, confirmAt) + } + return s.recover(ctx, t) +} + +// recover looks the title up on TMDB by its stored IMDb IDs, then its TVDB +// IDs, after its TMDB ID was confirmed deleted. One candidate of the right +// media type repoints the title (merging it into another title that already +// holds that ID); several need the user; none mark it removed. +func (s *Titles) recover(ctx context.Context, t Title) error { + aliases, err := s.repo.aliasesOf(ctx, t.ID) + if err != nil { + return err + } + now := s.now() + var candidates []int + for _, source := range []struct{ provider, tmdbSource string }{ + {contentid.ProviderIMDB, tmdb.ExternalSourceIMDb}, + {contentid.ProviderTVDB, tmdb.ExternalSourceTVDB}, + } { + for _, a := range aliases { + if a.Provider != source.provider { + continue + } + results, err := s.tmdb.FindByExternalID(ctx, source.tmdbSource, a.ProviderID) + if err != nil && !errors.Is(err, tmdb.ErrNotFound) { + return errors.Join(fmt.Errorf("finding tmdb title by %s id: %w", source.provider, err), + s.repo.backoff(ctx, t.ID, now.Add(errorBackoff))) + } + for _, r := range results { + if r.MediaType == t.MediaType && r.ID > 0 && r.ID != t.TMDBID && !containsInt(candidates, r.ID) { + candidates = append(candidates, r.ID) + } + } + } + if len(candidates) > 0 { + break + } + } + notFound := t.NotFoundCount + 1 + switch len(candidates) { + case 0: + return s.repo.setState(ctx, t.ID, t.TMDBID, TitleRemoved, notFound, now, now.Add(checkSettled)) + case 1: + survivor, err := s.repo.repoint(ctx, t.ID, t.TMDBID, candidates[0], now) + if err != nil || survivor != t.ID { + return err + } + // Refresh the snapshot from the new ID now; if this fails the title + // is already due and the next read retries. + detail, detailErr := s.tmdb.GetMediaDetail(ctx, t.MediaType, candidates[0]) + if detailErr == nil { + if snap, snapErr := SnapshotFromDetail(detail); snapErr == nil { + return s.repo.applyDetail(ctx, t.ID, snap, s.now()) + } + } + return nil + default: + slog.InfoContext(ctx, "watchlist title needs review: its tmdb id was deleted and several titles match its other ids", + "component", "watchlist", "title_id", t.ID, "tmdb_id", t.TMDBID, "candidates", candidates) + return s.repo.setState(ctx, t.ID, t.TMDBID, TitleNeedsReview, notFound, now, now.Add(checkSettled)) + } +} + +// ObservedDetail refreshes a tracked title from a detail TMDB returned for its +// current ID. It writes only when something changed and never merges titles: +// an IMDb or TVDB ID another title already holds is not taken over. +func (s *Titles) ObservedDetail(ctx context.Context, mediaType string, tmdbID int, detail *tmdb.MediaDetail) { + if s == nil || detail == nil { + return + } + t, err := s.repo.titleByTMDB(ctx, mediaType, tmdbID) + if err != nil { + slog.WarnContext(ctx, "watchlist title observation failed", "component", "watchlist", "tmdb_id", tmdbID, "error", err) + return + } + if t == nil || t.TMDBID != tmdbID { + return + } + snap, err := SnapshotFromDetail(detail) + if err != nil || snap.TMDBID != t.TMDBID || snap.MediaType != t.MediaType { + return + } + if !snapshotChanged(*t, snap) { + return + } + if err := s.repo.applyDetail(ctx, t.ID, snap, s.now()); err != nil { + slog.WarnContext(ctx, "watchlist title observation failed", "component", "watchlist", "title_id", t.ID, "error", err) + } +} + +// ObservedNotFound records a 404 TMDB returned for a tracked title's current +// ID. The first 404 schedules the confirming check; once the confirming check +// is due, a background check runs recovery. +func (s *Titles) ObservedNotFound(ctx context.Context, mediaType string, tmdbID int) { + if s == nil { + return + } + t, err := s.repo.titleByTMDB(ctx, mediaType, tmdbID) + if err != nil { + slog.WarnContext(ctx, "watchlist title observation failed", "component", "watchlist", "tmdb_id", tmdbID, "error", err) + return + } + if t == nil || t.TMDBID != tmdbID { + return + } + now := s.now() + switch { + case t.NotFoundCount == 0 || t.LastNotFoundAt == nil: + err = s.repo.recordNotFound(ctx, t.ID, t.TMDBID, 1, now, now.Add(notFoundConfirmGap)) + case t.State == TitleActive && !now.Before(t.LastNotFoundAt.Add(notFoundConfirmGap)) && !t.NextCheckAt.After(now): + s.ScheduleChecks(ctx, []Title{*t}) + } + if err != nil { + slog.WarnContext(ctx, "watchlist title observation failed", "component", "watchlist", "title_id", t.ID, "error", err) + } +} + +func snapshotChanged(t Title, snap Snapshot) bool { + if t.State != TitleActive || t.NotFoundCount != 0 { + return true + } + if t.IMDbID != snap.IMDbID || t.TVDBID != snap.TVDBID || t.Title != snap.Title || t.Year != snap.Year || + t.PosterPath != snap.PosterPath || t.Certification != snap.Certification { + return true + } + if (t.ReleaseDate == nil) != (snap.ReleaseDate == nil) || + (t.ReleaseDate != nil && !t.ReleaseDate.Equal(*snap.ReleaseDate)) { + return true + } + return roundedVote(t.VoteAverage) != roundedVote(snap.VoteAverage) +} + +// roundedVote compares ratings at the stored precision, numeric(3,1), rounding +// halves away from zero as Postgres does. +func roundedVote(v *float64) float64 { + if v == nil { + return -1 + } + return math.Round(*v*10) / 10 +} + +func containsInt(values []int, v int) bool { + for _, x := range values { + if x == v { + return true + } + } + return false +} diff --git a/internal/watchlist/titles_repo.go b/internal/watchlist/titles_repo.go new file mode 100644 index 0000000000..e25768c815 --- /dev/null +++ b/internal/watchlist/titles_repo.go @@ -0,0 +1,907 @@ +package watchlist + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/contentid" + "github.com/Silo-Server/silo-server/internal/idgen" +) + +// titlesRepo holds the SQL for watchlist_titles, watchlist_title_aliases and +// user_watchlist_titles. +// +// Lock order: every write that can delete a title row, or attach an entry to +// one, first locks that title row. An add locks it with SELECT … FOR UPDATE +// (or creates it), a remove or promotion locks it before deleting the entry and +// then deletes the title only if no entry remains, and a merge locks both +// titles in id order. So an add never attaches to a title a concurrent remove +// is deleting. The orphan check runs as its own statement after the lock, so +// it reads a snapshot taken after any add it waited on had committed. +type titlesRepo struct { + pool *pgxpool.Pool +} + +// maxAddAttempts bounds the retries of an add that lost a race to create the +// same title. +const maxAddAttempts = 3 + +const titleColumns = `t.id, t.media_type, t.tmdb_id, t.imdb_id, coalesce(t.tvdb_id, 0), t.title, coalesce(t.year, 0), + t.release_date, t.poster_path, t.certification, t.vote_average::float8, t.state, t.not_found_count, + t.last_not_found_at, t.checked_at, t.next_check_at, t.created_at, t.updated_at, + coalesce((SELECT array_agg(fa.provider_id::int ORDER BY fa.provider_id::int) + FROM watchlist_title_aliases fa + WHERE fa.title_id = t.id AND fa.provider = 'tmdb' AND fa.provider_id <> t.tmdb_id::text), '{}')` + +func scanTitle(row pgx.Row, extra ...any) (Title, error) { + var t Title + var state string + dest := append([]any{ + &t.ID, &t.MediaType, &t.TMDBID, &t.IMDbID, &t.TVDBID, &t.Title, &t.Year, + &t.ReleaseDate, &t.PosterPath, &t.Certification, &t.VoteAverage, &state, &t.NotFoundCount, + &t.LastNotFoundAt, &t.CheckedAt, &t.NextCheckAt, &t.CreatedAt, &t.UpdatedAt, &t.FormerTMDBIDs, + }, extra...) + if err := row.Scan(dest...); err != nil { + return Title{}, err + } + t.State = TitleState(state) + return t, nil +} + +// titleByTMDB returns the title holding the TMDB ID as a current or former +// alias, or nil. A title no entry references is left for the orphan sweep and +// not returned: nothing keeps its snapshot current. +func (r *titlesRepo) titleByTMDB(ctx context.Context, mediaType string, tmdbID int) (*Title, error) { + t, err := scanTitle(r.pool.QueryRow(ctx, ` + SELECT `+titleColumns+` + FROM watchlist_title_aliases a + JOIN watchlist_titles t ON t.id = a.title_id + WHERE a.media_type = $1 AND a.provider = 'tmdb' AND a.provider_id = $2 + AND EXISTS (SELECT 1 FROM user_watchlist_titles e WHERE e.title_id = t.id)`, + mediaType, strconv.Itoa(tmdbID))) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading watchlist title by tmdb id: %w", err) + } + return &t, nil +} + +func (r *titlesRepo) titleByID(ctx context.Context, id int64) (*Title, error) { + t, err := scanTitle(r.pool.QueryRow(ctx, `SELECT `+titleColumns+` FROM watchlist_titles t WHERE t.id = $1`, id)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading watchlist title: %w", err) + } + return &t, nil +} + +// lockTitleByTMDB locks the title holding the TMDB alias, or reports that no +// title holds it. +// +// A merge that deletes the title this waited on moves its aliases to the +// survivor, but the waiting row lock does not follow them: it comes back +// empty. So an empty result is trusted only once a new statement, with a new +// snapshot, finds no alias either; otherwise the lock is retried against the +// alias's current title. +func lockTitleByTMDB(ctx context.Context, tx pgx.Tx, mediaType string, tmdbID int) (int64, bool, error) { + alias := strconv.Itoa(tmdbID) + for attempt := 0; attempt < maxAddAttempts; attempt++ { + var id int64 + err := tx.QueryRow(ctx, ` + SELECT t.id + FROM watchlist_title_aliases a + JOIN watchlist_titles t ON t.id = a.title_id + WHERE a.media_type = $1 AND a.provider = 'tmdb' AND a.provider_id = $2 + FOR UPDATE OF t`, mediaType, alias).Scan(&id) + if err == nil { + return id, true, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return 0, false, fmt.Errorf("locking watchlist title: %w", err) + } + var held bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM watchlist_title_aliases + WHERE media_type = $1 AND provider = 'tmdb' AND provider_id = $2)`, mediaType, alias).Scan(&held); err != nil { + return 0, false, fmt.Errorf("checking watchlist title alias: %w", err) + } + if !held { + return 0, false, nil + } + } + return 0, false, fmt.Errorf("locking watchlist title: its tmdb alias kept moving") +} + +func lockTitleByID(ctx context.Context, tx pgx.Tx, id int64) (bool, error) { + var locked int64 + err := tx.QueryRow(ctx, `SELECT id FROM watchlist_titles WHERE id = $1 FOR UPDATE`, id).Scan(&locked) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("locking watchlist title: %w", err) + } + return true, nil +} + +// insertAliases records a title's current provider IDs as aliases. An ID that +// already belongs to another title is skipped: observing a live title never +// merges two titles. +func insertAliases(ctx context.Context, tx pgx.Tx, titleID int64, mediaType string, tmdbID int, imdbID string, tvdbID int) error { + providers := []string{contentid.ProviderTMDB} + values := []string{strconv.Itoa(tmdbID)} + if imdbID != "" { + providers = append(providers, contentid.ProviderIMDB) + values = append(values, imdbID) + } + if tvdbID > 0 { + providers = append(providers, contentid.ProviderTVDB) + values = append(values, strconv.Itoa(tvdbID)) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO watchlist_title_aliases (title_id, media_type, provider, provider_id) + SELECT $1, $2, p.provider, p.provider_id + FROM unnest($3::text[], $4::text[]) AS p(provider, provider_id) + ON CONFLICT DO NOTHING`, titleID, mediaType, providers, values); err != nil { + return fmt.Errorf("recording watchlist title aliases: %w", err) + } + return keepOwnedIDsLocked(ctx, tx, titleID) +} + +// keepOwnedIDsLocked keeps the title's IMDb and TVDB fields to IDs it owns as +// aliases. An ID another title already holds was skipped as an alias; left in +// the field, library matching and request presence would read it and could +// match the other title's copy. The field falls back to an ID of that +// provider the title does own (the previous one, since aliases accumulate), +// else it is emptied. +func keepOwnedIDsLocked(ctx context.Context, tx pgx.Tx, titleID int64) error { + if _, err := tx.Exec(ctx, ` + UPDATE watchlist_titles t + SET imdb_id = CASE + WHEN t.imdb_id = '' OR EXISTS ( + SELECT 1 FROM watchlist_title_aliases a + WHERE a.title_id = t.id AND a.provider = 'imdb' AND a.provider_id = t.imdb_id) + THEN t.imdb_id + ELSE coalesce(( + SELECT a.provider_id FROM watchlist_title_aliases a + WHERE a.title_id = t.id AND a.provider = 'imdb' + ORDER BY a.provider_id LIMIT 1), '') + END, + tvdb_id = CASE + WHEN t.tvdb_id IS NULL OR EXISTS ( + SELECT 1 FROM watchlist_title_aliases a + WHERE a.title_id = t.id AND a.provider = 'tvdb' AND a.provider_id = t.tvdb_id::text) + THEN t.tvdb_id + ELSE ( + SELECT a.provider_id::int FROM watchlist_title_aliases a + WHERE a.title_id = t.id AND a.provider = 'tvdb' + ORDER BY a.provider_id LIMIT 1) + END + WHERE t.id = $1`, titleID); err != nil { + return fmt.Errorf("keeping watchlist title ids to its aliases: %w", err) + } + return nil +} + +// add attaches an entry for the snapshot's title to the profile, creating the +// title when no title holds its TMDB ID. An existing entry keeps its added_at; +// inserted reports whether this call created the entry. +func (r *titlesRepo) add(ctx context.Context, userID int, profileID string, snap Snapshot, addedAt, now time.Time) (Entry, bool, error) { + for attempt := 0; ; attempt++ { + entry, inserted, raced, err := r.tryAdd(ctx, userID, profileID, snap, addedAt, now) + if err != nil { + return Entry{}, false, err + } + if !raced { + return entry, inserted, nil + } + if attempt+1 >= maxAddAttempts { + return Entry{}, false, fmt.Errorf("adding watchlist title: lost the race to create it %d times", maxAddAttempts) + } + } +} + +func (r *titlesRepo) tryAdd(ctx context.Context, userID int, profileID string, snap Snapshot, addedAt, now time.Time) (entry Entry, inserted, raced bool, err error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return Entry{}, false, false, fmt.Errorf("beginning watchlist title add: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + titleID, found, err := lockTitleByTMDB(ctx, tx, snap.MediaType, snap.TMDBID) + if err != nil { + return Entry{}, false, false, err + } + if found { + // A title no entry references waits for the orphan sweep and nothing + // kept its snapshot current; take the caller's instead. + var orphan bool + if err := tx.QueryRow(ctx, ` + SELECT NOT EXISTS (SELECT 1 FROM user_watchlist_titles WHERE title_id = $1)`, titleID).Scan(&orphan); err != nil { + return Entry{}, false, false, fmt.Errorf("checking watchlist title entries: %w", err) + } + if orphan { + // Held through a former TMDB ID, the snapshot doesn't apply; the + // next list read checks the title instead. + if _, err := tx.Exec(ctx, ` + UPDATE watchlist_titles SET next_check_at = least(next_check_at, $2) WHERE id = $1`, titleID, now); err != nil { + return Entry{}, false, false, fmt.Errorf("scheduling watchlist title check: %w", err) + } + if err := refreshLocked(ctx, tx, titleID, snap, now); err != nil { + return Entry{}, false, false, err + } + } + } + if !found { + idText, err := idgen.NextID() + if err != nil { + return Entry{}, false, false, fmt.Errorf("minting watchlist title id: %w", err) + } + titleID, err = strconv.ParseInt(idText, 10, 64) + if err != nil { + return Entry{}, false, false, fmt.Errorf("parsing watchlist title id: %w", err) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO watchlist_titles (id, media_type, tmdb_id, imdb_id, tvdb_id, title, year, release_date, + poster_path, certification, vote_average, checked_at, next_check_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, nullif($5, 0), $6, nullif($7, 0), $8, $9, $10, round($11::numeric, 1), $12, $13, $12, $12)`, + titleID, snap.MediaType, snap.TMDBID, snap.IMDbID, snap.TVDBID, snap.Title, snap.Year, snap.ReleaseDate, + snap.PosterPath, snap.Certification, snap.VoteAverage, now, nextCheckAfterSuccess(now, snap.ReleaseDate)); err != nil { + return Entry{}, false, false, fmt.Errorf("creating watchlist title: %w", err) + } + // The TMDB alias is the title's identity. If another add created the + // title first, start over and attach to that one. + tag, err := tx.Exec(ctx, ` + INSERT INTO watchlist_title_aliases (title_id, media_type, provider, provider_id) + VALUES ($1, $2, 'tmdb', $3) + ON CONFLICT DO NOTHING`, titleID, snap.MediaType, strconv.Itoa(snap.TMDBID)) + if err != nil { + return Entry{}, false, false, fmt.Errorf("recording watchlist title tmdb alias: %w", err) + } + if tag.RowsAffected() == 0 { + return Entry{}, false, true, nil + } + if err := insertAliases(ctx, tx, titleID, snap.MediaType, snap.TMDBID, snap.IMDbID, snap.TVDBID); err != nil { + return Entry{}, false, false, err + } + } + + var entryAddedAt time.Time + err = tx.QueryRow(ctx, ` + INSERT INTO user_watchlist_titles (user_id, profile_id, title_id, added_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, profile_id, title_id) DO NOTHING + RETURNING added_at`, userID, profileID, titleID, addedAt).Scan(&entryAddedAt) + switch { + case err == nil: + inserted = true + case errors.Is(err, pgx.ErrNoRows): + if err := tx.QueryRow(ctx, ` + SELECT added_at FROM user_watchlist_titles + WHERE user_id = $1 AND profile_id = $2 AND title_id = $3`, userID, profileID, titleID).Scan(&entryAddedAt); err != nil { + return Entry{}, false, false, fmt.Errorf("reading watchlist title entry: %w", err) + } + default: + return Entry{}, false, false, fmt.Errorf("adding watchlist title entry: %w", err) + } + title, err := scanTitle(tx.QueryRow(ctx, `SELECT `+titleColumns+` FROM watchlist_titles t WHERE t.id = $1`, titleID)) + if err != nil { + return Entry{}, false, false, fmt.Errorf("reading watchlist title: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return Entry{}, false, false, fmt.Errorf("committing watchlist title add: %w", err) + } + return Entry{Title: title, AddedAt: entryAddedAt}, inserted, false, nil +} + +// removeByTMDB removes the profile's entry for the title holding the TMDB ID +// (current or former), deleting the title when no entry remains. It returns +// the title as it was, or nil when no title holds the ID. +func (r *titlesRepo) removeByTMDB(ctx context.Context, userID int, profileID, mediaType string, tmdbID int) (*Title, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, false, fmt.Errorf("beginning watchlist title remove: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + titleID, found, err := lockTitleByTMDB(ctx, tx, mediaType, tmdbID) + if err != nil || !found { + return nil, false, err + } + title, err := scanTitle(tx.QueryRow(ctx, `SELECT `+titleColumns+` FROM watchlist_titles t WHERE t.id = $1`, titleID)) + if err != nil { + return nil, false, fmt.Errorf("reading watchlist title: %w", err) + } + removed, err := deleteEntryLocked(ctx, tx, userID, profileID, titleID) + if err != nil { + return nil, false, err + } + if err := tx.Commit(ctx); err != nil { + return nil, false, fmt.Errorf("committing watchlist title remove: %w", err) + } + return &title, removed, nil +} + +// promoteEntry moves the profile's entry on one title to the library +// watchlist. With the title locked, and only while the entry still exists, it +// runs add (the library watchlist write), then deletes the entry and the title +// if it is orphaned. A remove of the same title waits on the lock, so it either +// runs first and nothing is added, or runs after the library entry exists and +// can take it off. moved is true only for the caller whose DELETE removed the +// entry, which is what makes promotion side effects fire once. +// +// add writes through another connection. A node that dies after it rolls the +// delete back, so the entry stays and the next read finishes the move. +func (r *titlesRepo) promoteEntry(ctx context.Context, userID int, profileID string, titleID int64, add func(context.Context) error) (bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("beginning watchlist title promotion: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + locked, err := lockTitleByID(ctx, tx, titleID) + if err != nil || !locked { + return false, err + } + var present bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM user_watchlist_titles + WHERE user_id = $1 AND profile_id = $2 AND title_id = $3)`, userID, profileID, titleID).Scan(&present); err != nil { + return false, fmt.Errorf("reading promoted watchlist title entry: %w", err) + } + if !present { + return false, nil + } + if err := add(ctx); err != nil { + return false, err + } + removed, err := deleteEntryLocked(ctx, tx, userID, profileID, titleID) + if err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("committing watchlist title promotion: %w", err) + } + return removed, nil +} + +// entryExists reports whether the profile still has an entry on the title. +func (r *titlesRepo) entryExists(ctx context.Context, userID int, profileID string, titleID int64) (bool, error) { + var present bool + if err := r.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM user_watchlist_titles + WHERE user_id = $1 AND profile_id = $2 AND title_id = $3)`, userID, profileID, titleID).Scan(&present); err != nil { + return false, fmt.Errorf("reading watchlist title entry: %w", err) + } + return present, nil +} + +// deleteEntryLocked runs with the title row already locked. +func deleteEntryLocked(ctx context.Context, tx pgx.Tx, userID int, profileID string, titleID int64) (bool, error) { + tag, err := tx.Exec(ctx, ` + DELETE FROM user_watchlist_titles + WHERE user_id = $1 AND profile_id = $2 AND title_id = $3`, userID, profileID, titleID) + if err != nil { + return false, fmt.Errorf("deleting watchlist title entry: %w", err) + } + if err := deleteOrphanLocked(ctx, tx, titleID); err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +func deleteOrphanLocked(ctx context.Context, tx pgx.Tx, titleID int64) error { + if _, err := tx.Exec(ctx, ` + DELETE FROM watchlist_titles t + WHERE t.id = $1 + AND NOT EXISTS (SELECT 1 FROM user_watchlist_titles e WHERE e.title_id = t.id)`, titleID); err != nil { + return fmt.Errorf("deleting orphaned watchlist title: %w", err) + } + return nil +} + +// purgeProfile removes every entry of a deleted profile and the titles left +// without entries, locking the titles in id order first. +func (r *titlesRepo) purgeProfile(ctx context.Context, userID int, profileID string) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning watchlist title purge: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + rows, err := tx.Query(ctx, ` + SELECT t.id FROM watchlist_titles t + WHERE t.id IN (SELECT title_id FROM user_watchlist_titles WHERE user_id = $1 AND profile_id = $2) + ORDER BY t.id + FOR UPDATE`, userID, profileID) + if err != nil { + return fmt.Errorf("locking purged watchlist titles: %w", err) + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[int64]) + if err != nil { + return fmt.Errorf("locking purged watchlist titles: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM user_watchlist_titles WHERE user_id = $1 AND profile_id = $2`, userID, profileID); err != nil { + return fmt.Errorf("purging watchlist title entries: %w", err) + } + if len(ids) > 0 { + if _, err := tx.Exec(ctx, ` + DELETE FROM watchlist_titles t + WHERE t.id = ANY($1) + AND NOT EXISTS (SELECT 1 FROM user_watchlist_titles e WHERE e.title_id = t.id)`, ids); err != nil { + return fmt.Errorf("deleting orphaned watchlist titles: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing watchlist title purge: %w", err) + } + return r.sweepOrphans(ctx) +} + +// orphanSweepLimit bounds one sweep; the next profile or user delete +// continues it. +const orphanSweepLimit = 1000 + +// sweepOrphans deletes titles no entry references. Entries deleted without +// the title lock leave these behind: the Postgres user store's profile +// delete, and user deletion through the users foreign key. Reads skip such +// titles until then, and an add that attaches to one refreshes its snapshot. Titles an add +// holds are skipped, and the orphan check runs again after the locks are +// taken, so an entry committed meanwhile keeps its title. +func (r *titlesRepo) sweepOrphans(ctx context.Context) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning watchlist title sweep: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + rows, err := tx.Query(ctx, ` + SELECT t.id FROM watchlist_titles t + WHERE NOT EXISTS (SELECT 1 FROM user_watchlist_titles e WHERE e.title_id = t.id) + ORDER BY t.id + LIMIT $1 + FOR UPDATE SKIP LOCKED`, orphanSweepLimit) + if err != nil { + return fmt.Errorf("locking orphaned watchlist titles: %w", err) + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[int64]) + if err != nil { + return fmt.Errorf("locking orphaned watchlist titles: %w", err) + } + if len(ids) == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` + DELETE FROM watchlist_titles t + WHERE t.id = ANY($1) + AND NOT EXISTS (SELECT 1 FROM user_watchlist_titles e WHERE e.title_id = t.id)`, ids); err != nil { + return fmt.Errorf("deleting orphaned watchlist titles: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing watchlist title sweep: %w", err) + } + return nil +} + +// listPage returns the profile's entries newest first, after the keyset +// position when one is given. +func (r *titlesRepo) listPage(ctx context.Context, userID int, profileID string, after *PageKey, limit int) ([]Entry, error) { + var afterAt *time.Time + var afterID int64 + if after != nil { + afterAt, afterID = &after.AddedAt, after.TitleID + } + rows, err := r.pool.Query(ctx, ` + SELECT `+titleColumns+`, e.added_at + FROM user_watchlist_titles e + JOIN watchlist_titles t ON t.id = e.title_id + WHERE e.user_id = $1 AND e.profile_id = $2 + AND ($3::timestamptz IS NULL OR (e.added_at, e.title_id) < ($3::timestamptz, $4::bigint)) + ORDER BY e.added_at DESC, e.title_id DESC + LIMIT $5`, userID, profileID, afterAt, afterID, limit) + if err != nil { + return nil, fmt.Errorf("listing watchlist titles: %w", err) + } + defer rows.Close() + var out []Entry + for rows.Next() { + var e Entry + title, err := scanTitle(rows, &e.AddedAt) + if err != nil { + return nil, fmt.Errorf("scanning watchlist title: %w", err) + } + e.Title = title + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("listing watchlist titles: %w", err) + } + return out, nil +} + +// onWatchlist reports which of the keys the profile has an entry for, through +// any TMDB ID the entry's title has held. +func (r *titlesRepo) onWatchlist(ctx context.Context, userID int, profileID string, keys []TitleKey) (map[TitleKey]bool, error) { + out := make(map[TitleKey]bool) + if len(keys) == 0 { + return out, nil + } + mediaTypes := make([]string, len(keys)) + tmdbIDs := make([]string, len(keys)) + for i, k := range keys { + mediaTypes[i], tmdbIDs[i] = k.MediaType, strconv.Itoa(k.TMDBID) + } + rows, err := r.pool.Query(ctx, ` + SELECT DISTINCT a.media_type, a.provider_id + FROM unnest($3::text[], $4::text[]) AS k(media_type, tmdb_id) + JOIN watchlist_title_aliases a + ON a.media_type = k.media_type AND a.provider = 'tmdb' AND a.provider_id = k.tmdb_id + JOIN user_watchlist_titles e + ON e.title_id = a.title_id AND e.user_id = $1 AND e.profile_id = $2`, + userID, profileID, mediaTypes, tmdbIDs) + if err != nil { + return nil, fmt.Errorf("checking watchlist titles: %w", err) + } + defer rows.Close() + for rows.Next() { + var mediaType, tmdbID string + if err := rows.Scan(&mediaType, &tmdbID); err != nil { + return nil, fmt.Errorf("scanning watchlist title check: %w", err) + } + id, err := strconv.Atoi(tmdbID) + if err != nil { + continue + } + out[TitleKey{MediaType: mediaType, TMDBID: id}] = true + } + return out, rows.Err() +} + +// titleAlias is one alias row. +type titleAlias struct { + TitleID int64 + MediaType string + Provider string + ProviderID string +} + +func collectAliases(rows pgx.Rows, err error) ([]titleAlias, error) { + if err != nil { + return nil, err + } + defer rows.Close() + var out []titleAlias + for rows.Next() { + var a titleAlias + if err := rows.Scan(&a.TitleID, &a.MediaType, &a.Provider, &a.ProviderID); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// profileEntries returns every entry of the profile with its title's aliases. +// A profile with no entries costs one index probe. +func (r *titlesRepo) profileEntries(ctx context.Context, userID int, profileID string) (map[int64]time.Time, []titleAlias, error) { + rows, err := r.pool.Query(ctx, ` + SELECT e.title_id, e.added_at, a.media_type, a.provider, a.provider_id + FROM user_watchlist_titles e + JOIN watchlist_title_aliases a ON a.title_id = e.title_id + WHERE e.user_id = $1 AND e.profile_id = $2`, userID, profileID) + if err != nil { + return nil, nil, fmt.Errorf("reading watchlist title entries: %w", err) + } + defer rows.Close() + addedAt := make(map[int64]time.Time) + var aliases []titleAlias + for rows.Next() { + var a titleAlias + var at time.Time + if err := rows.Scan(&a.TitleID, &at, &a.MediaType, &a.Provider, &a.ProviderID); err != nil { + return nil, nil, fmt.Errorf("scanning watchlist title entry: %w", err) + } + addedAt[a.TitleID] = at + aliases = append(aliases, a) + } + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("reading watchlist title entries: %w", err) + } + return addedAt, aliases, nil +} + +// profileEntriesMatching returns the profile's entries whose title holds any +// of the given provider IDs, with all of those titles' aliases. +func (r *titlesRepo) profileEntriesMatching(ctx context.Context, userID int, profileID, mediaType string, providers, providerIDs []string) (map[int64]time.Time, []titleAlias, error) { + rows, err := r.pool.Query(ctx, ` + WITH matched AS ( + SELECT DISTINCT e.title_id, e.added_at + FROM unnest($4::text[], $5::text[]) AS v(provider, provider_id) + JOIN watchlist_title_aliases a + ON a.media_type = $3 AND a.provider = v.provider AND a.provider_id = v.provider_id + JOIN user_watchlist_titles e + ON e.title_id = a.title_id AND e.user_id = $1 AND e.profile_id = $2 + ) + SELECT m.title_id, m.added_at, a.media_type, a.provider, a.provider_id + FROM matched m + JOIN watchlist_title_aliases a ON a.title_id = m.title_id`, + userID, profileID, mediaType, providers, providerIDs) + if err != nil { + return nil, nil, fmt.Errorf("matching watchlist title entries: %w", err) + } + defer rows.Close() + addedAt := make(map[int64]time.Time) + var aliases []titleAlias + for rows.Next() { + var a titleAlias + var at time.Time + if err := rows.Scan(&a.TitleID, &at, &a.MediaType, &a.Provider, &a.ProviderID); err != nil { + return nil, nil, fmt.Errorf("scanning watchlist title entry: %w", err) + } + addedAt[a.TitleID] = at + aliases = append(aliases, a) + } + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("matching watchlist title entries: %w", err) + } + return addedAt, aliases, nil +} + +func (r *titlesRepo) aliasesOf(ctx context.Context, titleID int64) ([]titleAlias, error) { + aliases, err := collectAliases(r.pool.Query(ctx, ` + SELECT title_id, media_type, provider, provider_id + FROM watchlist_title_aliases + WHERE title_id = $1 + ORDER BY provider, provider_id`, titleID)) + if err != nil { + return nil, fmt.Errorf("reading watchlist title aliases: %w", err) + } + return aliases, nil +} + +// aliasOwner returns the title holding a provider ID, or 0. +func (r *titlesRepo) aliasOwner(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, mediaType, provider, providerID string) (int64, error) { + var owner int64 + err := q.QueryRow(ctx, ` + SELECT title_id FROM watchlist_title_aliases + WHERE media_type = $1 AND provider = $2 AND provider_id = $3`, mediaType, provider, providerID).Scan(&owner) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("reading watchlist title alias owner: %w", err) + } + return owner, nil +} + +// claimDue claims overdue titles for a check by pushing next_check_at out by +// the claim lease. Only one caller wins each title; a claim whose checker +// died expires with the lease and a later read retries. +func (r *titlesRepo) claimDue(ctx context.Context, ids []int64, now time.Time, lease time.Duration) ([]int64, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := r.pool.Query(ctx, ` + UPDATE watchlist_titles + SET next_check_at = $2::timestamptz + $3::interval + WHERE id = ANY($1) AND next_check_at <= $2::timestamptz + RETURNING id`, ids, now, lease) + if err != nil { + return nil, fmt.Errorf("claiming watchlist title checks: %w", err) + } + claimed, err := pgx.CollectRows(rows, pgx.RowTo[int64]) + if err != nil { + return nil, fmt.Errorf("claiming watchlist title checks: %w", err) + } + return claimed, nil +} + +// applyDetail stores a successful check or observation: the current IDs, the +// display snapshot, an active state and the next check time. New IDs become +// aliases unless another title holds them. +func (r *titlesRepo) applyDetail(ctx context.Context, titleID int64, snap Snapshot, now time.Time) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning watchlist title refresh: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := refreshLocked(ctx, tx, titleID, snap, now); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing watchlist title refresh: %w", err) + } + return nil +} + +// refreshLocked stores a TMDB detail's snapshot on the title if it still has +// the snapshot's TMDB ID, and records its new IDs as aliases. +func refreshLocked(ctx context.Context, tx pgx.Tx, titleID int64, snap Snapshot, now time.Time) error { + var mediaType string + err := tx.QueryRow(ctx, ` + UPDATE watchlist_titles + SET imdb_id = $2, tvdb_id = nullif($3, 0), title = $4, year = nullif($5, 0), release_date = $6, + poster_path = $7, certification = $8, vote_average = round($9::numeric, 1), + state = 'active', not_found_count = 0, last_not_found_at = NULL, + checked_at = $10, next_check_at = $11, updated_at = $10 + WHERE id = $1 AND tmdb_id = $12 + RETURNING media_type`, + titleID, snap.IMDbID, snap.TVDBID, snap.Title, snap.Year, snap.ReleaseDate, snap.PosterPath, + snap.Certification, snap.VoteAverage, now, nextCheckAfterSuccess(now, snap.ReleaseDate), snap.TMDBID).Scan(&mediaType) + if errors.Is(err, pgx.ErrNoRows) { + // The title was repointed or merged away since it was read. + return nil + } + if err != nil { + return fmt.Errorf("refreshing watchlist title: %w", err) + } + return insertAliases(ctx, tx, titleID, mediaType, snap.TMDBID, snap.IMDbID, snap.TVDBID) +} + +// recordNotFound stores a TMDB 404 for the title's current ID. +func (r *titlesRepo) recordNotFound(ctx context.Context, titleID int64, tmdbID int, count int, at, nextCheck time.Time) error { + if _, err := r.pool.Exec(ctx, ` + UPDATE watchlist_titles + SET not_found_count = $3, last_not_found_at = $4, next_check_at = $5, updated_at = $4 + WHERE id = $1 AND tmdb_id = $2`, titleID, tmdbID, count, at, nextCheck); err != nil { + return fmt.Errorf("recording watchlist title not found: %w", err) + } + return nil +} + +// setState records a recovery outcome that needs the user (needs_review) or +// found nothing (removed), with the confirmed 404. +func (r *titlesRepo) setState(ctx context.Context, titleID int64, tmdbID int, state TitleState, notFoundCount int, now, nextCheck time.Time) error { + if _, err := r.pool.Exec(ctx, ` + UPDATE watchlist_titles + SET state = $3, not_found_count = $4, last_not_found_at = $5, checked_at = $5, next_check_at = $6, updated_at = $5 + WHERE id = $1 AND tmdb_id = $2`, titleID, tmdbID, string(state), notFoundCount, now, nextCheck); err != nil { + return fmt.Errorf("setting watchlist title state: %w", err) + } + return nil +} + +// backoff pushes the next check out after a failure that says nothing about +// the title (TMDB unreachable), leaving its state alone. +func (r *titlesRepo) backoff(ctx context.Context, titleID int64, nextCheck time.Time) error { + if _, err := r.pool.Exec(ctx, `UPDATE watchlist_titles SET next_check_at = $2 WHERE id = $1`, titleID, nextCheck); err != nil { + return fmt.Errorf("backing off watchlist title check: %w", err) + } + return nil +} + +// repoint moves a title whose TMDB ID was deleted to the ID recovery found, +// keeping the old ID as an alias. When another title already holds the new ID +// the dead title is merged into it instead. It returns the surviving title. +func (r *titlesRepo) repoint(ctx context.Context, titleID int64, oldTMDBID, newTMDBID int, now time.Time) (int64, error) { + title, err := r.titleByID(ctx, titleID) + if err != nil || title == nil { + return 0, err + } + newAlias := strconv.Itoa(newTMDBID) + for attempt := 0; attempt < maxAddAttempts; attempt++ { + owner, err := r.aliasOwner(ctx, r.pool, title.MediaType, contentid.ProviderTMDB, newAlias) + if err != nil { + return 0, err + } + if owner != 0 && owner != titleID { + merged, retry, err := r.merge(ctx, titleID, owner, title.MediaType, newAlias) + if err != nil || !retry { + return merged, err + } + continue + } + done, err := r.tryRepoint(ctx, titleID, title.MediaType, oldTMDBID, newTMDBID, now) + if err != nil { + return 0, err + } + if done { + return titleID, nil + } + } + return 0, fmt.Errorf("repointing watchlist title: alias kept changing") +} + +func (r *titlesRepo) tryRepoint(ctx context.Context, titleID int64, mediaType string, oldTMDBID, newTMDBID int, now time.Time) (bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("beginning watchlist title repoint: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if locked, err := lockTitleByID(ctx, tx, titleID); err != nil || !locked { + return true, err + } + tag, err := tx.Exec(ctx, ` + INSERT INTO watchlist_title_aliases (title_id, media_type, provider, provider_id) + VALUES ($1, $2, 'tmdb', $3) + ON CONFLICT (media_type, provider, provider_id) DO UPDATE SET title_id = watchlist_title_aliases.title_id + WHERE watchlist_title_aliases.title_id = $1`, titleID, mediaType, strconv.Itoa(newTMDBID)) + if err != nil { + return false, fmt.Errorf("recording repointed tmdb alias: %w", err) + } + if tag.RowsAffected() == 0 { + // Another title took the ID meanwhile; the caller merges. + return false, nil + } + // The next check refreshes the snapshot from the new ID right away. + if _, err := tx.Exec(ctx, ` + UPDATE watchlist_titles + SET tmdb_id = $3, state = 'active', not_found_count = 0, last_not_found_at = NULL, + next_check_at = $4, updated_at = $4 + WHERE id = $1 AND tmdb_id = $2`, titleID, oldTMDBID, newTMDBID, now); err != nil { + return false, fmt.Errorf("repointing watchlist title: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("committing watchlist title repoint: %w", err) + } + return true, nil +} + +// merge folds the loser title into the survivor: entries move keeping the +// earliest added_at, aliases move, and the loser is deleted. Both rows are +// locked in id order. retry is true when the alias that justified the merge +// no longer belongs to the survivor. +func (r *titlesRepo) merge(ctx context.Context, loser, survivor int64, mediaType, sharedTMDBAlias string) (int64, bool, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return 0, false, fmt.Errorf("beginning watchlist title merge: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + rows, err := tx.Query(ctx, `SELECT id FROM watchlist_titles WHERE id = ANY($1) ORDER BY id FOR UPDATE`, []int64{loser, survivor}) + if err != nil { + return 0, false, fmt.Errorf("locking merged watchlist titles: %w", err) + } + locked, err := pgx.CollectRows(rows, pgx.RowTo[int64]) + if err != nil { + return 0, false, fmt.Errorf("locking merged watchlist titles: %w", err) + } + if len(locked) != 2 { + // One side is gone. A vanished loser needs nothing more; a vanished + // survivor means the alias is free again. + return survivor, slicesContains(locked, loser), nil + } + owner, err := r.aliasOwner(ctx, tx, mediaType, contentid.ProviderTMDB, sharedTMDBAlias) + if err != nil { + return 0, false, err + } + if owner != survivor { + return 0, true, nil + } + if _, err := tx.Exec(ctx, ` + INSERT INTO user_watchlist_titles (user_id, profile_id, title_id, added_at) + SELECT user_id, profile_id, $2, added_at FROM user_watchlist_titles WHERE title_id = $1 + ON CONFLICT (user_id, profile_id, title_id) + DO UPDATE SET added_at = LEAST(user_watchlist_titles.added_at, EXCLUDED.added_at)`, loser, survivor); err != nil { + return 0, false, fmt.Errorf("moving merged watchlist title entries: %w", err) + } + if _, err := tx.Exec(ctx, `UPDATE watchlist_title_aliases SET title_id = $2 WHERE title_id = $1`, loser, survivor); err != nil { + return 0, false, fmt.Errorf("moving merged watchlist title aliases: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM watchlist_titles WHERE id = $1`, loser); err != nil { + return 0, false, fmt.Errorf("deleting merged watchlist title: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return 0, false, fmt.Errorf("committing watchlist title merge: %w", err) + } + return survivor, false, nil +} + +func slicesContains(ids []int64, id int64) bool { + for _, v := range ids { + if v == id { + return true + } + } + return false +} diff --git a/migrations/sql/20260930012209_add_watchlist_titles.sql b/migrations/sql/20260930012209_add_watchlist_titles.sql new file mode 100644 index 0000000000..8bb9430836 --- /dev/null +++ b/migrations/sql/20260930012209_add_watchlist_titles.sql @@ -0,0 +1,63 @@ +-- +goose Up +-- +goose StatementBegin +-- A movie or series some profile has watchlisted before the server had it. +-- One row per title, shared by every profile that added it, so the title's +-- provider IDs are checked once for all of them. No column stores or is named +-- like a catalog content ID: re-anchoring, merging and deleting catalog items +-- never reach these tables. The id is minted by idgen and never exposed. +CREATE TABLE public.watchlist_titles ( + id bigint PRIMARY KEY, + media_type text NOT NULL CHECK (media_type IN ('movie', 'series')), + tmdb_id integer NOT NULL CHECK (tmdb_id > 0), + imdb_id text NOT NULL DEFAULT '', + tvdb_id integer, + title text NOT NULL, + year integer, + release_date date, + poster_path text NOT NULL DEFAULT '', + -- US certification, the one Discover's rating ceiling filters on. + certification text NOT NULL DEFAULT '', + vote_average numeric(3,1), + state text NOT NULL DEFAULT 'active' + CHECK (state IN ('active', 'needs_review', 'removed')), + -- Consecutive TMDB 404s; two at least a day apart confirm a deletion. + not_found_count integer NOT NULL DEFAULT 0, + last_not_found_at timestamptz, + checked_at timestamptz, + next_check_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Every provider ID a title has had. The key is how a duplicate is +-- recognized: two titles can never hold the same ID. +CREATE TABLE public.watchlist_title_aliases ( + title_id bigint NOT NULL REFERENCES public.watchlist_titles(id) ON DELETE CASCADE, + media_type text NOT NULL, + provider text NOT NULL CHECK (provider IN ('tmdb', 'imdb', 'tvdb')), + provider_id text NOT NULL, + PRIMARY KEY (media_type, provider, provider_id) +); +CREATE INDEX idx_watchlist_title_aliases_title ON public.watchlist_title_aliases (title_id); + +-- A profile's entry. Profiles may live in the SQLite user store, so there is +-- no profile foreign key; deleting a profile removes its rows explicitly, as +-- for user_dropped_series. +CREATE TABLE public.user_watchlist_titles ( + user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + profile_id text NOT NULL, + title_id bigint NOT NULL REFERENCES public.watchlist_titles(id) ON DELETE CASCADE, + added_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, profile_id, title_id) +); +CREATE INDEX idx_user_watchlist_titles_page + ON public.user_watchlist_titles (user_id, profile_id, added_at DESC, title_id DESC); +CREATE INDEX idx_user_watchlist_titles_title ON public.user_watchlist_titles (title_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS public.user_watchlist_titles; +DROP TABLE IF EXISTS public.watchlist_title_aliases; +DROP TABLE IF EXISTS public.watchlist_titles; +-- +goose StatementEnd diff --git a/migrations/sql/20260930012210_add_stale_media_ids_provider_value_index.sql b/migrations/sql/20260930012210_add_stale_media_ids_provider_value_index.sql new file mode 100644 index 0000000000..82832c6ac7 --- /dev/null +++ b/migrations/sql/20260930012210_add_stale_media_ids_provider_value_index.sql @@ -0,0 +1,33 @@ +-- +goose NO TRANSACTION + +-- +goose Up +-- Watchlisted titles resolve against the provider IDs a library item has +-- rejected as well as its current ones, so a title TMDB deleted as a duplicate +-- still finds the item that once carried it. The primary key leads with +-- content_id and cannot serve a lookup by value. Build concurrently: the +-- metadata refresh path writes this table constantly. +-- Remove an INVALID remnant first: IF NOT EXISTS otherwise accepts a failed +-- concurrent build and Goose would record an index that is never used. +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_index i ON i.indexrelid = c.oid + WHERE n.nspname = 'public' + AND c.relname = 'idx_stale_media_ids_provider_value' + AND NOT i.indisvalid + ) THEN + DROP INDEX public.idx_stale_media_ids_provider_value; + END IF; +END; +$$; +-- +goose StatementEnd + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_stale_media_ids_provider_value +ON public.stale_media_ids (provider, provider_id); + +-- +goose Down +DROP INDEX CONCURRENTLY IF EXISTS public.idx_stale_media_ids_provider_value; diff --git a/migrations/sql/20260930015040_add_watchlist_requests_and_request_source.sql b/migrations/sql/20260930015040_add_watchlist_requests_and_request_source.sql new file mode 100644 index 0000000000..1b72951be4 --- /dev/null +++ b/migrations/sql/20260930015040_add_watchlist_requests_and_request_source.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- +goose StatementBegin +-- Whether adding a title that is not in the library to a watchlist may also +-- request it. A profile can opt out on its own through the +-- requests.watchlist_auto_request setting; this is the server-wide switch. +ALTER TABLE public.request_settings + ADD COLUMN IF NOT EXISTS watchlist_requests boolean NOT NULL DEFAULT true; + +-- What created a request: the Request button ('direct') or a watchlist add +-- ('watchlist'). Removing the title from the watchlist cancels only a request +-- the watchlist created, and the admin queue labels those requests. +ALTER TABLE public.media_requests + ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'direct' + CONSTRAINT media_requests_source_check CHECK (source IN ('direct', 'watchlist')); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.media_requests DROP COLUMN IF EXISTS source; +ALTER TABLE public.request_settings DROP COLUMN IF EXISTS watchlist_requests; +-- +goose StatementEnd diff --git a/web/perf-budget.json b/web/perf-budget.json index 5df0715a86..204292e355 100644 --- a/web/perf-budget.json +++ b/web/perf-budget.json @@ -1,4 +1,4 @@ { - "eagerBrotliBytes": 317516, + "eagerBrotliBytes": 318833, "crossOriginRenderBlocking": 0 } diff --git a/web/src/App.tsx b/web/src/App.tsx index 94750d639a..7405413b6a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -138,6 +138,7 @@ const TasteSeed = lazy(() => import("@/pages/TasteSeed")); const AccessibilitySettings = lazy(() => import("@/pages/settings/AccessibilitySettings")); const ProfilesSettings = lazy(() => import("@/pages/settings/ProfilesSettings")); const LibrarySettings = lazy(() => import("@/pages/settings/LibrarySettings")); +const RequestsSettings = lazy(() => import("@/pages/settings/RequestsSettings")); const HistoryImportSettings = lazy(() => import("@/pages/settings/HistoryImportSettings")); const WebhookSyncSettings = lazy(() => import("@/pages/settings/WebhookSyncSettings")); const WatchProvidersSettings = lazy(() => import("@/pages/settings/WatchProvidersSettings")); @@ -593,6 +594,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8d0390b2c3..c5966ac0bf 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1886,6 +1886,12 @@ export interface RequestMediaResult { availability: RequestAvailability; library_content_id?: string; request: RequestState; + /** + * The title is on the viewer's watchlist, as an entry for a title the + * library doesn't have or as its library item. Absent from servers without + * watchlist titles. + */ + in_watchlist?: boolean; } export interface RequestMediaPage { @@ -1937,6 +1943,8 @@ export interface RequestMediaDetail { availability: RequestAvailability; library_content_id?: string; request: RequestState; + /** The title is on the viewer's watchlist; see RequestMediaResult.in_watchlist. */ + in_watchlist?: boolean; } /** One regular season of a series, as the request detail reports it. */ @@ -2081,6 +2089,11 @@ export interface MediaRequest { external_status?: string; library_content_id?: string; last_error?: string; + /** + * What created the request: direct (the Request button) or watchlist + * (adding the title to a watchlist). The server may add values. + */ + source?: string; created_at: string; updated_at: string; approved_at?: string; @@ -2103,6 +2116,12 @@ export interface RequestSettings { global_window_days: number; global_auto_approval_enabled: boolean; force_dual_quality: boolean; + /** + * Adding a title the library doesn't have to a watchlist also requests it. + * Absent from servers that predate it; left out of an update, the stored + * value is kept. + */ + watchlist_requests?: boolean; updated_at: string; } diff --git a/web/src/api/v2/adminRequests.ts b/web/src/api/v2/adminRequests.ts index 96836a1fb0..d9865b316b 100644 --- a/web/src/api/v2/adminRequests.ts +++ b/web/src/api/v2/adminRequests.ts @@ -68,6 +68,7 @@ export async function putAdminRequestSettingsV2( global_window_days, global_auto_approval_enabled, force_dual_quality, + watchlist_requests, } = settings; let etag = ""; const body = await v2("PUT /api/v2/admin/request-settings", { @@ -78,6 +79,8 @@ export async function putAdminRequestSettingsV2( global_window_days, global_auto_approval_enabled, force_dual_quality, + // Sent only when read: a server without the setting keeps its own. + ...(watchlist_requests !== undefined && { watchlist_requests }), }, onResponse: (r) => { etag = r.headers.get("ETag") ?? ""; diff --git a/web/src/api/v2/operations.ts b/web/src/api/v2/operations.ts index 03671e9bb1..643c0462d0 100644 --- a/web/src/api/v2/operations.ts +++ b/web/src/api/v2/operations.ts @@ -80,6 +80,7 @@ export const v2Operations = { "DELETE /api/v2/watch-together/rooms/{room_id}/suggestions/{suggestion_id}/vote": "unvoteWatchTogetherSuggestion", "DELETE /api/v2/watched/{id}": "unmarkWatched", + "DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id}": "deleteWatchlistTitle", "DELETE /api/v2/watchlist/{item_id}": "deleteWatchlistEntry", "DELETE /api/v2/webhook-sync/connections/{id}": "deleteWebhookConnection", "GET /api/v2/account/me": "getCurrentUser", @@ -453,6 +454,7 @@ export const v2Operations = { "GET /api/v2/watch-together/rooms/{room_id}/ws": "connectWatchTogetherSocket", "GET /api/v2/watch/{id}": "getWatchState", "GET /api/v2/watchlist": "listWatchlist", + "GET /api/v2/watchlist/titles": "listWatchlistTitles", "GET /api/v2/watchlist/{item_id}": "getWatchlistEntry", "GET /api/v2/webhook-sync/capabilities": "getWebhookReceiverCapabilities", "GET /api/v2/webhook-sync/connections": "listWebhookConnections", @@ -803,6 +805,7 @@ export const v2Operations = { "PUT /api/v2/subtitle-prefs/{series_id}": "updateSubtitlePreference", "PUT /api/v2/watch-together/rooms/{room_id}/selection": "selectWatchTogetherRoomItem", "PUT /api/v2/watch-together/rooms/{room_id}/staged-selection": "stageWatchTogetherRoomItem", + "PUT /api/v2/watchlist/titles/{media_type}/{tmdb_id}": "addWatchlistTitle", "PUT /api/v2/watchlist/{item_id}": "addToWatchlist", "PUT /api/v2/webhook-sync/connections/{id}": "updateWebhookConnection", "PUT /api/v2/webhook-sync/connections/{id}/profile-mappings": "updateWebhookMappings", diff --git a/web/src/api/v2/requests.test.ts b/web/src/api/v2/requests.test.ts index fc5ccd25cb..bb602a28e1 100644 --- a/web/src/api/v2/requests.test.ts +++ b/web/src/api/v2/requests.test.ts @@ -43,6 +43,7 @@ describe("request v2 adapters keep download progress", () => { tmdb_id: 949, title: "Heat", availability: "missing", + in_watchlist: false, cast: [], creators: [], genres: [], diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 0a2656e2d1..15f7423fd7 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -10574,6 +10574,41 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/watchlist/titles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the acting profile's watchlist entries for titles the library doesn't have, newest first. Entries whose title reached the library move to the library watchlist first; titles above the viewer's rating ceiling are omitted. */ + get: operations["listWatchlistTitles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/watchlist/titles/{media_type}/{tmdb_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Add a title to the acting profile's watchlist. A title the library has goes onto the library watchlist; otherwise it is kept by TMDB ID and, when watchlist requests apply, requested or followed. Automatic retries are unsafe because provider, refresh and request effects are not change-gated. */ + put: operations["addWatchlistTitle"]; + post?: never; + /** Remove a title from the acting profile's watchlist by its current or a former TMDB ID, from the library watchlist too, and withdraw the request the watchlist made for it while nothing has been sent. An absent entry succeeds, but automatic retries can repeat provider and refresh effects. */ + delete: operations["deleteWatchlistTitle"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/webhook-sync/capabilities": { parameters: { query?: never; @@ -15070,6 +15105,11 @@ export interface components { /** Format: int64 */ global_window_days: number; requests_enabled: boolean; + /** + * @description Adding a title that is not in the library to a watchlist also requests it; absent on update keeps the stored value + * @example true + */ + watchlist_requests?: boolean; }; AdminRequestStandardDestination: { /** @description The media type's one server that is not marked 4K; absent when it has none */ @@ -19556,7 +19596,7 @@ export interface components { FeatureStatus: { /** @description Whether the current principal may use the capability */ allowed: boolean; - /** @description Whether the server reports download progress (download on requests, their targets, and the title detail's request state). Whether a given request has any depends on its download server's request plugin. */ + /** @description Whether the server reports download progress (download on requests, their targets, the title detail's request state, and watchlist titles). Whether a given request has any depends on its download server's request plugin. */ download_progress_supported: boolean; follow_supported: boolean; /** @description Whether a series already in the library can be requested for the seasons it is missing. False while a download server that takes series uses a request plugin that cannot fetch individual seasons, so such a series stays already_available. */ @@ -19571,6 +19611,10 @@ export interface components { * @enum {string} */ state: "available" | "disabled" | "not_configured" | "unsupported"; + /** @description Whether adding a title the library doesn't have to the watchlist also requests it (or follows its request) for this viewer: requests and watchlist requests are on for the server, the profile has not opted out, and the viewer may request. */ + watchlist_requests: boolean; + /** @description Whether the server keeps watchlist entries for titles the library doesn't have: the /watchlist/titles operations, and in_watchlist on discovery results and the title detail. False while requests are disabled, when those operations answer 409 capability_disabled; the entries are kept. */ + watchlist_titles_supported: boolean; }; FileMarkers: { credits: components["schemas"]["MarkerSegment"]; @@ -21261,6 +21305,11 @@ export interface components { season_progress: components["schemas"]["RequestSeasonProgress"][]; /** @description Series: the requested season numbers; empty means the whole series (requests made through v1 or before season requests) */ seasons: number[]; + /** + * @description What created the request: direct (the Request button or an API create) or watchlist (adding a title that is not in the library to a watchlist); more values may be added + * @example direct + */ + source: string; /** * @description The one state to show a user: pending, approved, processing, partially_available (some requested seasons are in the library), available (in the library), declined, cancelled or failed * @example pending @@ -24476,6 +24525,11 @@ export interface components { homepage?: string; /** @example tt0113277 */ imdb_id?: string; + /** + * @description Whether the title is on the viewing profile's watchlist, as a watchlist title or through its catalog item + * @example false + */ + in_watchlist: boolean; /** @description Calendar date, YYYY-MM-DD */ last_air_date?: string; /** @description The catalog item when the media is available */ @@ -24566,6 +24620,11 @@ export interface components { availability: string; /** @description TMDB image path */ backdrop_path?: string; + /** + * @description Whether the title is on the viewing profile's watchlist, as a watchlist title or through its catalog item + * @example false + */ + in_watchlist: boolean; /** * @description The catalog item when the media is available * @example movie:heat-1995 @@ -24637,7 +24696,7 @@ export interface components { season_number: number; }; RequestMediaState: { - /** @description How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) carries it */ + /** @description How far the active request's downloads are, while its download server reports them. Only the title detail (getRequestMediaDetail) and the watchlist titles (listWatchlistTitles) carry it */ download?: components["schemas"]["RequestDownload"]; /** * @description Whether the viewer will be notified when the media becomes available: they requested it or follow it @@ -26787,6 +26846,92 @@ export interface components { */ item_id: string; }; + WatchlistTitle: { + /** + * Format: date-time + * @description When the title joined the watchlist + * @example 2026-01-02T03:04:05.000Z + */ + added_at: string; + /** + * @description US certification + * @example R + */ + content_rating?: string; + /** + * @description movie or series + * @example movie + */ + media_type: string; + /** + * @description TMDB image path + * @example /abc.jpg + */ + poster_path?: string; + /** + * @description Calendar date, YYYY-MM-DD: the release date of a movie, the first air date of a series + * @example 1995-12-15 + */ + release_date?: string; + /** @description The title's request state for the viewer, with download progress while it downloads */ + request: components["schemas"]["RequestMediaState"]; + /** + * @description active, needs_review (TMDB deleted the ID and several titles could replace it) or removed (TMDB deleted the ID and nothing replaces it). More values may be added: read an unknown one as active + * @example active + */ + status: string; + /** @example Heat */ + title: string; + /** + * Format: int64 + * @description The title's current TMDB identifier (external, not a Silo ID) + * @example 949 + */ + tmdb_id: number; + /** + * Format: double + * @description TMDB rating out of 10; absent while the title has no votes + * @example 7.9 + */ + vote_average?: number; + /** + * Format: int64 + * @example 1995 + */ + year?: number; + }; + WatchlistTitleCollection: { + /** @description The page's items; empty, never null */ + items: components["schemas"]["WatchlistTitle"][]; + /** @description Cursor state; absent for bounded unpaginated collections */ + page?: components["schemas"]["PageInfo"]; + }; + WatchlistTitleEntry: { + /** + * Format: date-time + * @description When the title joined the watchlist; adding it again keeps the first time + * @example 2026-01-02T03:04:05.000Z + */ + added_at: string; + /** + * @description The catalog item, when the library has the title and the entry went to the library watchlist + * @example movie:heat-1995 + */ + item_id?: string; + /** + * @description movie or series + * @example movie + */ + media_type: string; + /** @description The title's request state after the add, including why a watchlist request was refused */ + request: components["schemas"]["RequestMediaState"]; + /** + * Format: int64 + * @description The title's current TMDB identifier (external, not a Silo ID) + * @example 949 + */ + tmdb_id: number; + }; WatchMarker: { /** * Format: double @@ -122375,6 +122520,364 @@ export interface operations { }; }; }; + listWatchlistTitles: { + parameters: { + query?: { + /** @description Opaque cursor from page.next_cursor */ + cursor?: string; + /** @description Page size; default 50, maximum 200 */ + limit?: number; + }; + header: { + /** @description The household profile acting for this request; it must belong to the authenticated account. */ + "X-Profile-Id": string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WatchlistTitleCollection"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + addWatchlistTitle: { + parameters: { + query?: never; + header: { + /** @description The household profile acting for this request; it must belong to the authenticated account. */ + "X-Profile-Id": string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description The media type */ + media_type: "movie" | "series"; + /** @description TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it */ + tmdb_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WatchlistTitleEntry"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; + deleteWatchlistTitle: { + parameters: { + query?: never; + header: { + /** @description The household profile acting for this request; it must belong to the authenticated account. */ + "X-Profile-Id": string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description The media type */ + media_type: "movie" | "series"; + /** @description TMDB identifier (external, not a Silo ID). A title's former TMDB ID still names it */ + tmdb_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; getWebhookReceiverCapabilities: { parameters: { query?: never; diff --git a/web/src/api/v2/watchlistTitles.test.ts b/web/src/api/v2/watchlistTitles.test.ts new file mode 100644 index 0000000000..9105a40258 --- /dev/null +++ b/web/src/api/v2/watchlistTitles.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { v2 } from "@/api/v2/request"; +import { listWatchlistTitlesV2 } from "./watchlistTitles"; + +vi.mock("@/api/v2/request", () => ({ v2: vi.fn() })); + +const page = (ids: number[], nextCursor?: string) => ({ + items: ids.map((id) => ({ media_type: "movie", tmdb_id: id })), + page: { has_more: Boolean(nextCursor), next_cursor: nextCursor }, +}); + +describe("listWatchlistTitlesV2", () => { + beforeEach(() => vi.mocked(v2).mockReset()); + + it("reads every page, however many there are", async () => { + const pages = 30; + for (let i = 0; i < pages; i++) { + vi.mocked(v2).mockResolvedValueOnce( + page([i], i < pages - 1 ? `c${i + 1}` : undefined) as never, + ); + } + const titles = await listWatchlistTitlesV2(); + expect(titles).toHaveLength(pages); + expect(vi.mocked(v2)).toHaveBeenCalledTimes(pages); + }); + + it("fails instead of looping on a repeated cursor", async () => { + vi.mocked(v2) + .mockResolvedValueOnce(page([1], "same") as never) + .mockResolvedValueOnce(page([2], "same") as never); + await expect(listWatchlistTitlesV2()).rejects.toThrow(/repeated a page cursor/); + }); +}); diff --git a/web/src/api/v2/watchlistTitles.ts b/web/src/api/v2/watchlistTitles.ts new file mode 100644 index 0000000000..0a33e47a95 --- /dev/null +++ b/web/src/api/v2/watchlistTitles.ts @@ -0,0 +1,80 @@ +import type { RequestMediaType, RequestState } from "@/api/types"; +import { v2 } from "@/api/v2/request"; +import type { components } from "@/api/v2/schema"; + +type Schemas = components["schemas"]; + +/** + * A watchlist entry for a title the library doesn't have. `status` is active, + * needs_review (TMDB deleted the ID and several titles could replace it) or + * removed (TMDB deleted it and nothing replaces it); the server may add + * values, which read as active. + */ +export interface WatchlistTitle extends Omit { + media_type: RequestMediaType; + request: RequestState; +} + +/** What an add answers: where the entry went, and the request state after it. */ +export interface WatchlistTitleEntry extends Omit< + Schemas["WatchlistTitleEntry"], + "media_type" | "request" +> { + media_type: RequestMediaType; + request: RequestState; +} + +/** The largest page the server serves. */ +const WATCHLIST_TITLES_PAGE_SIZE = 200; + +function watchlistTitleFromV2(t: Schemas["WatchlistTitle"]): WatchlistTitle { + return t as WatchlistTitle; +} + +/** + * Every watchlist entry for titles the library doesn't have, newest first. + * The tab sorts and counts the whole list on the client, so this follows the + * cursor until the server has no more pages. A cursor the server repeats + * fails the read rather than looping or showing part of the list as all of it. + */ +export async function listWatchlistTitlesV2(signal?: AbortSignal): Promise { + const out: WatchlistTitle[] = []; + const seen = new Set(); + let cursor: string | undefined; + for (;;) { + const result = await v2("GET /api/v2/watchlist/titles", { + query: { limit: WATCHLIST_TITLES_PAGE_SIZE, cursor }, + signal, + }); + out.push(...result.items.map(watchlistTitleFromV2)); + const next = result.page?.has_more ? result.page.next_cursor : undefined; + if (!next) return out; + if (seen.has(next)) throw new Error("The watchlist titles list repeated a page cursor."); + seen.add(next); + cursor = next; + } +} + +/** + * Adds a title to the watchlist by its TMDB ID. The server files it under the + * library item when the library has the title, and may also request it; the + * answer carries the request state either way. + */ +export function addWatchlistTitleV2( + mediaType: RequestMediaType, + tmdbID: number, +): Promise { + return v2("PUT /api/v2/watchlist/titles/{media_type}/{tmdb_id}", { + path: { media_type: mediaType, tmdb_id: tmdbID }, + }).then((entry) => entry as WatchlistTitleEntry); +} + +/** Removes a title from the watchlist, and its library item's entry too. */ +export async function deleteWatchlistTitleV2( + mediaType: RequestMediaType, + tmdbID: number, +): Promise { + await v2("DELETE /api/v2/watchlist/titles/{media_type}/{tmdb_id}", { + path: { media_type: mediaType, tmdb_id: tmdbID }, + }); +} diff --git a/web/src/components/RequestPosterCard.test.tsx b/web/src/components/RequestPosterCard.test.tsx index 8625e5cfa1..acac93f97e 100644 --- a/web/src/components/RequestPosterCard.test.tsx +++ b/web/src/components/RequestPosterCard.test.tsx @@ -237,6 +237,49 @@ describe("RequestPosterCard (discover variant)", () => { }); }); +describe("RequestPosterCard watchlist action", () => { + it("adds or removes the title from the hover corner action", () => { + const onToggleWatchlist = vi.fn(); + const { unmount } = render( + + + , + ); + const add = screen.getByRole("button", { name: "Add Test Movie to your watchlist" }); + expect(add).toHaveAttribute("aria-pressed", "false"); + expect(add).toHaveAttribute("title", "Add to Watchlist"); + fireEvent.click(add); + expect(onToggleWatchlist).toHaveBeenCalledTimes(1); + unmount(); + + render( + + + , + ); + const remove = screen.getByRole("button", { name: "Remove Test Movie from your watchlist" }); + expect(remove).toHaveAttribute("aria-pressed", "true"); + expect(remove).toHaveAttribute("title", "On Watchlist"); + }); + + it("offers no watchlist action without a handler", () => { + render( + + + , + ); + expect(screen.queryByRole("button", { name: /your watchlist/ })).toBeNull(); + }); +}); + describe("RequestPosterCard (mine variant)", () => { const request: MediaRequest = { id: "req-1", diff --git a/web/src/components/RequestPosterCard.tsx b/web/src/components/RequestPosterCard.tsx index d5ee996705..cc344e0df7 100644 --- a/web/src/components/RequestPosterCard.tsx +++ b/web/src/components/RequestPosterCard.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Library, Loader2, Plus } from "lucide-react"; +import { Bookmark, BookmarkCheck, Library, Loader2, Plus } from "lucide-react"; import type { MediaRequest, RequestMediaResult, RequestMediaType } from "@/api/types"; import { cn } from "@/lib/utils"; import { @@ -22,6 +22,10 @@ import MediaCardArtwork, { import { RequestReasonBadge, RequestStatusBadge } from "@/components/RequestStatusBadge"; import { Button } from "@/components/ui/button"; import ViewTransitionLink from "@/components/ViewTransitionLink"; +import { + mediaItemMenuIconClassName, + mediaItemMenuTriggerClassName, +} from "@/components/mediaItemMenuTrigger"; // The badge takes the top-right corner and truncates before it reaches the // Library chip on the left. @@ -35,6 +39,13 @@ type DiscoverProps = { onRequest?: () => void; /** Shows the pending state on the hover Request action. Ignored when onRequest is omitted. */ isSubmitting?: boolean; + /** + * Called when the hover watchlist action is clicked; it reads + * item.in_watchlist. Omit to suppress the action. + */ + onToggleWatchlist?: () => void; + /** Disables the hover watchlist action while a change is in flight. */ + isWatchlistPending?: boolean; /** When true, fills the parent (use inside grids). Default: the viewer's carousel card width. */ fluid?: boolean; }; @@ -72,12 +83,21 @@ export default function RequestPosterCard(props: RequestPosterCardProps) { item={props.item} isSubmitting={props.isSubmitting} onRequest={props.onRequest} + onToggleWatchlist={props.onToggleWatchlist} + isWatchlistPending={props.isWatchlistPending} fluid={props.fluid} /> ); } -function DiscoverCard({ item, isSubmitting, onRequest, fluid }: Omit) { +function DiscoverCard({ + item, + isSubmitting, + onRequest, + onToggleWatchlist, + isWatchlistPending, + fluid, +}: Omit) { const requestable = item.request.requestable; const availableInLibrary = item.availability === "available" && !item.request.status; const state: RequestDisplayState | undefined = item.request.status @@ -112,6 +132,16 @@ function DiscoverCard({ item, isSubmitting, onRequest, fluid }: Omit ) : null } + cornerAction={ + onToggleWatchlist ? ( + + ) : null + } /> ); } @@ -197,6 +227,7 @@ function ExternalTitleCard({ dim, badge, action, + cornerAction, children, }: { title: string; @@ -211,6 +242,8 @@ function ExternalTitleCard({ badge?: ReactNode; /** The hover action in the card's centre slot. */ action?: ReactNode; + /** A hover action in the poster's bottom-right corner, where library cards keep their menu. */ + cornerAction?: ReactNode; /** Request details below the caption. Shown whatever the caption setting; pass null for none. */ children?: ReactNode; }) { @@ -252,6 +285,11 @@ function ExternalTitleCard({ ) : null} {action} + {cornerAction ? ( +
+ {cornerAction} +
+ ) : null} {showCaption || children ? (
@@ -272,7 +310,8 @@ function ExternalTitleCard({ ); } -function RequestAction({ +/** The centred hover Request action on a TMDB title's poster card. */ +export function RequestAction({ title, pending, onRequest, @@ -313,6 +352,48 @@ function RequestAction({ ); } +/** The hover watchlist toggle in a TMDB title's poster corner. */ +export function WatchlistAction({ + title, + inWatchlist, + pending, + onToggle, +}: { + title: string; + inWatchlist: boolean; + pending: boolean; + onToggle: () => void; +}) { + const Icon = inWatchlist ? BookmarkCheck : Bookmark; + return ( + + ); +} + function LibraryChip({ contentID, title }: { contentID: string; title: string }) { return ( {results.map((item) => ( @@ -38,6 +40,8 @@ export default function RequestResultsGrid({ item={item} isSubmitting={isSubmitting(item)} onRequest={() => submit(item)} + onToggleWatchlist={watchlist.enabled ? () => watchlist.toggle(item) : undefined} + isWatchlistPending={watchlist.isPending(item)} fluid /> ))} diff --git a/web/src/components/RequestToAddSection.test.tsx b/web/src/components/RequestToAddSection.test.tsx index 61d36a8cfc..e60a9f4681 100644 --- a/web/src/components/RequestToAddSection.test.tsx +++ b/web/src/components/RequestToAddSection.test.tsx @@ -22,6 +22,10 @@ vi.mock("@/hooks/queries/useRequests", () => ({ useCreateMediaRequest: () => mocks.useCreateMediaRequest(), })); +vi.mock("@/hooks/useWatchlistTitleToggle", () => ({ + useWatchlistTitleToggle: () => ({ enabled: false, toggle: vi.fn(), isPending: () => false }), +})); + vi.mock("@/hooks/useDebounce", () => ({ useDebounce: (v: T) => mocks.useDebounce(v) ?? v, })); diff --git a/web/src/components/overlays/CardOverlays.test.tsx b/web/src/components/overlays/CardOverlays.test.tsx index 4acb97a135..eca906c3d8 100644 --- a/web/src/components/overlays/CardOverlays.test.tsx +++ b/web/src/components/overlays/CardOverlays.test.tsx @@ -4,12 +4,16 @@ import { render } from "@testing-library/react"; import CardOverlays from "./CardOverlays"; import { formatLanguageWhenLoaded } from "@/lib/languageNamesLoader"; import { + ATTENTION_ACCENT, + OVERLAY_POSITIONS, OVERLAY_PRESETS, OVERLAY_REGISTRY, PRESET_IDS, SAMPLE_MOVIE_DATA, + SAMPLE_REQUEST_DATA, SAMPLE_SHOW_DATA, buildDefaultPrefs, + type OverlayData, type CardOverlayPrefs, type OverlayId, type PresetId, @@ -47,7 +51,11 @@ describe("CardOverlays", () => { await vi.waitFor(() => expect(formatLanguageWhenLoaded("en")).toBe("English")); for (const def of OVERLAY_REGISTRY) { const data = - def.id === "network" || def.id === "show_status" ? SAMPLE_SHOW_DATA : SAMPLE_MOVIE_DATA; + def.id === "network" || def.id === "show_status" + ? SAMPLE_SHOW_DATA + : def.id === "request_status" + ? SAMPLE_REQUEST_DATA + : SAMPLE_MOVIE_DATA; const expected = def.getValue(data); expect(expected, `sample data should exercise overlay ${def.id}`).toBeTruthy(); const { container, unmount } = render( @@ -423,6 +431,88 @@ describe("CardOverlays", () => { expect(container.querySelector("span.inline-flex")?.style.pointerEvents).toBe(""); }); + describe("request status", () => { + const attention: OverlayData = { + request_status: "Not found yet", + request_status_icon: "alert", + request_status_attention: true, + }; + + function badgeStyle(data: OverlayData, prefs: CardOverlayPrefs): string | null { + return ( + render() + .container.querySelector("[data-overlay-badge]") + ?.getAttribute("style") ?? null + ); + } + + it.each(PRESET_IDS)("renders the status in the %s preset, with its icon on request", (id) => { + const plain = render( + , + ).container; + expect(badgeTexts(plain)).toEqual(["Downloading 43%"]); + expect(plain.querySelector("[data-overlay-badge] svg") !== null).toBe( + OVERLAY_PRESETS[id].preferIcon, + ); + + const prefs = prefsWithOnly("request_status", id); + prefs.items.request_status = { ...prefs.items.request_status, showIcon: true }; + const withIcon = render().container; + expect(badgeTexts(withIcon)).toEqual(["Downloading 43%"]); + expect(withIcon.querySelector("[data-overlay-badge] svg")).not.toBeNull(); + }); + + it.each(OVERLAY_POSITIONS)("renders in the %s corner", (position) => { + const prefs = prefsWithOnly("request_status"); + prefs.items.request_status = { ...prefs.items.request_status, position }; + const [edge, side] = position.split("-"); + const { container } = render(); + const stack = container.querySelector( + `[data-overlay-edge="${edge}"] > div.${side === "left" ? "items-start" : "items-end"}`, + ); + expect(stack?.textContent).toBe("Downloading 43%"); + }); + + it("renders nothing when the badge is off", () => { + const prefs = prefsWithOnly("request_status"); + prefs.items.request_status = { ...prefs.items.request_status, enabled: false }; + const { container } = render(); + expect(badgeTexts(container)).toEqual([]); + }); + + it.each(PRESET_IDS)("colors a status that needs attention amber in the %s preset", (id) => { + const accented = prefsWithOnly("request_status", id); + accented.items.request_status = { + ...accented.items.request_status, + accentColor: ATTENTION_ACCENT, + }; + const calm = { ...attention, request_status_attention: false }; + expect(badgeStyle(attention, prefsWithOnly("request_status", id))).toBe( + badgeStyle(calm, accented), + ); + expect(badgeStyle(calm, prefsWithOnly("request_status", id))).not.toBe( + badgeStyle(calm, accented), + ); + }); + + it("lets the viewer's own accent win over the attention accent", () => { + const custom = prefsWithOnly("request_status", "square"); + custom.items.request_status = { ...custom.items.request_status, accentColor: "#3b82f6" }; + expect(badgeStyle(attention, custom)).toBe( + badgeStyle({ ...attention, request_status_attention: false }, custom), + ); + }); + + it("lifts a bottom-row status above the download bar", () => { + const prefs = prefsWithOnly("request_status"); + prefs.items.request_status = { ...prefs.items.request_status, position: "bottom-left" }; + const lifted = render( + , + ).container.querySelector('[data-overlay-edge="bottom"]'); + expect(bottomMarginClasses(lifted)).toEqual(["mb-2"]); + }); + }); + it("renders nothing when no enabled overlay has data", () => { const { container } = render(); expect(container.querySelectorAll("span.inline-flex").length).toBe(0); diff --git a/web/src/components/overlays/CardOverlays.tsx b/web/src/components/overlays/CardOverlays.tsx index fb6ec2705c..1cd61529a2 100644 --- a/web/src/components/overlays/CardOverlays.tsx +++ b/web/src/components/overlays/CardOverlays.tsx @@ -154,7 +154,9 @@ function resolveBadge( return { def, label, - accentColor: itemAccent ?? def.defaultAccent, + // The viewer's own accent wins; a data-driven accent (amber for a title + // that needs attention) comes before the badge's default. + accentColor: itemAccent ?? def.getAccent?.(data) ?? def.defaultAccent, iconId: showIcon ? candidateIcon : null, }; } diff --git a/web/src/components/overlays/OverlayPreviewCard.tsx b/web/src/components/overlays/OverlayPreviewCard.tsx index 3fd85b6c76..d141d5c2d2 100644 --- a/web/src/components/overlays/OverlayPreviewCard.tsx +++ b/web/src/components/overlays/OverlayPreviewCard.tsx @@ -1,8 +1,24 @@ import CardOverlays from "./CardOverlays"; -import { SAMPLE_MOVIE_DATA, SAMPLE_SHOW_DATA, type CardOverlayPrefs } from "@/lib/overlays"; +import { RequestDownloadBar } from "./RequestDownloadBar"; +import { + OVERLAY_PREVIEW_SAMPLES, + requestDownloadBarPercent, + type CardOverlayPrefs, + type OverlayPreviewVariant, +} from "@/lib/overlays"; -/** Which sample item the preview stands in for. Picked by . */ -export type OverlayPreviewVariant = "movie" | "show"; +/** + * Which sample item the preview stands in for. Picked by + * . "requested" is a watchlist title the + * library doesn't have yet, downloading. + */ +export type { OverlayPreviewVariant }; + +const PLACEHOLDER_LABEL: Record = { + movie: "Movie preview", + show: "Show preview", + requested: "Requested preview", +}; interface OverlayPreviewCardProps { prefs: CardOverlayPrefs; @@ -26,15 +42,19 @@ export function OverlayPreviewCard({ size = "md", showPosterOverlays = true, }: OverlayPreviewCardProps) { - const data = variant === "show" ? SAMPLE_SHOW_DATA : SAMPLE_MOVIE_DATA; + const data = OVERLAY_PREVIEW_SAMPLES[variant]; const sizeClass = SIZE_CLASSES[size]; + const downloadPercent = showPosterOverlays ? requestDownloadBarPercent(data, prefs) : null; return (
- {variant === "show" ? "Show preview" : "Movie preview"} + {PLACEHOLDER_LABEL[variant]}
- {showPosterOverlays ? : null} + {downloadPercent !== null ? : null} + {showPosterOverlays ? ( + + ) : null}
); diff --git a/web/src/components/overlays/OverlayPreviewVariantToggle.tsx b/web/src/components/overlays/OverlayPreviewVariantToggle.tsx index 3df562558f..48fe590d0c 100644 --- a/web/src/components/overlays/OverlayPreviewVariantToggle.tsx +++ b/web/src/components/overlays/OverlayPreviewVariantToggle.tsx @@ -1,28 +1,30 @@ import { cn } from "@/lib/utils"; -import type { OverlayPreviewVariant } from "./OverlayPreviewCard"; +import { OVERLAY_PREVIEW_VARIANTS, type OverlayPreviewVariant } from "@/lib/overlays"; interface OverlayPreviewVariantToggleProps { value: OverlayPreviewVariant; onChange: (value: OverlayPreviewVariant) => void; + /** The samples offered; defaults to all of them. */ + variants?: readonly OverlayPreviewVariant[]; className?: string; } -const VARIANTS: readonly OverlayPreviewVariant[] = ["movie", "show"]; - /** * Pill pair that picks which sample data renders. Shared * by the user Card Overlays page and the admin defaults editor so both can - * preview show-only overlays (network, show status) while editing. The choice + * preview show-only overlays (network, show status) and the request status of + * a watchlist title the library doesn't have yet while editing. The choice * is local view state on both surfaces and is deliberately never persisted. */ export function OverlayPreviewVariantToggle({ value, onChange, + variants = OVERLAY_PREVIEW_VARIANTS, className, }: OverlayPreviewVariantToggleProps) { return (
- {VARIANTS.map((variant) => ( + {variants.map((variant) => ( +
+ ); + } + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + if (entries.length === 0) { + return ( +
+ +

Nothing waiting for the library

+

+ Add movies and series from Discover to your watchlist. The ones the library doesn’t + have yet wait here until they arrive. +

+ +
+ ); + } + + return ( +
+
+

{watchlistTitlesHint(watchlistRequests)}

+ + Find more in Discover + +
+
+ {entries.map(({ title, status }) => ( + + submit({ + media_type: title.media_type, + tmdb_id: title.tmdb_id, + title: title.title, + year: title.year, + poster_path: title.poster_path, + availability: "missing", + request: title.request, + }) + } + isRequesting={isSubmitting(title)} + onRemove={() => watchlist.toggle({ ...title, in_watchlist: true })} + isRemoving={watchlist.isPending(title)} + /> + ))} +
+
+ ); +} diff --git a/web/src/hooks/queries/keys.ts b/web/src/hooks/queries/keys.ts index ee61339c71..a58c0463a6 100644 --- a/web/src/hooks/queries/keys.ts +++ b/web/src/hooks/queries/keys.ts @@ -103,6 +103,8 @@ export const watchlistKeys = { all: ["watchlist"] as const, list: () => ["watchlist", "list"] as const, check: (itemId: string) => ["watchlist", "check", itemId] as const, + /** Watchlist entries for titles the library doesn't have yet. */ + titles: () => ["watchlist", "titles"] as const, }; export const historyKeys = { diff --git a/web/src/hooks/queries/useRequests.ts b/web/src/hooks/queries/useRequests.ts index cd600b19c2..5fa2fb781b 100644 --- a/web/src/hooks/queries/useRequests.ts +++ b/web/src/hooks/queries/useRequests.ts @@ -37,7 +37,7 @@ import type { RequestMediaType, } from "@/api/types"; import { tmdbPageCount } from "@/lib/mediaRequests"; -import { adminKeys, requestKeys } from "./keys"; +import { adminKeys, requestKeys, watchlistKeys } from "./keys"; export const REQUESTS_STALE_TIME = 30_000; const DISCOVER_BRAND_STALE_TIME = 24 * 60 * 60 * 1000; @@ -78,6 +78,8 @@ export function invalidateRequestSurfaces(queryClient: ReturnType ({ + ...(await importOriginal()), + scheduleMediaSurfaceInvalidation: vi.fn(), +})); + +describe("watchlistAddToast", () => { + const input = { title: "Heat", request: { requestable: true } }; + + it("says a title the library has was only added", () => { + expect( + watchlistAddToast({ item_id: "abc", request: { requestable: false } }, input, true), + ).toEqual({ title: "Added to your watchlist" }); + }); + + it("says the add also requested the title", () => { + expect( + watchlistAddToast( + { request: { requestable: false, status: "pending", requested_by_viewer: true } }, + input, + true, + ), + ).toEqual({ + title: "Added to your watchlist and requested", + description: + "We'll let you know when Heat is available. It moves into your watchlist on its own.", + }); + }); + + it("says the add followed someone else's request", () => { + expect( + watchlistAddToast( + { request: { requestable: false, status: "pending", following: true } }, + input, + true, + ), + ).toEqual({ + title: "Added to your watchlist", + description: "We'll let you know when Heat is available.", + }); + }); + + it("stays quiet about a request the viewer already had", () => { + const requested = { requestable: false, status: "pending" as const, requested_by_viewer: true }; + expect( + watchlistAddToast({ request: requested }, { title: "Heat", request: requested }, true), + ).toEqual({ title: "Added to your watchlist" }); + }); + + it("gives the reason a request was refused", () => { + expect( + watchlistAddToast({ request: { requestable: false, reason: "quota_exceeded" } }, input, true), + ).toEqual({ + title: "Added to your watchlist", + description: "It wasn't requested: request limit reached.", + }); + }); + + it("says watchlist requests are off", () => { + expect(watchlistAddToast({ request: { requestable: true } }, input, false)).toEqual({ + title: "Added to your watchlist", + description: "It wasn't requested, because watchlist requests are off.", + }); + expect(watchlistAddToast({ request: { requestable: true } }, input, undefined)).toEqual({ + title: "Added to your watchlist", + description: "It wasn't requested.", + }); + }); +}); + +describe("patchCachedInWatchlist", () => { + const heat = { media_type: "movie", tmdb_id: 949, title: "Heat", request: { requestable: true } }; + const other = { + media_type: "series", + tmdb_id: 949, + title: "Other", + request: { requestable: true }, + }; + + it("sets in_watchlist on the matching title at any depth", () => { + const data = { sections: [{ items: [heat, other] }], detail: { ...heat, in_watchlist: false } }; + const patched = patchCachedInWatchlist(data, "movie", 949, true) as typeof data; + expect(patched.sections[0]!.items[0]).toEqual({ ...heat, in_watchlist: true }); + expect(patched.detail.in_watchlist).toBe(true); + // A series with the same TMDB number is a different title. + expect(patched.sections[0]!.items[1]).toBe(other); + }); + + it("keeps the references of anything it does not change", () => { + const data = { pages: [{ items: [other] }] }; + expect(patchCachedInWatchlist(data, "movie", 949, true)).toBe(data); + const already = { items: [{ ...heat, in_watchlist: true }] }; + expect(patchCachedInWatchlist(already, "movie", 949, true)).toBe(already); + }); + + it("ignores objects without a request state", () => { + const person = { media_type: "movie", tmdb_id: 949, name: "Not a title" }; + expect(patchCachedInWatchlist(person, "movie", 949, true)).toBe(person); + }); +}); + +describe("refreshAfterTitlesRead", () => { + const title = (tmdbID: number) => ({ media_type: "movie", tmdb_id: tmdbID }) as WatchlistTitle; + let queryClient: QueryClient; + let invalidated: unknown[][]; + + beforeEach(() => { + vi.mocked(scheduleMediaSurfaceInvalidation).mockClear(); + queryClient = new QueryClient(); + invalidated = []; + const invalidate = queryClient.invalidateQueries.bind(queryClient); + vi.spyOn(queryClient, "invalidateQueries").mockImplementation((filters, options) => { + for (const query of queryClient.getQueryCache().findAll(filters)) { + invalidated.push([...query.queryKey]); + } + return invalidate(filters, options); + }); + queryClient.setQueryData(["catalog", "list", { source: "watchlist" }], { items: [] }); + queryClient.setQueryData(["catalog", "list", { source: "favorites" }], { items: [] }); + queryClient.setQueryData(["watchlist", "titles"], []); + }); + + it("refreshes the library watchlist grid after every read, not other lists or itself", () => { + refreshAfterTitlesRead(queryClient, undefined, [title(1)]); + expect(invalidated).toEqual([["catalog", "list", { source: "watchlist" }]]); + expect(scheduleMediaSurfaceInvalidation).not.toHaveBeenCalled(); + }); + + it("refreshes the library surfaces when a title the tab showed is gone", () => { + refreshAfterTitlesRead(queryClient, [title(1), title(2)], [title(2)]); + expect(scheduleMediaSurfaceInvalidation).toHaveBeenCalledTimes(1); + }); + + it("settles when the same titles come back", () => { + refreshAfterTitlesRead(queryClient, [title(2)], [title(2), title(3)]); + expect(scheduleMediaSurfaceInvalidation).not.toHaveBeenCalled(); + }); +}); + +describe("watchlistTitlesRefetchInterval", () => { + const title = (download?: object) => + ({ + media_type: "movie", + tmdb_id: 1, + request: { requestable: false, download }, + }) as WatchlistTitle; + + it("polls while a title downloads", () => { + expect(watchlistTitlesRefetchInterval([title(), title({ phase: "downloading" })])).toBe(30_000); + }); + + it("stays quiet otherwise", () => { + expect(watchlistTitlesRefetchInterval([title()])).toBe(false); + expect(watchlistTitlesRefetchInterval(undefined)).toBe(false); + }); +}); diff --git a/web/src/hooks/queries/watchlistTitles.ts b/web/src/hooks/queries/watchlistTitles.ts new file mode 100644 index 0000000000..ce6866d777 --- /dev/null +++ b/web/src/hooks/queries/watchlistTitles.ts @@ -0,0 +1,245 @@ +import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import type { RequestMediaType, RequestState } from "@/api/types"; +import { + addWatchlistTitleV2, + deleteWatchlistTitleV2, + listWatchlistTitlesV2, + type WatchlistTitle, + type WatchlistTitleEntry, +} from "@/api/v2/watchlistTitles"; +import { formatRequestReason } from "@/lib/mediaRequests"; +import { catalogKeys, requestKeys, watchlistKeys } from "./keys"; +import { scheduleMediaSurfaceInvalidation } from "./mediaSurfaceRefresh"; +import { + invalidateRequestSurfaces, + REQUEST_DOWNLOAD_REFETCH_INTERVAL, + REQUESTS_STALE_TIME, +} from "./useRequests"; + +/** Watchlist entries for titles the library doesn't have yet. */ +export function useWatchlistTitles(options: { enabled?: boolean } = {}) { + const queryClient = useQueryClient(); + return useQuery({ + queryKey: watchlistKeys.titles(), + queryFn: async ({ signal }) => { + const before = queryClient.getQueryData(watchlistKeys.titles()); + const titles = await listWatchlistTitlesV2(signal); + refreshAfterTitlesRead(queryClient, before, titles); + return titles; + }, + enabled: options.enabled ?? true, + staleTime: REQUESTS_STALE_TIME, + refetchInterval: (query) => watchlistTitlesRefetchInterval(query.state.data), + }); +} + +/** + * While a title downloads, the list refreshes on the interval the other + * request surfaces use, so its progress doesn't freeze. + */ +export function watchlistTitlesRefetchInterval( + titles: readonly WatchlistTitle[] | undefined, +): number | false { + return titles?.some((t) => t.request.download) ? REQUEST_DOWNLOAD_REFETCH_INTERVAL : false; +} + +/** + * Reading the titles moves the ones the library now has onto the library + * watchlist, server side, so the library tab's grid is refreshed after every + * read. When a title this tab showed is gone, the library surfaces that list + * the watchlist (the home row, item states) refresh too. That refresh reads + * the titles once more, which finds nothing else gone, so it settles. + */ +export function refreshAfterTitlesRead( + queryClient: QueryClient, + before: readonly WatchlistTitle[] | undefined, + after: readonly WatchlistTitle[], +): void { + queryClient.invalidateQueries({ predicate: (query) => isWatchlistCatalogQuery(query.queryKey) }); + if (!before) return; + const still = new Set(after.map((t) => `${t.media_type}-${t.tmdb_id}`)); + if (before.some((t) => !still.has(`${t.media_type}-${t.tmdb_id}`))) { + scheduleMediaSurfaceInvalidation(queryClient); + } +} + +export interface ToggleWatchlistTitleInput { + mediaType: RequestMediaType; + tmdbID: number; + /** Names the title in the toast. */ + title: string; + /** Whether the title is on the watchlist now; the mutation flips it. */ + inWatchlist: boolean; + /** The title's request state before the add, to tell what the add did. */ + request?: RequestState; +} + +/** The toast after an add: whether the title was also requested, and why not. */ +export function watchlistAddToast( + entry: Pick, + input: Pick, + watchlistRequests: boolean | undefined, +): { title: string; description?: string } { + const before = input.request; + const after = entry.request; + const available = `We'll let you know when ${input.title} is available.`; + if (entry.item_id) { + return { title: "Added to your watchlist" }; + } + if (after.requested_by_viewer && !before?.requested_by_viewer) { + return { + title: "Added to your watchlist and requested", + description: `${available} It moves into your watchlist on its own.`, + }; + } + if (after.following && !before?.following) { + return { title: "Added to your watchlist", description: available }; + } + if (after.status) { + // It already had a request the viewer made or follows. + return { title: "Added to your watchlist" }; + } + if (after.reason) { + return { + title: "Added to your watchlist", + description: `It wasn't requested: ${formatRequestReason(after.reason).toLowerCase()}.`, + }; + } + if (watchlistRequests === false) { + return { + title: "Added to your watchlist", + description: "It wasn't requested, because watchlist requests are off.", + }; + } + return { title: "Added to your watchlist", description: "It wasn't requested." }; +} + +/** + * Sets in_watchlist on every cached TMDB title matching the key: the title + * page, and the Discover, browse and search results that list it. The request + * caches hold those titles at several depths (pages, sections, the title + * page's recommendations), so this walks plain objects and arrays, and keeps + * the reference of anything it does not change. + */ +export function patchCachedInWatchlist( + data: unknown, + mediaType: RequestMediaType, + tmdbID: number, + inWatchlist: boolean, +): unknown { + if (Array.isArray(data)) { + let changed = false; + const next = data.map((entry) => { + const patched = patchCachedInWatchlist(entry, mediaType, tmdbID, inWatchlist); + if (patched !== entry) changed = true; + return patched; + }); + return changed ? next : data; + } + if ( + typeof data !== "object" || + data === null || + Object.getPrototypeOf(data) !== Object.prototype + ) { + return data; + } + const record = data as Record; + let next: Record | null = null; + for (const [key, value] of Object.entries(record)) { + if (typeof value !== "object" || value === null) continue; + const patched = patchCachedInWatchlist(value, mediaType, tmdbID, inWatchlist); + if (patched !== value) { + next ??= { ...record }; + next[key] = patched; + } + } + const isTitle = + record.media_type === mediaType && + record.tmdb_id === tmdbID && + typeof record.request === "object" && + record.request !== null; + if (isTitle && record.in_watchlist !== inWatchlist) { + next ??= { ...record }; + next.in_watchlist = inWatchlist; + } + return next ?? data; +} + +function setCachedInWatchlist( + queryClient: QueryClient, + mediaType: RequestMediaType, + tmdbID: number, + inWatchlist: boolean, +) { + queryClient.setQueriesData({ queryKey: requestKeys.all }, (data: unknown) => + patchCachedInWatchlist(data, mediaType, tmdbID, inWatchlist), + ); +} + +function isWatchlistCatalogQuery(queryKey: readonly unknown[]): boolean { + const [root, kind, params] = queryKey; + return ( + root === catalogKeys.all[0] && + (kind === "list" || kind === "filters") && + typeof params === "object" && + params !== null && + (params as { source?: unknown }).source === "watchlist" + ); +} + +/** + * Adds a title to the watchlist by its TMDB ID, or removes it. The server may + * also request the title on add, or cancel its watchlist request on remove, + * so both refresh the request surfaces as well as the watchlist. + */ +export function useToggleWatchlistTitle() { + const queryClient = useQueryClient(); + return useMutation({ + retry: false, + mutationFn: async (input: ToggleWatchlistTitleInput): Promise => { + if (input.inWatchlist) { + await deleteWatchlistTitleV2(input.mediaType, input.tmdbID); + return null; + } + return addWatchlistTitleV2(input.mediaType, input.tmdbID); + }, + onMutate: async ({ mediaType, tmdbID, inWatchlist }) => { + await queryClient.cancelQueries({ queryKey: requestKeys.detail(mediaType, tmdbID) }); + setCachedInWatchlist(queryClient, mediaType, tmdbID, !inWatchlist); + }, + onError: (err, { mediaType, tmdbID, inWatchlist }) => { + setCachedInWatchlist(queryClient, mediaType, tmdbID, inWatchlist); + toast.error(err instanceof Error ? err.message : "Failed to update your watchlist"); + }, + onSuccess: (entry, input) => { + if (!entry) { + toast.success("Removed from your watchlist"); + return; + } + const status = queryClient.getQueryData<{ watchlist_requests?: boolean }>( + requestKeys.status(), + ); + const message = watchlistAddToast(entry, input, status?.watchlist_requests); + toast.success( + message.title, + message.description ? { description: message.description } : undefined, + ); + }, + onSettled: (entry, _err, input) => { + queryClient.invalidateQueries({ queryKey: watchlistKeys.all }); + queryClient.invalidateQueries({ + predicate: (query) => isWatchlistCatalogQuery(query.queryKey), + }); + invalidateRequestSurfaces(queryClient); + // An add the library has lands on its item, and a remove drops the + // item's entry too, so the library surfaces (the home watchlist row, + // the item's user state) refresh as for a library watchlist toggle. + if (entry?.item_id) { + scheduleMediaSurfaceInvalidation(queryClient, { itemId: entry.item_id }); + } else if (input.inWatchlist) { + scheduleMediaSurfaceInvalidation(queryClient); + } + }, + }); +} diff --git a/web/src/hooks/useOverlayPrefs.test.tsx b/web/src/hooks/useOverlayPrefs.test.tsx index 7407dcad2a..5401c0e559 100644 --- a/web/src/hooks/useOverlayPrefs.test.tsx +++ b/web/src/hooks/useOverlayPrefs.test.tsx @@ -290,24 +290,38 @@ describe("useOverlayPrefs", () => { // ui.card_overlays validation is all-or-nothing, so one overlay id the // server's schema predates would fail every badge save on that server. With // the revision unknown, an id the server already stored is still accepted. - it.each([ - { name: "a revision-12 server", revision: 12, storedAdvisory: false, kept: false }, - { name: "a revision-13 server", revision: 13, storedAdvisory: false, kept: true }, - { name: "an unknown revision", revision: undefined, storedAdvisory: false, kept: false }, - { - name: "an unknown revision with a stored advisory badge", - revision: undefined, - storedAdvisory: true, - kept: true, - }, - ])("writes advisory_age only where it is accepted: $name", async (c) => { + it.each( + ( + [ + ["advisory_age", 13], + ["request_status", 15], + ] as const + ).flatMap(([id, since]) => [ + { + id, + name: `a revision-${since - 1} server`, + revision: since - 1, + stored: false, + kept: false, + }, + { id, name: `a revision-${since} server`, revision: since, stored: false, kept: true }, + { id, name: "an unknown revision", revision: undefined, stored: false, kept: false }, + { + id, + name: "an unknown revision with it stored", + revision: undefined, + stored: true, + kept: true, + }, + ]), + )("writes $id only where it is accepted: $name", async (c) => { mocks.profileId = "profile-1"; - mocks.effective = c.storedAdvisory + mocks.effective = c.stored ? (effectiveOverlayValue({ version: 2, preset: "classic", order: [], - items: { advisory_age: { enabled: true, position: "bottom-right" } }, + items: { [c.id]: { enabled: true, position: "bottom-right" } }, }).data as Record) : {}; mocks.v2.mockImplementation(async (operation: string) => { @@ -328,17 +342,17 @@ describe("useOverlayPrefs", () => { "pending", ); }); - expect(result.current.isOverlaySupported("advisory_age")).toBe(c.kept); + expect(result.current.isOverlaySupported(c.id)).toBe(c.kept); const next = buildDefaultPrefs(); next.preset = "pill"; - next.order = ["advisory_age", "year"]; - next.items.advisory_age = { enabled: true, position: "bottom-right" }; + next.order = [c.id, "year"]; + next.items[c.id] = { enabled: true, position: "bottom-right" }; act(() => result.current.setPrefs(next)); const written = mocks.setValue.mock.calls[0]![0].value as CardOverlayPrefs; - expect("advisory_age" in written.items).toBe(c.kept); - expect(written.order).toEqual(c.kept ? ["advisory_age", "year"] : ["year"]); + expect(c.id in written.items).toBe(c.kept); + expect(written.order).toEqual(c.kept ? [c.id, "year"] : ["year"]); expect(written.items.year).toEqual(next.items.year); }); diff --git a/web/src/hooks/useSubmitMediaRequest.ts b/web/src/hooks/useSubmitMediaRequest.ts index d53c4454a7..89128f01dc 100644 --- a/web/src/hooks/useSubmitMediaRequest.ts +++ b/web/src/hooks/useSubmitMediaRequest.ts @@ -37,7 +37,7 @@ export function useSubmitMediaRequest() { ); const isSubmitting = useCallback( - (item: RequestMediaResult) => pendingKeys.has(resultKey(item)), + (item: Pick) => pendingKeys.has(resultKey(item)), [pendingKeys], ); diff --git a/web/src/hooks/useWatchlistTitleToggle.ts b/web/src/hooks/useWatchlistTitleToggle.ts new file mode 100644 index 0000000000..1e8080bdd6 --- /dev/null +++ b/web/src/hooks/useWatchlistTitleToggle.ts @@ -0,0 +1,62 @@ +import { useCallback, useState } from "react"; +import type { RequestMediaResult } from "@/api/types"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; +import { useToggleWatchlistTitle } from "@/hooks/queries/watchlistTitles"; +import { watchlistTitlesAvailable } from "@/lib/watchlistTitles"; + +type WatchlistToggleTarget = Pick< + RequestMediaResult, + "media_type" | "tmdb_id" | "title" | "in_watchlist" | "request" +>; + +function titleKey(item: Pick): string { + return `${item.media_type}-${item.tmdb_id}`; +} + +/** + * Adds TMDB titles to the watchlist from a list of cards, or removes them, + * and tracks which cards have a change in flight (see useSubmitMediaRequest + * for why each card waits on its own call). `enabled` is false until the + * server reports it keeps watchlist entries for titles outside the library. + */ +export function useWatchlistTitleToggle() { + const featureStatus = useRequestFeatureStatus(); + const { mutateAsync } = useToggleWatchlistTitle(); + const [pendingKeys, setPendingKeys] = useState>(() => new Set()); + + const toggle = useCallback( + (item: WatchlistToggleTarget) => { + const key = titleKey(item); + setPendingKeys((prev) => new Set(prev).add(key)); + mutateAsync({ + mediaType: item.media_type, + tmdbID: item.tmdb_id, + title: item.title, + inWatchlist: Boolean(item.in_watchlist), + request: item.request, + }) + // The mutation's own onError already reported the failure. + .catch(() => {}) + .finally(() => { + setPendingKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }); + }, + [mutateAsync], + ); + + const isPending = useCallback( + (item: Pick) => pendingKeys.has(titleKey(item)), + [pendingKeys], + ); + + return { + enabled: watchlistTitlesAvailable(featureStatus.data), + toggle, + isPending, + }; +} diff --git a/web/src/lib/documentTitle.ts b/web/src/lib/documentTitle.ts index 02258a0265..c3af8f7200 100644 --- a/web/src/lib/documentTitle.ts +++ b/web/src/lib/documentTitle.ts @@ -32,6 +32,7 @@ const SETTINGS_TITLES: Record = { playback: "Playback Settings", profiles: "Profile Settings", libraries: "Library Settings", + requests: "Request Settings", "history-import": "History Import Settings", "plex-webhooks": "Webhook Sync Settings", "webhook-sync": "Webhook Sync Settings", diff --git a/web/src/lib/overlays/extractors.ts b/web/src/lib/overlays/extractors.ts index 0b42155317..1b89b744a0 100644 --- a/web/src/lib/overlays/extractors.ts +++ b/web/src/lib/overlays/extractors.ts @@ -1,4 +1,6 @@ import type { BrowseItem, EpisodeListItem, OverlaySummary, SectionItem } from "@/api/types"; +import type { WatchlistTitle } from "@/api/v2/watchlistTitles"; +import type { WatchlistTitleStatus } from "@/lib/watchlistTitles"; import type { OverlayData } from "./types"; // BrowseItem and SectionItem share the fields the overlay system consumes; @@ -74,3 +76,25 @@ export function overlayDataFromSectionItem(item: SectionItem): OverlayData { export function overlayDataFromEpisodeListItem(item: EpisodeListItem): OverlayData { return extract(item); } + +/** + * A watchlist title the library doesn't have. TMDB supplies the rating, age + * rating and year; file-based badges (resolution, HDR, audio) have no value + * and don't render. The request_status badge takes the status the card + * derived (watchlistTitleStatus), which keeps the request labels out of the + * overlay library. + */ +export function overlayDataFromWatchlistTitle( + title: Pick, + status: Pick, +): OverlayData { + return { + rating_tmdb: title.vote_average ?? null, + content_rating: title.content_rating || undefined, + year: title.year || null, + request_status: status.badge, + request_status_icon: status.badgeIcon, + request_status_attention: status.attention, + request_download_percent: status.downloadPercent ?? null, + }; +} diff --git a/web/src/lib/overlays/icons.tsx b/web/src/lib/overlays/icons.tsx index 66782b852c..7df4bdc832 100644 --- a/web/src/lib/overlays/icons.tsx +++ b/web/src/lib/overlays/icons.tsx @@ -4,8 +4,10 @@ import { Building2, Calendar, Clock, + Download, Film, Globe, + Hourglass, Languages, LayoutTemplate, Monitor, @@ -13,6 +15,7 @@ import { Shield, Star, Subtitles, + TriangleAlert, Tv, Users, Volume2, @@ -40,6 +43,9 @@ const LUCIDE_ICONS: Partial> = { calendar: Calendar, globe: Globe, users: Users, + download: Download, + hourglass: Hourglass, + alert: TriangleAlert, }; // Inline brand marks. Each is a tiny SVG component that fills currentColor so diff --git a/web/src/lib/overlays/index.ts b/web/src/lib/overlays/index.ts index 8227cdee32..c3edaeb8f3 100644 --- a/web/src/lib/overlays/index.ts +++ b/web/src/lib/overlays/index.ts @@ -27,6 +27,7 @@ export { isOverlaySuppressed, isOverlaySupportedBy, overlayPrefsForServer, + requestDownloadBarPercent, storedOverlayIds, } from "./schema"; export type { OverlayServerSupport } from "./schema"; @@ -37,5 +38,14 @@ export { overlayDataFromBrowseItem, overlayDataFromEpisodeListItem, overlayDataFromSectionItem, + overlayDataFromWatchlistTitle, } from "./extractors"; -export { SAMPLE_MOVIE_DATA, SAMPLE_SHOW_DATA } from "./sample-data"; +export { + OVERLAY_PREVIEW_SAMPLES, + OVERLAY_PREVIEW_VARIANTS, + SAMPLE_MOVIE_DATA, + SAMPLE_REQUEST_DATA, + SAMPLE_SHOW_DATA, +} from "./sample-data"; +export type { OverlayPreviewVariant } from "./sample-data"; +export { ATTENTION_ACCENT } from "./registry/ribbons"; diff --git a/web/src/lib/overlays/registry/ribbons.ts b/web/src/lib/overlays/registry/ribbons.ts index edff46f6f4..8052dd1f4c 100644 --- a/web/src/lib/overlays/registry/ribbons.ts +++ b/web/src/lib/overlays/registry/ribbons.ts @@ -4,6 +4,9 @@ import type { OverlayDef } from "../types"; // ribbons. Only entries backed by a real API field belong in this registry; // registry membership makes a control visible in Settings. +// Amber, for a title TMDB no longer lists as saved. +export const ATTENTION_ACCENT = "#f59e0b"; + function formatShowStatus(value: string | undefined): string | null { if (!value) return null; switch (value.toLowerCase()) { @@ -39,4 +42,18 @@ export const RIBBON_OVERLAYS: readonly OverlayDef[] = [ availabilityNote: "Populated by metadata plugins (TMDB/TVDB updates pending)", getValue: (d) => formatShowStatus(d.show_status), }, + { + id: "request_status", + category: "ribbons", + label: "Request Status", + description: + "Where a watchlist title the library doesn't have yet stands: downloading, release date, awaiting approval", + defaultPosition: "top-left", + defaultEnabled: true, + iconCapable: true, + introducedInManifest: 15, + getValue: (d) => d.request_status || null, + getIcon: (d) => d.request_status_icon ?? null, + getAccent: (d) => (d.request_status_attention ? ATTENTION_ACCENT : null), + }, ]; diff --git a/web/src/lib/overlays/requestStatus.test.ts b/web/src/lib/overlays/requestStatus.test.ts new file mode 100644 index 0000000000..7cd8e5e840 --- /dev/null +++ b/web/src/lib/overlays/requestStatus.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + buildDefaultPrefs, + overlayDataFromWatchlistTitle, + requestDownloadBarPercent, + SAMPLE_REQUEST_DATA, +} from "@/lib/overlays"; + +describe("overlayDataFromWatchlistTitle", () => { + it("carries the status badge, icon, attention and download progress", () => { + expect( + overlayDataFromWatchlistTitle( + { vote_average: 7.9, content_rating: "R", year: 1995 }, + { badge: "Downloading 43%", badgeIcon: "download", attention: false, downloadPercent: 43 }, + ), + ).toEqual({ + rating_tmdb: 7.9, + content_rating: "R", + year: 1995, + request_status: "Downloading 43%", + request_status_icon: "download", + request_status_attention: false, + request_download_percent: 43, + }); + }); +}); + +describe("requestDownloadBarPercent", () => { + it("draws the bar only while the request status badge is on", () => { + const prefs = buildDefaultPrefs(); + expect(prefs.items.request_status.enabled).toBe(true); + expect(requestDownloadBarPercent(SAMPLE_REQUEST_DATA, prefs)).toBe(43); + + prefs.items.request_status = { ...prefs.items.request_status, enabled: false }; + expect(requestDownloadBarPercent(SAMPLE_REQUEST_DATA, prefs)).toBeNull(); + }); + + it("draws no bar with overlays off, without a status, or without a known size", () => { + const prefs = buildDefaultPrefs(); + expect(requestDownloadBarPercent(SAMPLE_REQUEST_DATA, null)).toBeNull(); + expect( + requestDownloadBarPercent({ ...SAMPLE_REQUEST_DATA, request_status: undefined }, prefs), + ).toBeNull(); + expect( + requestDownloadBarPercent({ ...SAMPLE_REQUEST_DATA, request_download_percent: null }, prefs), + ).toBeNull(); + }); + + it("clamps the percent to the bar", () => { + expect( + requestDownloadBarPercent( + { ...SAMPLE_REQUEST_DATA, request_download_percent: 140 }, + buildDefaultPrefs(), + ), + ).toBe(100); + }); +}); diff --git a/web/src/lib/overlays/sample-data.ts b/web/src/lib/overlays/sample-data.ts index 2a7baef3d9..eec089ebf2 100644 --- a/web/src/lib/overlays/sample-data.ts +++ b/web/src/lib/overlays/sample-data.ts @@ -3,7 +3,8 @@ import type { OverlayData } from "./types"; // Sample OverlayData used by the settings preview cards so users can see how // the configured overlays will actually look. Two variants — movie and show — // because some overlays (show_status, edition) only render for one or the -// other in real data. +// other in real data. A third, a requested title the library doesn't have +// yet, shows the request_status badge and its download bar. export const SAMPLE_MOVIE_DATA: OverlayData = { resolution: "2160p", @@ -46,3 +47,29 @@ export const SAMPLE_SHOW_DATA: OverlayData = { rt_certified_fresh: false, imdb_top_250: null, }; + +// A watchlist title outside the library: TMDB data only, no file badges. +export const SAMPLE_REQUEST_DATA: OverlayData = { + rating_tmdb: 8.1, + content_rating: "PG-13", + year: 2026, + request_status: "Downloading 43%", + request_status_icon: "download", + request_status_attention: false, + request_download_percent: 43, +}; + +/** The samples the settings preview offers. */ +export type OverlayPreviewVariant = "movie" | "show" | "requested"; + +export const OVERLAY_PREVIEW_VARIANTS: readonly OverlayPreviewVariant[] = [ + "movie", + "show", + "requested", +]; + +export const OVERLAY_PREVIEW_SAMPLES: Readonly> = { + movie: SAMPLE_MOVIE_DATA, + show: SAMPLE_SHOW_DATA, + requested: SAMPLE_REQUEST_DATA, +}; diff --git a/web/src/lib/overlays/schema.ts b/web/src/lib/overlays/schema.ts index 6bd121663d..45c7bab5df 100644 --- a/web/src/lib/overlays/schema.ts +++ b/web/src/lib/overlays/schema.ts @@ -3,6 +3,7 @@ import { OVERLAY_MAP, OVERLAY_REGISTRY } from "./registry"; import { OVERLAY_POSITIONS } from "./types"; import type { CardOverlayPrefs, + OverlayData, OverlayId, OverlayItemConfig, OverlayPosition, @@ -203,3 +204,16 @@ export function orderedOverlaysForPosition(prefs: CardOverlayPrefs, position: Ov const orderIndex = new Map(prefs.order.map((id, i) => [id, i])); return [...enabled].sort((a, b) => (orderIndex.get(a.id) ?? 999) - (orderIndex.get(b.id) ?? 999)); } + +// The download bar a card draws while a watchlist title downloads, as a +// percentage, or null for no bar. The bar belongs to the request_status +// badge: it shows only while overlays are on (prefs is null when they are +// off) and the badge is enabled. +export function requestDownloadBarPercent( + data: OverlayData, + prefs: CardOverlayPrefs | null | undefined, +): number | null { + if (!prefs?.items.request_status?.enabled || !data.request_status) return null; + const percent = data.request_download_percent; + return percent == null ? null : Math.min(100, Math.max(0, percent)); +} diff --git a/web/src/lib/overlays/types.ts b/web/src/lib/overlays/types.ts index 8e917dc53f..8795933336 100644 --- a/web/src/lib/overlays/types.ts +++ b/web/src/lib/overlays/types.ts @@ -38,6 +38,7 @@ export type OverlayId = | "network" // ribbons (status / awards) | "show_status" + | "request_status" | "imdb_top_250" | "rt_certified_fresh"; @@ -72,6 +73,12 @@ export interface OverlayData { show_status?: string; imdb_top_250?: number | null; rt_certified_fresh?: boolean | null; + // request status: only titles outside the library carry these, so library + // cards never show the badge. + request_status?: string; // badge label: "Downloading 43%", "Out Dec 18" + request_status_icon?: OverlayIconId | null; + request_status_attention?: boolean; // TMDB lost the title; paints amber + request_download_percent?: number | null; // drives the card's download bar } // Per-overlay user configuration. accentColor and showIcon are optional @@ -144,6 +151,9 @@ export interface OverlayDef { introducedInManifest?: number; getValue: (data: OverlayData) => string | null; getIcon?: (data: OverlayData) => OverlayIconId | null; // dynamic icon by data + // Accent by data, used when the viewer has not picked an accent for the + // badge; null falls back to defaultAccent. + getAccent?: (data: OverlayData) => string | null; } // Typed icon identifiers — every icon used anywhere must be in this union. @@ -167,6 +177,9 @@ export type OverlayIconId = | "calendar" | "globe" | "users" + | "download" + | "hourglass" + | "alert" // brand marks (inline SVG) | "hdr10" | "hdr" diff --git a/web/src/lib/overlays/ui-constants.ts b/web/src/lib/overlays/ui-constants.ts index 6ee0afc37d..4014d46a2d 100644 --- a/web/src/lib/overlays/ui-constants.ts +++ b/web/src/lib/overlays/ui-constants.ts @@ -35,7 +35,8 @@ export const CATEGORY_META: Record = { ribbons: { category: "ribbons", title: "Status & Awards", - description: "Series lifecycle and award badges. Some require upcoming data sources.", + description: + "Series lifecycle, request status and award badges. Some require upcoming data sources.", }, }; diff --git a/web/src/lib/settingsConformance.json b/web/src/lib/settingsConformance.json index f63d46fa00..5b58f8e6d2 100644 --- a/web/src/lib/settingsConformance.json +++ b/web/src/lib/settingsConformance.json @@ -1,6 +1,6 @@ { "fixture_version": 1, - "manifest_revision": 14, + "manifest_revision": 15, "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", "cases": [ { diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts index 0148b959cb..8d514ced40 100644 --- a/web/src/lib/settingsContract.ts +++ b/web/src/lib/settingsContract.ts @@ -9,7 +9,7 @@ */ export const SETTINGS_API_VERSION = 1; -export const SETTINGS_REVISION = 14; +export const SETTINGS_REVISION = 15; export interface SettingSuggestedOption { value: string; @@ -264,6 +264,8 @@ export const SETTING_KEYS = { PLAYER_VIDEO_SKIP_BACK_SECONDS: "player.video_skip_back_seconds", /** Video fast-forward interval */ PLAYER_VIDEO_SKIP_FORWARD_SECONDS: "player.video_skip_forward_seconds", + /** Request titles I add to my watchlist */ + REQUESTS_WATCHLIST_AUTO_REQUEST: "requests.watchlist_auto_request", /** Search scope */ SEARCH_MEDIA_SCOPE: "search.media_scope", /** Match device caption settings */ @@ -1142,6 +1144,22 @@ export const SETTING_DEFINITIONS: Record = { { value: 90, label: "90 seconds", introducedIn: 9 }, ], }, + "requests.watchlist_auto_request": { + key: "requests.watchlist_auto_request", + type: "boolean", + nullable: false, + persistence: "remote", + introducedIn: 15, + scopes: ["profile"], + scopeIntroducedIn: [15], + resolutionOrder: ["profile", "default"], + defaultValue: true, + label: "Request titles I add to my watchlist", + description: + "When you add a title that is not in the library to your watchlist, also request it.", + category: "requests", + control: "switch", + }, "search.media_scope": { key: "search.media_scope", type: "enum", diff --git a/web/src/lib/watchlistTitles.test.ts b/web/src/lib/watchlistTitles.test.ts new file mode 100644 index 0000000000..cd6a3eb2ed --- /dev/null +++ b/web/src/lib/watchlistTitles.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import type { WatchlistTitle } from "@/api/v2/watchlistTitles"; +import { + WATCHLIST_NOT_IN_LIBRARY_TAB, + WATCHLIST_TITLE_RANK, + parseWatchlistTab, + showWatchlistAutoRequestControl, + sortWatchlistTitlesSoonestFirst, + watchlistTitleNeedsAttention, + watchlistTitlesAvailable, + watchlistTitlesHint, + watchlistTitleStatus, +} from "./watchlistTitles"; + +const NOW = new Date(2026, 8, 30, 12); + +function title(overrides: Partial = {}): WatchlistTitle { + return { + media_type: "movie", + tmdb_id: 1, + title: "Heat", + added_at: "2026-09-01T00:00:00Z", + status: "active", + request: { requestable: true }, + ...overrides, + }; +} + +describe("watchlistTitleStatus", () => { + it("shows download progress on the badge and the full status in the caption", () => { + const status = watchlistTitleStatus( + title({ + request: { + requestable: false, + status: "downloading", + download: { + phase: "downloading", + percent: 43, + downloads: 1, + updated_at: NOW.toISOString(), + }, + }, + }), + NOW, + ); + expect(status.badge).toBe("Downloading 43%"); + expect(status.badgeIcon).toBe("download"); + expect(status.caption).toMatch(/^Downloading · 43%/); + expect(status.downloadPercent).toBe(43); + expect(status.rank).toBe(WATCHLIST_TITLE_RANK.downloading); + expect(status.attention).toBe(false); + }); + + it("leads with the release date while a request awaits approval", () => { + const status = watchlistTitleStatus( + title({ release_date: "2026-12-18", request: { requestable: false, status: "pending" } }), + NOW, + ); + expect(status.badge).toMatch(/^Out /); + expect(status.badgeIcon).toBe("calendar"); + expect(status.caption).toBe(`${status.badge} · awaiting approval`); + expect(status.rank).toBe(WATCHLIST_TITLE_RANK.dated); + }); + + it("reads an undated pending request as awaiting approval", () => { + const status = watchlistTitleStatus( + title({ release_date: "2001-01-01", request: { requestable: false, status: "pending" } }), + NOW, + ); + expect(status.badge).toBe("Awaiting approval"); + expect(status.caption).toBe("Awaiting approval"); + expect(status.rank).toBe(WATCHLIST_TITLE_RANK.awaitingApproval); + }); + + it("says an approved request is waiting for a download", () => { + const status = watchlistTitleStatus( + title({ request: { requestable: false, status: "approved" } }), + NOW, + ); + expect(status.badge).toBe("Approved"); + expect(status.badgeIcon).toBe("hourglass"); + expect(status.caption).toBe("Approved · waiting for a download"); + }); + + it("offers a request only when the title is requestable", () => { + expect(watchlistTitleStatus(title(), NOW)).toMatchObject({ + badge: "Not requested", + caption: "Not requested", + requestable: true, + rank: WATCHLIST_TITLE_RANK.notRequested, + }); + expect( + watchlistTitleStatus( + title({ request: { requestable: false, reason: "quota_exceeded" } }), + NOW, + ), + ).toMatchObject({ caption: "Not requested · request limit reached", requestable: false }); + }); + + it("flags titles TMDB lost as needing attention", () => { + expect(watchlistTitleStatus(title({ status: "needs_review" }), NOW)).toMatchObject({ + badge: "Needs attention", + badgeIcon: "alert", + caption: "TMDB lists it twice", + attention: true, + requestable: false, + }); + expect(watchlistTitleStatus(title({ status: "removed" }), NOW)).toMatchObject({ + badge: "Not on TMDB", + caption: "No longer listed on TMDB", + attention: true, + }); + expect(watchlistTitleNeedsAttention({ status: "removed" })).toBe(true); + expect(watchlistTitleNeedsAttention({ status: "some_future_value" })).toBe(false); + }); +}); + +describe("sortWatchlistTitlesSoonestFirst", () => { + it("orders downloading, dated, awaiting approval, not requested, then attention", () => { + const download = { + phase: "downloading", + percent: 10, + downloads: 1, + updated_at: NOW.toISOString(), + }; + const titles = [ + title({ tmdb_id: 5, status: "removed" }), + title({ tmdb_id: 4 }), + title({ tmdb_id: 3, request: { requestable: false, status: "pending" } }), + title({ + tmdb_id: 22, + release_date: "2027-03-01", + request: { requestable: false, status: "approved" }, + }), + title({ + tmdb_id: 21, + release_date: "2026-11-01", + request: { requestable: false, status: "approved" }, + }), + title({ tmdb_id: 1, request: { requestable: false, status: "downloading", download } }), + ]; + expect(sortWatchlistTitlesSoonestFirst(titles, NOW).map((t) => t.tmdb_id)).toEqual([ + 1, 21, 22, 3, 4, 5, + ]); + }); + + it("puts the most recently added first within a group of the same date", () => { + const titles = [ + title({ tmdb_id: 1, added_at: "2026-09-01T00:00:00Z" }), + title({ tmdb_id: 2, added_at: "2026-09-20T00:00:00Z" }), + ]; + expect(sortWatchlistTitlesSoonestFirst(titles, NOW).map((t) => t.tmdb_id)).toEqual([2, 1]); + }); +}); + +describe("parseWatchlistTab", () => { + it("reads only the not-in-library value as the titles tab", () => { + expect(parseWatchlistTab(WATCHLIST_NOT_IN_LIBRARY_TAB)).toBe("not-in-library"); + expect(parseWatchlistTab(null)).toBe("library"); + expect(parseWatchlistTab("anything")).toBe("library"); + }); +}); + +describe("watchlistTitlesHint", () => { + it("drops the requested sentence when watchlist requests are off", () => { + expect(watchlistTitlesHint(true)).toBe( + "These titles aren't in the library yet, so they can't be played. They've been requested for you. When one arrives, it moves to In your library and you get a notification.", + ); + expect(watchlistTitlesHint(false)).toBe( + "These titles aren't in the library yet, so they can't be played. When one arrives, it moves to In your library.", + ); + }); +}); + +describe("watchlistTitlesAvailable", () => { + it("needs both requests and watchlist titles on", () => { + expect( + watchlistTitlesAvailable({ requests_enabled: true, watchlist_titles_supported: true }), + ).toBe(true); + expect( + watchlistTitlesAvailable({ requests_enabled: false, watchlist_titles_supported: true }), + ).toBe(false); + expect( + watchlistTitlesAvailable({ requests_enabled: true, watchlist_titles_supported: false }), + ).toBe(false); + expect(watchlistTitlesAvailable(undefined)).toBe(false); + }); +}); + +describe("showWatchlistAutoRequestControl", () => { + const status = { requests_enabled: true, allowed: true, watchlist_titles_supported: true }; + + it("shows the switch while watchlist adds request", () => { + expect(showWatchlistAutoRequestControl({ ...status, watchlist_requests: true }, true)).toBe( + true, + ); + }); + + it("keeps the switch for a profile that opted out, so it can opt back in", () => { + expect(showWatchlistAutoRequestControl({ ...status, watchlist_requests: false }, false)).toBe( + true, + ); + }); + + it("hides the switch when the server setting is off", () => { + expect(showWatchlistAutoRequestControl({ ...status, watchlist_requests: false }, true)).toBe( + false, + ); + }); + + it("hides the switch when the viewer cannot request or the server is older", () => { + expect( + showWatchlistAutoRequestControl( + { ...status, allowed: false, watchlist_requests: true }, + true, + ), + ).toBe(false); + expect(showWatchlistAutoRequestControl({ allowed: true, watchlist_requests: true }, true)).toBe( + false, + ); + expect(showWatchlistAutoRequestControl(undefined, false)).toBe(false); + expect( + showWatchlistAutoRequestControl( + { ...status, requests_enabled: false, watchlist_requests: false }, + false, + ), + ).toBe(false); + }); +}); diff --git a/web/src/lib/watchlistTitles.ts b/web/src/lib/watchlistTitles.ts new file mode 100644 index 0000000000..967eebdf8a --- /dev/null +++ b/web/src/lib/watchlistTitles.ts @@ -0,0 +1,261 @@ +import type { WatchlistTitle } from "@/api/v2/watchlistTitles"; +import { preferredDateLocale } from "@/lib/datetime"; +import { + formatRequestDisplayState, + formatRequestReason, + requestDisplayState, +} from "@/lib/mediaRequests"; +import { formatRequestDownload, requestDownloadPercent } from "@/lib/requestDownload"; +import type { OverlayIconId } from "@/lib/overlays"; + +/** + * How a watchlist title outside the library reads on its card: a short badge + * for the poster overlay, and a caption that always carries the full status in + * words, so the card stays clear with overlays turned off. + */ +export interface WatchlistTitleStatus { + /** The request_status badge: "Downloading 43%", "Out Dec 18", "Awaiting approval". */ + badge: string; + badgeIcon: OverlayIconId | null; + /** The caption before any "Find it" link: "Out Dec 18 · awaiting approval". */ + caption: string; + /** TMDB no longer has the title as saved; the viewer has to find it again. */ + attention: boolean; + /** Download progress for the card's bar, while a download reports a size. */ + downloadPercent?: number; + /** The title has no active request, and the viewer may request it now. */ + requestable: boolean; + /** Sort group for "Soonest first"; lower sorts first. */ + rank: WatchlistTitleRank; +} + +/** + * Soonest first: downloading, then by release date, then awaiting approval, + * then not requested, then needs attention. + */ +export const WATCHLIST_TITLE_RANK = { + downloading: 0, + dated: 1, + awaitingApproval: 2, + notRequested: 3, + attention: 4, +} as const; +export type WatchlistTitleRank = (typeof WATCHLIST_TITLE_RANK)[keyof typeof WATCHLIST_TITLE_RANK]; + +/** A YYYY-MM-DD calendar date as local midnight, or null when unparseable. */ +function calendarDate(value: string | undefined): Date | null { + const match = value ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null; + if (!match) return null; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return Number.isNaN(date.getTime()) ? null : date; +} + +function startOfDay(now: Date): Date { + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +/** "Dec 18", with the year when it isn't this year. */ +function formatReleaseDay(date: Date, now: Date): string { + return date.toLocaleDateString(preferredDateLocale(), { + month: "short", + day: "numeric", + ...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}), + }); +} + +/** Whether TMDB lost the title as saved: needs_review or removed. */ +export function watchlistTitleNeedsAttention(title: Pick): boolean { + return title.status === "needs_review" || title.status === "removed"; +} + +export function watchlistTitleStatus( + title: WatchlistTitle, + now: Date = new Date(), +): WatchlistTitleStatus { + const request = title.request; + if (title.status === "needs_review") { + return { + badge: "Needs attention", + badgeIcon: "alert", + caption: "TMDB lists it twice", + attention: true, + requestable: false, + rank: WATCHLIST_TITLE_RANK.attention, + }; + } + if (title.status === "removed") { + return { + badge: "Not on TMDB", + badgeIcon: "alert", + caption: "No longer listed on TMDB", + attention: true, + requestable: false, + rank: WATCHLIST_TITLE_RANK.attention, + }; + } + + const state = requestDisplayState(request.status, undefined, request.state); + const release = calendarDate(title.release_date); + const upcoming = release !== null && release.getTime() > startOfDay(now).getTime(); + const out = upcoming && release ? `Out ${formatReleaseDay(release, now)}` : null; + + if (request.download) { + const percent = requestDownloadPercent(request.download); + const downloading = request.download.phase === "downloading"; + return { + badge: + downloading && percent !== undefined + ? `Downloading ${percent}%` + : formatRequestDownload(request.download, { now }).split(" · ")[0]!, + badgeIcon: "download", + caption: formatRequestDownload(request.download, { now }), + attention: false, + downloadPercent: percent, + requestable: false, + rank: WATCHLIST_TITLE_RANK.downloading, + }; + } + + switch (state) { + case undefined: + case "declined": + case "cancelled": + case "failed": { + const refused = !request.requestable && request.reason ? request.reason : undefined; + const detail = refused ? formatRequestReason(refused).toLowerCase() : null; + return { + badge: "Not requested", + badgeIcon: null, + caption: ["Not requested", detail ?? (out ? out.toLowerCase() : null)] + .filter(Boolean) + .join(" · "), + attention: false, + requestable: request.requestable, + rank: WATCHLIST_TITLE_RANK.notRequested, + }; + } + case "processing": + return { + badge: "Processing", + badgeIcon: "download", + caption: "Processing", + attention: false, + requestable: false, + rank: WATCHLIST_TITLE_RANK.downloading, + }; + case "pending": + return { + badge: out ?? "Awaiting approval", + badgeIcon: out ? "calendar" : "clock", + caption: out ? `${out} · awaiting approval` : "Awaiting approval", + attention: false, + requestable: false, + rank: out ? WATCHLIST_TITLE_RANK.dated : WATCHLIST_TITLE_RANK.awaitingApproval, + }; + case "approved": + return { + badge: out ?? "Approved", + badgeIcon: out ? "calendar" : "hourglass", + caption: out ? `${out} · approved` : "Approved · waiting for a download", + attention: false, + requestable: false, + rank: WATCHLIST_TITLE_RANK.dated, + }; + default: { + const label = formatRequestDisplayState(state); + return { + badge: label, + badgeIcon: null, + caption: label, + attention: false, + requestable: false, + rank: WATCHLIST_TITLE_RANK.dated, + }; + } + } +} + +function releaseTime(title: WatchlistTitle): number { + return calendarDate(title.release_date)?.getTime() ?? Number.POSITIVE_INFINITY; +} + +function addedTime(title: WatchlistTitle): number { + const t = Date.parse(title.added_at); + return Number.isFinite(t) ? t : 0; +} + +/** + * "Soonest first": downloading, then by release date, then awaiting approval, + * then not requested, then needs attention. Within a group, the earlier + * release date first (unknown dates last), then the most recently added. + */ +export function sortWatchlistTitlesSoonestFirst( + titles: readonly WatchlistTitle[], + now: Date = new Date(), +): WatchlistTitle[] { + const ranked = titles.map((title) => ({ title, rank: watchlistTitleStatus(title, now).rank })); + ranked.sort((a, b) => { + if (a.rank !== b.rank) return a.rank - b.rank; + const releaseA = releaseTime(a.title); + const releaseB = releaseTime(b.title); + if (releaseA !== releaseB) return releaseA < releaseB ? -1 : 1; + return addedTime(b.title) - addedTime(a.title); + }); + return ranked.map((entry) => entry.title); +} + +/** The `?tab=` value of the watchlist's "Not in your library yet" tab. */ +export const WATCHLIST_NOT_IN_LIBRARY_TAB = "not-in-library"; + +export type WatchlistTab = "library" | typeof WATCHLIST_NOT_IN_LIBRARY_TAB; + +/** The watchlist tab a `?tab=` value names; anything else is the library tab. */ +export function parseWatchlistTab(value: string | null): WatchlistTab { + return value === WATCHLIST_NOT_IN_LIBRARY_TAB ? WATCHLIST_NOT_IN_LIBRARY_TAB : "library"; +} + +/** + * The hint at the top of the titles tab. The request and notification clauses + * hold only while adds request: the notification comes from the request. + */ +export function watchlistTitlesHint(watchlistRequests: boolean): string { + return [ + "These titles aren't in the library yet, so they can't be played.", + watchlistRequests + ? "They've been requested for you. When one arrives, it moves to In your library and you get a notification." + : "When one arrives, it moves to In your library.", + ].join(" "); +} + +/** + * Whether the watchlist can hold titles the library doesn't have. The titles + * belong to the requests surface: with requests off their operations answer + * 409 capability_disabled, so both flags must hold. + */ +export function watchlistTitlesAvailable( + status: { requests_enabled?: boolean; watchlist_titles_supported?: boolean } | undefined, +): boolean { + return status?.requests_enabled === true && status.watchlist_titles_supported === true; +} + +/** + * Whether the profile's watchlist auto-request switch applies. The request + * status reports the effective answer (server setting, this profile's opt-in, + * and whether the viewer may request), not the server setting on its own. A + * profile that opted out still sees the switch so it can opt back in; with its + * opt-in on and the effective answer off, the server setting is off. + */ +export function showWatchlistAutoRequestControl( + status: + | { + requests_enabled?: boolean; + allowed?: boolean; + watchlist_titles_supported?: boolean; + watchlist_requests?: boolean; + } + | undefined, + profileValue: boolean, +): boolean { + if (!watchlistTitlesAvailable(status) || !status?.allowed) return false; + return status.watchlist_requests === true || profileValue === false; +} diff --git a/web/src/pages/Catalog.collectionUnavailable.test.tsx b/web/src/pages/Catalog.collectionUnavailable.test.tsx index b18b48f0c8..2bbf8c0f02 100644 --- a/web/src/pages/Catalog.collectionUnavailable.test.tsx +++ b/web/src/pages/Catalog.collectionUnavailable.test.tsx @@ -26,6 +26,10 @@ vi.mock("@/hooks/useCanRequest", () => ({ })); vi.mock("@/hooks/queries/useRequests", () => ({ useRequestSearch: () => ({ data: undefined, isLoading: false }), + useRequestFeatureStatus: () => ({ data: undefined }), +})); +vi.mock("@/hooks/queries/watchlistTitles", () => ({ + useWatchlistTitles: () => ({ data: undefined, isLoading: false, isError: false }), })); vi.mock("@/components/ItemGrid", () => ({ default: () =>
})); vi.mock("@/components/catalog/CatalogFiltersPanel", () => ({ default: () => null })); diff --git a/web/src/pages/Catalog.test.tsx b/web/src/pages/Catalog.test.tsx index aa82ee7a0d..a4f30f7185 100644 --- a/web/src/pages/Catalog.test.tsx +++ b/web/src/pages/Catalog.test.tsx @@ -56,6 +56,11 @@ vi.mock("@/hooks/useCanRequest", () => ({ vi.mock("@/hooks/queries/useRequests", () => ({ useRequestSearch: (...args: unknown[]) => mockUseRequestSearch(...args), + useRequestFeatureStatus: () => ({ data: undefined }), +})); + +vi.mock("@/hooks/queries/watchlistTitles", () => ({ + useWatchlistTitles: () => ({ data: undefined, isLoading: false, isError: false }), })); vi.mock("@/components/RequestToAddSection", () => ({ diff --git a/web/src/pages/Catalog.tsx b/web/src/pages/Catalog.tsx index 82cdf7332e..ffa2450814 100644 --- a/web/src/pages/Catalog.tsx +++ b/web/src/pages/Catalog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import { CheckSquare, RefreshCw, Search, Trash2, X } from "lucide-react"; @@ -19,7 +19,17 @@ import { useSetCollectionSortPreference } from "@/hooks/queries/collections"; import { querySortToSelectValue } from "@/lib/collectionSortConfig"; import { useSearchMediaScope, type SearchMediaScope } from "@/hooks/useSearchMediaScope"; import { useRemoveHistory } from "@/hooks/queries/history"; -import { useRequestSearch } from "@/hooks/queries/useRequests"; +import { useRequestFeatureStatus, useRequestSearch } from "@/hooks/queries/useRequests"; +import { useWatchlistTitles } from "@/hooks/queries/watchlistTitles"; +import WatchlistTabs, { WatchlistTabPanel } from "@/components/watchlist/WatchlistTabs"; +import WatchlistTitlesTab from "@/components/watchlist/WatchlistTitlesTab"; +import { + parseWatchlistTab, + WATCHLIST_NOT_IN_LIBRARY_TAB, + watchlistTitleNeedsAttention, + watchlistTitlesAvailable, + type WatchlistTab, +} from "@/lib/watchlistTitles"; import { useCanRequest } from "@/hooks/useCanRequest"; import { useDebounce } from "@/hooks/useDebounce"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; @@ -176,7 +186,37 @@ function CatalogResults({ [searchParams, setPreferredScope, setSearchParams], ); - const showExactResultCount = state.source !== "section" && !isQuerySource; + // The watchlist splits into the library grid and the titles the library + // doesn't have yet, once the server keeps watchlist entries for those. + const isWatchlistSource = state.source === "watchlist"; + const requestFeatureStatus = useRequestFeatureStatus({ enabled: isWatchlistSource }); + const showWatchlistTabs = + isWatchlistSource && watchlistTitlesAvailable(requestFeatureStatus.data); + const watchlistTitles = useWatchlistTitles({ enabled: showWatchlistTabs }); + const watchlistTabsId = useId(); + const watchlistTab: WatchlistTab = showWatchlistTabs + ? parseWatchlistTab(searchParams.get("tab")) + : "library"; + const showingWatchlistTitles = watchlistTab === WATCHLIST_NOT_IN_LIBRARY_TAB; + const watchlistNeedsAttention = useMemo( + () => (watchlistTitles.data ?? []).some(watchlistTitleNeedsAttention), + [watchlistTitles.data], + ); + const setWatchlistTab = useCallback( + (tab: WatchlistTab) => { + const next = new URLSearchParams(searchParams); + if (tab === WATCHLIST_NOT_IN_LIBRARY_TAB) { + next.set("tab", tab); + } else { + next.delete("tab"); + } + setSearchParams(next); + }, + [searchParams, setSearchParams], + ); + + const showExactResultCount = + state.source !== "section" && !isQuerySource && !showingWatchlistTitles; const catalogQuery = useCatalogWindow(effectiveState, { limit, visibleRange, @@ -375,160 +415,191 @@ function CatalogResults({
- {state.source === "query" ? ( -
- - -
+ {showWatchlistTabs ? ( + ) : null} - { - const sortChanged = - nextState.uses_source_order !== sortedState.uses_source_order || - querySortToSelectValue(nextState.query_definition.sort) !== - querySortToSelectValue(sortedState.query_definition.sort); - const stateForNavigation = sortChanged - ? { ...nextState, sort_from_server: false, explicit_sort: true } - : nextState; - rememberCollectionSort(stateForNavigation); - const nextSearchParams = buildCatalogFilterSearchParams(stateForNavigation); - if (nextSearchParams.toString() !== searchParams.toString()) { - setSearchParams(nextSearchParams); - } - }} - allowLibrarySelection={!isCollectionSource} - allowPersonalizedFilters={allowPersonalizedOverlayControls} - allowPersonalizedSorts={ - isHistorySource - ? "date_viewed" - : state.source === "favorites" || state.source === "watchlist" - ? false - : allowPersonalizedOverlayControls - } - /> + {showingWatchlistTitles ? ( + + void watchlistTitles.refetch()} + watchlistRequests={requestFeatureStatus.data?.watchlist_requests === true} + /> + + ) : ( + + {state.source === "query" ? ( +
+ + +
+ ) : null} - {isHistorySource && ( -
-
-

Watch History

-

- Removing items clears watch history, watched status, and resume progress for this - profile. -

-
-
- {!selectionMode ? ( - - ) : ( - <> - {selectedIds.size} selected - - - - - - )} -
-
- )} + { + const sortChanged = + nextState.uses_source_order !== sortedState.uses_source_order || + querySortToSelectValue(nextState.query_definition.sort) !== + querySortToSelectValue(sortedState.query_definition.sort); + const stateForNavigation = sortChanged + ? { ...nextState, sort_from_server: false, explicit_sort: true } + : nextState; + rememberCollectionSort(stateForNavigation); + const nextSearchParams = buildCatalogFilterSearchParams(stateForNavigation); + if (nextSearchParams.toString() !== searchParams.toString()) { + setSearchParams(nextSearchParams); + } + }} + allowLibrarySelection={!isCollectionSource} + allowPersonalizedFilters={allowPersonalizedOverlayControls} + allowPersonalizedSorts={ + isHistorySource + ? "date_viewed" + : state.source === "favorites" || state.source === "watchlist" + ? false + : allowPersonalizedOverlayControls + } + /> + + {isHistorySource && ( +
+
+

Watch History

+

+ Removing items clears watch history, watched status, and resume progress for this + profile. +

+
+
+ {!selectionMode ? ( + + ) : ( + <> + + {selectedIds.size} selected + + + + + + + )} +
+
+ )} - {showPeopleSection ? ( -
-

People

- {peopleQuery.isError ? ( -
-

Could not load people results.

- +
+ ) : peopleQuery.isLoading ? ( +

+ Searching people... +

+ ) : ( + ({ + person_id: person.id, + name: person.name, + photo_url: person.photo_url, + character: "", + order: index, + }))} + /> + )} +
+ ) : null} + + {catalogQuery.isError ? ( +
+

+ {isQuerySource + ? "Could not load search results." + : "Could not load catalog results."} +

+

+ {isQuerySource + ? "The search request failed. Please retry." + : "The catalog request failed. Please retry."} +

+
- ) : peopleQuery.isLoading ? ( -

- Searching people... -

- ) : ( - ({ - person_id: person.id, - name: person.name, - photo_url: person.photo_url, - character: "", - order: index, - }))} + ) : tmdbMayRescueLibrary || (libraryEmpty && showPeopleSection) ? null : ( + )} - - ) : null} - - {catalogQuery.isError ? ( -
-

- {isQuerySource ? "Could not load search results." : "Could not load catalog results."} -

-

- {isQuerySource - ? "The search request failed. Please retry." - : "The catalog request failed. Please retry."} -

- -
- ) : tmdbMayRescueLibrary || (libraryEmpty && showPeopleSection) ? null : ( - +
)} {isQuerySource && canRequest.discoveryEnabled && requestSearchType ? ( diff --git a/web/src/pages/Catalog.watchlistTabs.test.tsx b/web/src/pages/Catalog.watchlistTabs.test.tsx new file mode 100644 index 0000000000..4aed1e676b --- /dev/null +++ b/web/src/pages/Catalog.watchlistTabs.test.tsx @@ -0,0 +1,208 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { WatchlistTitle } from "@/api/v2/watchlistTitles"; + +const mocks = vi.hoisted(() => ({ + featureStatus: vi.fn(), + watchlistTitles: vi.fn(), + titlesTab: vi.fn(), +})); + +vi.mock("@/hooks/queries/catalog", () => ({ + useCatalogWindow: () => ({ + data: { + title: "Watchlist", + totalItems: 1, + pages: new Map([[0, [{ content_id: "movie-1", title: "Heat", type: "movie" }]]]), + }, + isLoading: false, + isError: false, + isPlaceholderData: false, + error: null, + sourceError: null, + refetch: vi.fn(), + }), + useCatalogFilters: () => ({ data: undefined, isLoading: false }), + useCatalogMetadataFilters: () => ({ data: undefined, isLoading: false }), +})); +vi.mock("@/hooks/queries/personSearch", () => ({ + usePersonSearch: () => ({ data: undefined, isLoading: false, isError: false }), +})); +vi.mock("@/hooks/useCanRequest", () => ({ + useCanRequest: () => ({ discoveryEnabled: false, isResolving: false }), +})); +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestSearch: () => ({ data: undefined, isLoading: false }), + useRequestFeatureStatus: () => mocks.featureStatus(), +})); +vi.mock("@/hooks/queries/watchlistTitles", () => ({ + useWatchlistTitles: (options: { enabled?: boolean }) => mocks.watchlistTitles(options), +})); +vi.mock("@/components/watchlist/WatchlistTitlesTab", () => ({ + default: (props: { watchlistRequests: boolean; titles?: WatchlistTitle[] }) => { + mocks.titlesTab(props); + return
; + }, +})); +vi.mock("@/components/ItemGrid", () => ({ default: () =>
})); +vi.mock("@/components/catalog/CatalogFiltersPanel", () => ({ + default: () =>
, +})); + +import Catalog from "./Catalog"; + +function title(tmdbID: number, status = "active"): WatchlistTitle { + return { + media_type: "movie", + tmdb_id: tmdbID, + title: `Title ${tmdbID}`, + added_at: "2026-09-01T00:00:00Z", + status, + request: { requestable: true }, + }; +} + +function LocationProbe() { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function renderCatalog(href: string) { + render( + + + + + + , + ); +} + +function selectTab(name: RegExp) { + // Radix tabs activate on a primary-button mouse down. + fireEvent.mouseDown(screen.getByRole("tab", { name }), { button: 0, ctrlKey: false }); +} + +beforeEach(() => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: true, + }, + }); + mocks.watchlistTitles.mockReturnValue({ + data: [title(1), title(2)], + isLoading: false, + isError: false, + refetch: vi.fn(), + }); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +it("opens on the library tab and keeps the other tab in the URL", () => { + renderCatalog("/catalog?source=watchlist"); + + expect(screen.getByRole("tab", { name: "In your library" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByTestId("item-grid")).toBeInTheDocument(); + expect(screen.getByTestId("catalog-filters")).toBeInTheDocument(); + expect(screen.queryByTestId("watchlist-titles-tab")).toBeNull(); + + selectTab(/Not in your library yet/); + + expect(screen.getByTestId("location").textContent).toBe( + "/catalog?source=watchlist&tab=not-in-library", + ); + expect(screen.getByTestId("watchlist-titles-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("item-grid")).toBeNull(); + expect(screen.queryByTestId("catalog-filters")).toBeNull(); + + selectTab(/In your library/); + expect(screen.getByTestId("location").textContent).toBe("/catalog?source=watchlist"); +}); + +it("labels each tab's content as its tab panel", () => { + renderCatalog("/catalog?source=watchlist"); + const checkPanel = (tabName: RegExp, content: string) => { + const tab = screen.getByRole("tab", { name: tabName }); + const panel = screen.getByRole("tabpanel", { name: tabName }); + expect(tab).toHaveAttribute("aria-controls", panel.id); + expect(panel).toContainElement(screen.getByTestId(content)); + }; + checkPanel(/In your library/, "item-grid"); + expect(screen.getByRole("tabpanel")).toContainElement(screen.getByTestId("catalog-filters")); + + selectTab(/Not in your library yet/); + checkPanel(/Not in your library yet/, "watchlist-titles-tab"); +}); + +it("restores the titles tab from the URL and passes whether adds request", () => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: false, + }, + }); + renderCatalog("/catalog?source=watchlist&tab=not-in-library"); + + const tab = screen.getByRole("tab", { name: /Not in your library yet/ }); + expect(tab).toHaveAttribute("aria-selected", "true"); + expect(mocks.titlesTab).toHaveBeenLastCalledWith( + expect.objectContaining({ watchlistRequests: false }), + ); +}); + +it("counts the titles and flags ones that need attention", () => { + renderCatalog("/catalog?source=watchlist"); + const tab = screen.getByRole("tab", { name: /Not in your library yet/ }); + expect(tab).toHaveTextContent("2"); + // Narrow screens read the shorter label. + expect(tab.querySelector(".sm\\:hidden")?.textContent).toBe("Not in library"); + expect(screen.queryByTestId("watchlist-attention-dot")).toBeNull(); + cleanup(); + + mocks.watchlistTitles.mockReturnValue({ + data: [title(1), title(2, "needs_review")], + isLoading: false, + isError: false, + refetch: vi.fn(), + }); + renderCatalog("/catalog?source=watchlist"); + expect(screen.getByTestId("watchlist-attention-dot")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /some need attention/ })).toBeInTheDocument(); +}); + +it.each([ + [ + "a server without watchlist titles", + { requests_enabled: true, watchlist_titles_supported: false }, + ], + // The titles operations answer 409 capability_disabled while requests are off. + ["requests turned off", { requests_enabled: false, watchlist_titles_supported: true }], +])("shows no tabs with %s", (_, status) => { + mocks.featureStatus.mockReturnValue({ data: { allowed: true, ...status } }); + renderCatalog("/catalog?source=watchlist&tab=not-in-library"); + + expect(screen.queryByRole("tab")).toBeNull(); + expect(screen.queryByTestId("watchlist-titles-tab")).toBeNull(); + expect(screen.getByTestId("item-grid")).toBeInTheDocument(); + expect(screen.queryByRole("tabpanel")).toBeNull(); + expect(mocks.watchlistTitles).toHaveBeenLastCalledWith({ enabled: false }); +}); + +it("shows no tabs outside the watchlist", () => { + renderCatalog("/catalog?source=favorites&tab=not-in-library"); + expect(screen.queryByRole("tab")).toBeNull(); + expect(mocks.watchlistTitles).toHaveBeenLastCalledWith({ enabled: false }); +}); diff --git a/web/src/pages/ItemDetail/ExternalTitleContent.tsx b/web/src/pages/ItemDetail/ExternalTitleContent.tsx index ffa8820a6c..105469ca05 100644 --- a/web/src/pages/ItemDetail/ExternalTitleContent.tsx +++ b/web/src/pages/ItemDetail/ExternalTitleContent.tsx @@ -13,6 +13,7 @@ import { MoreLikeThisRow } from "@/components/RecommendationGrid"; import RequestPosterCard from "@/components/RequestPosterCard"; import { SeasonStatus } from "@/components/RequestSeasonsDialog"; import { useCreateMediaRequest } from "@/hooks/queries/useRequests"; +import { useWatchlistTitleToggle } from "@/hooks/useWatchlistTitleToggle"; import { formatRuntimeMinutes } from "@/lib/mediaFormat"; import { formatRequestSeasonMeta, @@ -137,6 +138,7 @@ function TitleSeasons({ seasons }: { seasons: RequestMediaSeason[] }) { /** "More Like This" for a TMDB title: request cards, each able to request its title. */ function TitleRecommendations({ items }: { items: RequestMediaResult[] }) { const createRequest = useCreateMediaRequest(); + const watchlist = useWatchlistTitleToggle(); return ( createRequest.mutate(requestInputFromMediaResult(item))} + onToggleWatchlist={watchlist.enabled ? () => watchlist.toggle(item) : undefined} + isWatchlistPending={watchlist.isPending(item)} /> )} /> diff --git a/web/src/pages/ItemDetail/components/RequestActionBar.test.tsx b/web/src/pages/ItemDetail/components/RequestActionBar.test.tsx new file mode 100644 index 0000000000..f231c943f7 --- /dev/null +++ b/web/src/pages/ItemDetail/components/RequestActionBar.test.tsx @@ -0,0 +1,205 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestMediaDetail } from "@/api/types"; +import type { WatchlistTitleEntry } from "@/api/v2/watchlistTitles"; +import { requestKeys } from "@/hooks/queries/keys"; +import RequestActionBar from "./RequestActionBar"; + +const mocks = vi.hoisted(() => ({ + featureStatus: vi.fn(), + addWatchlistTitle: vi.fn(), + deleteWatchlistTitle: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", async () => { + const actual = await vi.importActual( + "@/hooks/queries/useRequests", + ); + const idle = { mutate: vi.fn(), isPending: false }; + return { + ...actual, + useRequestFeatureStatus: () => mocks.featureStatus(), + useCreateMediaRequest: () => idle, + useCancelMediaRequest: () => idle, + useToggleRequestFollow: () => idle, + useMediaRequest: () => ({ data: undefined }), + useMyMediaRequests: () => ({ data: undefined }), + }; +}); +vi.mock("@/api/v2/watchlistTitles", () => ({ + addWatchlistTitleV2: (...args: unknown[]) => mocks.addWatchlistTitle(...args), + deleteWatchlistTitleV2: (...args: unknown[]) => mocks.deleteWatchlistTitle(...args), + listWatchlistTitlesV2: vi.fn(), +})); +vi.mock("@/playback/watchPlaybackContext", () => ({ + useWatchPlaybackController: () => ({ startPlayback: vi.fn() }), +})); +vi.mock("./SubtitlesPopover", () => ({ default: () => null })); +vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { id: 1 } }) })); +vi.mock("sonner", () => ({ + toast: { success: mocks.toastSuccess, error: mocks.toastError }, +})); + +const heat: RequestMediaDetail = { + media_type: "movie", + tmdb_id: 949, + title: "Heat", + availability: "missing", + in_watchlist: false, + request: { requestable: true }, +} as RequestMediaDetail; + +function renderBar(item: RequestMediaDetail, queryClient = new QueryClient()) { + render( + + + + + , + ); + return queryClient; +} + +const NOTE = /Adding it to your watchlist also requests it/; + +describe("RequestActionBar watchlist toggle", () => { + beforeEach(() => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: true, + }, + }); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("offers Add to Watchlist and says the add also requests", () => { + renderBar(heat); + const button = screen.getByRole("button", { name: "Add to Watchlist" }); + expect(button).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByText(NOTE)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Settings › Requests" })).toHaveAttribute( + "href", + "/settings/requests", + ); + }); + + it("drops the note when watchlist adds don't request or the title can't be requested", () => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: false, + }, + }); + renderBar(heat); + expect(screen.queryByText(NOTE)).toBeNull(); + cleanup(); + + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: true, + }, + }); + renderBar({ ...heat, request: { requestable: false, status: "pending" } }); + expect(screen.queryByText(NOTE)).toBeNull(); + }); + + it("shows On Watchlist, pressed, without the note", () => { + renderBar({ ...heat, in_watchlist: true }); + expect(screen.getByRole("button", { name: "On Watchlist" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.queryByText(NOTE)).toBeNull(); + }); + + it("offers no watchlist action on a server without watchlist titles", () => { + mocks.featureStatus.mockReturnValue({ data: { allowed: true } }); + renderBar(heat); + expect(screen.queryByRole("button", { name: /Watchlist/ })).toBeNull(); + expect(screen.queryByText(NOTE)).toBeNull(); + }); + + it("offers no watchlist action while requests are off", () => { + mocks.featureStatus.mockReturnValue({ + data: { requests_enabled: false, allowed: true, watchlist_titles_supported: true }, + }); + renderBar(heat); + expect(screen.queryByRole("button", { name: /Watchlist/ })).toBeNull(); + }); + + it("adds the title and says it was also requested", async () => { + const entry: WatchlistTitleEntry = { + media_type: "movie", + tmdb_id: 949, + added_at: "2026-09-30T00:00:00Z", + request: { requestable: false, status: "pending", requested_by_viewer: true }, + }; + mocks.addWatchlistTitle.mockResolvedValue(entry); + const queryClient = new QueryClient(); + queryClient.setQueryData(requestKeys.detail("movie", 949), heat); + renderBar(heat, queryClient); + + fireEvent.click(screen.getByRole("button", { name: "Add to Watchlist" })); + + await waitFor(() => + expect(mocks.toastSuccess).toHaveBeenCalledWith("Added to your watchlist and requested", { + description: + "We'll let you know when Heat is available. It moves into your watchlist on its own.", + }), + ); + expect(mocks.addWatchlistTitle).toHaveBeenCalledWith("movie", 949); + // The title page reads as on the watchlist straight away. + expect( + queryClient.getQueryData(requestKeys.detail("movie", 949))?.in_watchlist, + ).toBe(true); + }); + + it("says why an add did not request the title", async () => { + mocks.addWatchlistTitle.mockResolvedValue({ + media_type: "movie", + tmdb_id: 949, + added_at: "2026-09-30T00:00:00Z", + request: { requestable: false, reason: "quota_exceeded" }, + } satisfies WatchlistTitleEntry); + renderBar(heat); + + fireEvent.click(screen.getByRole("button", { name: "Add to Watchlist" })); + + await waitFor(() => + expect(mocks.toastSuccess).toHaveBeenCalledWith("Added to your watchlist", { + description: "It wasn't requested: request limit reached.", + }), + ); + }); + + it("removes the title and reverts the page when the call fails", async () => { + mocks.deleteWatchlistTitle.mockRejectedValue(new Error("Network down")); + const queryClient = new QueryClient(); + const onList = { ...heat, in_watchlist: true }; + queryClient.setQueryData(requestKeys.detail("movie", 949), onList); + renderBar(onList, queryClient); + + fireEvent.click(screen.getByRole("button", { name: "On Watchlist" })); + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith("Network down")); + expect(mocks.deleteWatchlistTitle).toHaveBeenCalledWith("movie", 949); + expect( + queryClient.getQueryData(requestKeys.detail("movie", 949))?.in_watchlist, + ).toBe(true); + }); +}); diff --git a/web/src/pages/ItemDetail/components/RequestActionBar.tsx b/web/src/pages/ItemDetail/components/RequestActionBar.tsx index 9f692356be..dca87a1c71 100644 --- a/web/src/pages/ItemDetail/components/RequestActionBar.tsx +++ b/web/src/pages/ItemDetail/components/RequestActionBar.tsx @@ -3,6 +3,8 @@ import { Ban, Bell, BellOff, + Bookmark, + BookmarkCheck, CircleCheck, Clock, Hourglass, @@ -20,10 +22,13 @@ import { useCreateMediaRequest, useMediaRequest, useMyMediaRequests, + useRequestFeatureStatus, useToggleRequestFollow, } from "@/hooks/queries/useRequests"; +import { useToggleWatchlistTitle } from "@/hooks/queries/watchlistTitles"; import { useAuth } from "@/hooks/useAuth"; import { useViewTransitionNavigate } from "@/hooks/useViewTransition"; +import ViewTransitionLink from "@/components/ViewTransitionLink"; import { canCancelOwnRequest, formatRequestDisplayState, @@ -32,6 +37,7 @@ import { requestInputFromMediaResult, type RequestDisplayState, } from "@/lib/mediaRequests"; +import { watchlistTitlesAvailable } from "@/lib/watchlistTitles"; import ActionBar, { type ActionBarLink, type ActionBarPrimaryAction, @@ -71,6 +77,8 @@ export default function RequestActionBar({ item, libraryHref }: RequestActionBar const createRequest = useCreateMediaRequest(); const cancelRequest = useCancelMediaRequest(); const toggleFollow = useToggleRequestFollow(); + const toggleWatchlist = useToggleWatchlistTitle(); + const featureStatus = useRequestFeatureStatus(); const ownRequest = useOwnCancellableRequest(item); const [confirmCancel, setConfirmCancel] = useState(false); const [pickSeasons, setPickSeasons] = useState(false); @@ -114,7 +122,35 @@ export default function RequestActionBar({ item, libraryHref }: RequestActionBar primaryAction = { label: formatRequestReason(item.request.reason), icon: Ban, disabled: true }; } + const watchlistSupported = watchlistTitlesAvailable(featureStatus.data); + const inWatchlist = item.in_watchlist === true; + // The server requests a title added to the watchlist only while it has no + // request and the viewer may make one; say so before the add, not after. + const addAlsoRequests = + watchlistSupported && + featureStatus.data?.watchlist_requests === true && + !inWatchlist && + !inLibrary && + item.request.requestable; + const secondaryActions: ActionBarSecondaryAction[] = []; + if (watchlistSupported) { + secondaryActions.push({ + id: "watchlist", + label: inWatchlist ? "On Watchlist" : "Add to Watchlist", + icon: inWatchlist ? BookmarkCheck : Bookmark, + pressed: inWatchlist, + pending: toggleWatchlist.isPending, + onClick: () => + toggleWatchlist.mutate({ + mediaType: item.media_type, + tmdbID: item.tmdb_id, + title: item.title, + inWatchlist, + request: item.request, + }), + }); + } if (ownRequest) { secondaryActions.push({ id: "cancel", @@ -152,6 +188,18 @@ export default function RequestActionBar({ item, libraryHref }: RequestActionBar return ( <> + {addAlsoRequests ? ( +

+ Adding it to your watchlist also requests it. You can turn this off in{" "} + + Settings › Requests + + . +

+ ) : null} {item.request.download ? ( ) : null} diff --git a/web/src/pages/RequestBrowse.test.tsx b/web/src/pages/RequestBrowse.test.tsx index 55215b6a18..25c3f6c687 100644 --- a/web/src/pages/RequestBrowse.test.tsx +++ b/web/src/pages/RequestBrowse.test.tsx @@ -14,6 +14,9 @@ vi.mock("@/hooks/queries/useRequests", () => ({ useCreateMediaRequest: () => ({ mutateAsync: mocks.mutate, isPending: false }), })); vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: () => {} })); +vi.mock("@/hooks/useWatchlistTitleToggle", () => ({ + useWatchlistTitleToggle: () => ({ enabled: false, toggle: vi.fn(), isPending: () => false }), +})); import RequestBrowse from "./RequestBrowse"; diff --git a/web/src/pages/RequestDiscoverSection.test.tsx b/web/src/pages/RequestDiscoverSection.test.tsx index a83d6a593c..ab2fb876f3 100644 --- a/web/src/pages/RequestDiscoverSection.test.tsx +++ b/web/src/pages/RequestDiscoverSection.test.tsx @@ -14,6 +14,9 @@ vi.mock("@/hooks/queries/useRequests", () => ({ useCreateMediaRequest: () => ({ mutateAsync: vi.fn(), isPending: false }), })); vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: () => {} })); +vi.mock("@/hooks/useWatchlistTitleToggle", () => ({ + useWatchlistTitleToggle: () => ({ enabled: false, toggle: vi.fn(), isPending: () => false }), +})); import RequestDiscoverSection from "./RequestDiscoverSection"; diff --git a/web/src/pages/Requests.test.tsx b/web/src/pages/Requests.test.tsx index 665de82e34..946d8eae9d 100644 --- a/web/src/pages/Requests.test.tsx +++ b/web/src/pages/Requests.test.tsx @@ -28,6 +28,9 @@ vi.mock("@/hooks/queries/useRequests", () => { useCancelMediaRequest: () => ({ mutate: mocks.cancel, isPending: false, variables: undefined }), }; }); +vi.mock("@/hooks/useWatchlistTitleToggle", () => ({ + useWatchlistTitleToggle: () => ({ enabled: false, toggle: vi.fn(), isPending: () => false }), +})); vi.mock("@/hooks/useDocumentTitle", () => ({ useDocumentTitle: () => {} })); vi.mock("@/components/BrandCarousel", () => ({ default: () => null })); // Embla needs layout APIs jsdom lacks; MediaCarousel.test covers the real row. diff --git a/web/src/pages/Requests.tsx b/web/src/pages/Requests.tsx index 0e9a82f550..615b073e15 100644 --- a/web/src/pages/Requests.tsx +++ b/web/src/pages/Requests.tsx @@ -24,6 +24,7 @@ import { } from "@/hooks/queries/useRequests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { useSubmitMediaRequest } from "@/hooks/useSubmitMediaRequest"; +import { useWatchlistTitleToggle } from "@/hooks/useWatchlistTitleToggle"; import { useUICustomization } from "@/hooks/useUICustomization"; import { formatRelativeTime } from "@/lib/date"; import { formatDate } from "@/lib/datetime"; @@ -184,6 +185,7 @@ function DiscoverTab() { const networks = useDiscoverNetworks(); const genres = useDiscoverGenres(); const { submit, isSubmitting } = useSubmitMediaRequest(); + const watchlist = useWatchlistTitleToggle(); if (discovery.isLoading) return ; @@ -210,6 +212,7 @@ function DiscoverTab() { section={section} isSubmitting={isSubmitting} onRequest={submit} + watchlist={watchlist} /> )) )} @@ -245,10 +248,12 @@ function DiscoverRow({ section, isSubmitting, onRequest, + watchlist, }: { section: RequestDiscoverySection; isSubmitting: (item: RequestMediaResult) => boolean; onRequest: (item: RequestMediaResult) => void; + watchlist: ReturnType; }) { if (section.results.length === 0) return null; return ( @@ -263,6 +268,8 @@ function DiscoverRow({ item={item} isSubmitting={isSubmitting(item)} onRequest={() => onRequest(item)} + onToggleWatchlist={watchlist.enabled ? () => watchlist.toggle(item) : undefined} + isWatchlistPending={watchlist.isPending(item)} /> ))} diff --git a/web/src/pages/SettingsLayout.test.tsx b/web/src/pages/SettingsLayout.test.tsx index ff81a7d85f..bd239d6150 100644 --- a/web/src/pages/SettingsLayout.test.tsx +++ b/web/src/pages/SettingsLayout.test.tsx @@ -6,6 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ useAuth: vi.fn(), + requestStatus: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.requestStatus(), })); vi.mock("@/hooks/useAuth", () => ({ @@ -25,6 +30,7 @@ describe("SettingsLayout", () => { mocks.useAuth.mockReturnValue({ user: { role: "admin" }, }); + mocks.requestStatus.mockReturnValue({ data: { requests_enabled: true } }); }); afterEach(() => { @@ -85,13 +91,28 @@ describe("SettingsLayout", () => { , ); - // Five groups, sixteen sections — every card the same height so no group + // Five groups, seventeen sections — every card the same height so no group // is visually ranked above another. expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(5); - expect(markup.match(/lg:h-28/g)).toHaveLength(16); + expect(markup.match(/lg:h-28/g)).toHaveLength(17); expect(markup).not.toContain("max-w-5xl"); }); + it("lists Requests only while the server has requests on", () => { + const renderIndex = () => + renderToStaticMarkup( + + + , + ); + + expect(renderIndex()).toContain('href="/settings/requests"'); + mocks.requestStatus.mockReturnValue({ data: { requests_enabled: false } }); + expect(renderIndex()).not.toContain('href="/settings/requests"'); + mocks.requestStatus.mockReturnValue({ data: undefined }); + expect(renderIndex()).not.toContain('href="/settings/requests"'); + }); + it("keeps each settings section in exactly one group", () => { const markup = renderToStaticMarkup( diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx index c21b182afb..81c9ae4cff 100644 --- a/web/src/pages/SettingsLayout.tsx +++ b/web/src/pages/SettingsLayout.tsx @@ -19,6 +19,7 @@ import { MonitorSmartphone, PanelTop, KeyRound, + Bookmark, } from "lucide-react"; // Sparkles is used by the Personalization nav entry below. import type { LucideIcon } from "lucide-react"; @@ -27,6 +28,7 @@ import { SideNavItem, SideNavSection } from "@/components/SideNav"; import { SettingsOverviewNav } from "@/components/settings/SettingsOverviewNav"; import { SettingsSearchInput } from "@/components/settings/SettingsSearchInput"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; @@ -45,6 +47,8 @@ interface NavItem { keywords?: readonly string[]; settings?: readonly { label: string; description?: string; keywords?: readonly string[] }[]; primaryOrAdmin?: boolean; + /** Listed only while the server has requests turned on. */ + requiresRequests?: boolean; } interface NavSection { @@ -279,6 +283,15 @@ const NAV_SECTIONS: NavSection[] = [ keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"], settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"), }, + { + path: "requests", + label: "Requests", + icon: Bookmark, + description: "Whether adding to your watchlist also requests the title.", + keywords: ["watchlist", "request", "discover", "auto request"], + settings: settingIndex("Request titles I add to my watchlist"), + requiresRequests: true, + }, { path: "libraries", label: "Libraries", @@ -506,6 +519,7 @@ export default function SettingsLayout() { const segments = location.pathname.split("/"); const activeSegment = segments[2] || null; const canManageProfiles = actingAdmin || profile?.is_primary === true; + const requestsEnabled = useRequestFeatureStatus().data?.requests_enabled === true; // Most settings pages are a single column of rows and read best measured. // A page that is itself two panes needs the room, so it opts out. const wideSetting = activeSegment ? WIDE_SETTINGS_PAGES.has(activeSegment) : false; @@ -514,9 +528,13 @@ export default function SettingsLayout() { () => NAV_SECTIONS.map((section) => ({ ...section, - items: section.items.filter((item) => !item.primaryOrAdmin || canManageProfiles), + items: section.items.filter( + (item) => + (!item.primaryOrAdmin || canManageProfiles) && + (!item.requiresRequests || requestsEnabled), + ), })).filter((section) => section.items.length > 0), - [canManageProfiles], + [canManageProfiles, requestsEnabled], ); const filteredSections = useMemo( diff --git a/web/src/pages/TitleDetail.test.tsx b/web/src/pages/TitleDetail.test.tsx index 6930fcaac7..28896c47d7 100644 --- a/web/src/pages/TitleDetail.test.tsx +++ b/web/src/pages/TitleDetail.test.tsx @@ -36,6 +36,13 @@ vi.mock("@/hooks/queries/useRequests", () => ({ }, useCancelMediaRequest: () => ({ mutate: mocks.cancel, isPending: false }), useToggleRequestFollow: () => ({ mutate: mocks.toggleFollow, isPending: false }), + useRequestFeatureStatus: () => ({ data: undefined }), +})); +vi.mock("@/hooks/queries/watchlistTitles", () => ({ + useToggleWatchlistTitle: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/hooks/useWatchlistTitleToggle", () => ({ + useWatchlistTitleToggle: () => ({ enabled: false, toggle: vi.fn(), isPending: () => false }), })); vi.mock("@/hooks/queries/catalogRead", () => ({ useCatalogItemDetail: (id: string | undefined) => { diff --git a/web/src/pages/admin-requests/RequestQueue.tsx b/web/src/pages/admin-requests/RequestQueue.tsx index 854dc09162..b60ebc0b55 100644 --- a/web/src/pages/admin-requests/RequestQueue.tsx +++ b/web/src/pages/admin-requests/RequestQueue.tsx @@ -708,6 +708,7 @@ function QueueRow({ {requestedAgo ? ( · {requestedAgo} ) : null} + {request.source === "watchlist" ? · via watchlist : null}

diff --git a/web/src/pages/admin-requests/RequestQueueDialog.tsx b/web/src/pages/admin-requests/RequestQueueDialog.tsx index 2d87066f4a..88b1ba84ce 100644 --- a/web/src/pages/admin-requests/RequestQueueDialog.tsx +++ b/web/src/pages/admin-requests/RequestQueueDialog.tsx @@ -146,6 +146,9 @@ function RequestDetail({ {requestedAgo ? ( · {requestedAgo} ) : null} + {request.source === "watchlist" ? ( + · via watchlist + ) : null} {request.approved_at ? ( {formatDateTime(request.approved_at)} diff --git a/web/src/pages/admin-settings/RequestsSettings.test.tsx b/web/src/pages/admin-settings/RequestsSettings.test.tsx index a0167a0eab..9073a35618 100644 --- a/web/src/pages/admin-settings/RequestsSettings.test.tsx +++ b/web/src/pages/admin-settings/RequestsSettings.test.tsx @@ -154,6 +154,36 @@ describe("Requests settings: general", () => { ).toBeInTheDocument(); }); + it("saves the watchlist request switch, and hides it from a server without one", async () => { + serve({ + handlers: { + "GET /api/v2/admin/request-settings": (options) => + reply(options, { ...settings, watchlist_requests: true }, '"initial"'), + "PUT /api/v2/admin/request-settings": (options) => + reply(options, { ...settings, watchlist_requests: false }, '"saved"'), + }, + }); + mount(); + const toggle = await screen.findByRole("switch", { + name: "Request titles added to a watchlist", + }); + expect(toggle).toHaveAttribute("aria-checked", "true"); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(calls("PUT /api/v2/admin/request-settings")).toHaveLength(1)); + expect(calls("PUT /api/v2/admin/request-settings")[0]).toMatchObject({ + body: { watchlist_requests: false }, + }); + cleanup(); + + serve(); + mount(); + await screen.findByLabelText("Request limit"); + expect( + screen.queryByRole("switch", { name: "Request titles added to a watchlist" }), + ).toBeNull(); + }); + it("calls approval and the limit server-wide defaults that groups and accounts override", async () => { serve(); mount(); diff --git a/web/src/pages/admin-settings/RequestsSettings.tsx b/web/src/pages/admin-settings/RequestsSettings.tsx index 70b816ebb9..2606570de8 100644 --- a/web/src/pages/admin-settings/RequestsSettings.tsx +++ b/web/src/pages/admin-settings/RequestsSettings.tsx @@ -32,6 +32,8 @@ interface GeneralDraft { max_requests: string; window_days: string; force_dual_quality: boolean; + /** Absent from servers without watchlist requests; the control hides. */ + watchlist_requests: boolean | undefined; } function generalDraft(settings: RequestSettings): GeneralDraft { @@ -41,6 +43,7 @@ function generalDraft(settings: RequestSettings): GeneralDraft { max_requests: String(settings.global_max_requests), window_days: String(settings.global_window_days), force_dual_quality: settings.force_dual_quality, + watchlist_requests: settings.watchlist_requests, }; } @@ -147,6 +150,7 @@ function RequestsSettingsContent({ routing }: { routing: boolean }) { global_max_requests: maxRequests ?? base.global_max_requests, global_window_days: windowDays ?? base.global_window_days, force_dual_quality: draft.force_dual_quality, + watchlist_requests: draft.watchlist_requests, updated_at: base.updated_at, etag: base.etag, }); @@ -282,6 +286,16 @@ function RequestsSettingsContent({ routing }: { routing: boolean }) { onChange={(value) => editGeneral({ force_dual_quality: value === "true" })} dirty={draft.force_dual_quality !== baseGeneral.force_dual_quality} /> + {draft.watchlist_requests !== undefined ? ( + editGeneral({ watchlist_requests: value === "true" })} + dirty={draft.watchlist_requests !== baseGeneral.watchlist_requests} + /> + ) : null} {generalConflict ? (
diff --git a/web/src/pages/settings/CardOverlaySettings.tsx b/web/src/pages/settings/CardOverlaySettings.tsx index 92e70f6b6b..c37497ae94 100644 --- a/web/src/pages/settings/CardOverlaySettings.tsx +++ b/web/src/pages/settings/CardOverlaySettings.tsx @@ -16,6 +16,7 @@ import { CATEGORY_GROUPS, getOverlayDef, isOverlaySuppressed, + OVERLAY_PREVIEW_VARIANTS, OVERLAY_REGISTRY, OVERLAY_PRESETS, POSITION_OPTIONS, @@ -290,6 +291,10 @@ export default function CardOverlaySettings() { isOverlaySupported, } = useOverlayPrefs(); const [previewVariant, setPreviewVariant] = useState("movie"); + // The request sample only shows a badge this server lets the profile keep. + const previewVariants = isOverlaySupported("request_status") + ? OVERLAY_PREVIEW_VARIANTS + : OVERLAY_PREVIEW_VARIANTS.filter((variant) => variant !== "requested"); const [confirmRestoreOpen, setConfirmRestoreOpen] = useState(false); const handleUpdate = (next: CardOverlayPrefs) => { @@ -425,7 +430,11 @@ export default function CardOverlaySettings() { >
- +
diff --git a/web/src/pages/settings/RequestsSettings.test.tsx b/web/src/pages/settings/RequestsSettings.test.tsx new file mode 100644 index 0000000000..a31f98fd2a --- /dev/null +++ b/web/src/pages/settings/RequestsSettings.test.tsx @@ -0,0 +1,168 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { requestKeys } from "@/hooks/queries/keys"; +import { SETTING_KEYS } from "@/lib/settingsContract"; +import RequestsSettings from "./RequestsSettings"; + +const mocks = vi.hoisted(() => ({ + featureStatus: vi.fn(), + effective: vi.fn(), + setValue: vi.fn(), + clearValue: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@/hooks/queries/useRequests", () => ({ + useRequestFeatureStatus: () => mocks.featureStatus(), +})); +vi.mock("@/hooks/queries/settingValues", async (importOriginal) => ({ + ...(await importOriginal()), + useEffectiveSettings: () => mocks.effective(), + useSetSettingValue: () => ({ mutateAsync: mocks.setValue, isPending: false }), + useClearSettingValue: () => ({ mutateAsync: mocks.clearValue, isPending: false }), +})); +vi.mock("sonner", () => ({ + toast: { success: mocks.toastSuccess, error: mocks.toastError }, +})); + +const KEY = SETTING_KEYS.REQUESTS_WATCHLIST_AUTO_REQUEST; +const LABEL = "Request titles I add to my watchlist"; + +function profileValue(value: boolean | undefined) { + mocks.effective.mockReturnValue({ + data: value === undefined ? {} : { [KEY]: { value, source: "profile" } }, + isLoading: false, + }); +} + +function renderPage() { + const queryClient = new QueryClient(); + const invalidate = vi.spyOn(queryClient, "invalidateQueries"); + render( + + + , + ); + return invalidate; +} + +describe("Settings › Requests", () => { + beforeEach(() => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: true, + }, + isLoading: false, + }); + profileValue(undefined); + mocks.setValue.mockResolvedValue(undefined); + mocks.clearValue.mockResolvedValue(undefined); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("turns watchlist requests off for this profile", async () => { + const invalidate = renderPage(); + const toggle = screen.getByRole("switch", { name: LABEL }); + expect(toggle).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(toggle); + + await waitFor(() => + expect(mocks.toastSuccess).toHaveBeenCalledWith("Request preference saved"), + ); + expect(mocks.setValue).toHaveBeenCalledWith({ + key: KEY, + value: false, + identity: { scope: "profile" }, + }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: requestKeys.status() }); + }); + + it("turns them back on by clearing the profile's value", async () => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: false, + }, + isLoading: false, + }); + profileValue(false); + renderPage(); + const toggle = screen.getByRole("switch", { name: LABEL }); + expect(toggle).toHaveAttribute("aria-checked", "false"); + + fireEvent.click(toggle); + + await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalled()); + expect(mocks.clearValue).toHaveBeenCalledWith({ key: KEY, identity: { scope: "profile" } }); + expect(mocks.setValue).not.toHaveBeenCalled(); + }); + + it("reports a failed save", async () => { + mocks.setValue.mockRejectedValue(new Error("boom")); + renderPage(); + fireEvent.click(screen.getByRole("switch", { name: LABEL })); + await waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith("Failed to save request preference"), + ); + }); + + it("hides the switch while the server doesn't request watchlist titles", () => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: true, + watchlist_titles_supported: true, + watchlist_requests: false, + }, + isLoading: false, + }); + renderPage(); + expect(screen.queryByRole("switch", { name: LABEL })).toBeNull(); + expect(screen.getByTestId("watchlist-auto-request-off")).toHaveTextContent( + "Adding a title to your watchlist doesn’t request it on this server. You can still request titles from Discover.", + ); + }); + + it("says requests are off instead of pointing to Discover", () => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: false, + allowed: true, + watchlist_titles_supported: false, + watchlist_requests: false, + }, + isLoading: false, + }); + profileValue(false); + renderPage(); + expect(screen.queryByRole("switch", { name: LABEL })).toBeNull(); + expect(screen.getByTestId("watchlist-auto-request-off")).toHaveTextContent( + "Requests are turned off on this server.", + ); + }); + + it("doesn't point to Discover when the viewer may not request", () => { + mocks.featureStatus.mockReturnValue({ + data: { + requests_enabled: true, + allowed: false, + watchlist_titles_supported: true, + watchlist_requests: false, + }, + isLoading: false, + }); + renderPage(); + expect(screen.getByTestId("watchlist-auto-request-off").textContent).not.toContain("Discover"); + }); +}); diff --git a/web/src/pages/settings/RequestsSettings.tsx b/web/src/pages/settings/RequestsSettings.tsx new file mode 100644 index 0000000000..8910940952 --- /dev/null +++ b/web/src/pages/settings/RequestsSettings.tsx @@ -0,0 +1,99 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { SettingRow } from "@/components/settings/SettingRow"; +import { SettingsGroup } from "@/components/settings/SettingsGroup"; +import { Switch } from "@/components/ui/switch"; +import { requestKeys } from "@/hooks/queries/keys"; +import { + isSettingValueMissing, + useClearSettingValue, + useEffectiveSettings, + useSetSettingValue, + type SettingIdentity, +} from "@/hooks/queries/settingValues"; +import { useRequestFeatureStatus } from "@/hooks/queries/useRequests"; +import { SETTING_DEFINITIONS, SETTING_KEYS } from "@/lib/settingsContract"; +import { showWatchlistAutoRequestControl } from "@/lib/watchlistTitles"; + +const PROFILE_SCOPE: SettingIdentity = { scope: "profile" }; +const AUTO_REQUEST_KEY = SETTING_KEYS.REQUESTS_WATCHLIST_AUTO_REQUEST; +const AUTO_REQUEST_DEFINITION = SETTING_DEFINITIONS[AUTO_REQUEST_KEY]; + +/** The profile's request preferences: whether adding to the watchlist also requests. */ +export default function RequestsSettings() { + const queryClient = useQueryClient(); + const featureStatus = useRequestFeatureStatus(); + const { data: effective, isLoading: settingsLoading } = useEffectiveSettings({ + keys: [AUTO_REQUEST_KEY], + }); + const setValue = useSetSettingValue(); + const clearValue = useClearSettingValue(); + // The contract default is on; only an explicit false opts the profile out. + const autoRequest = effective?.[AUTO_REQUEST_KEY]?.value !== false; + const pending = setValue.isPending || clearValue.isPending; + + if (featureStatus.isLoading || settingsLoading) { + return
Loading request settings...
; + } + + const status = featureStatus.data; + const showControl = showWatchlistAutoRequestControl(status, autoRequest); + const canRequest = status?.requests_enabled === true && status.allowed === true; + + async function handleChange(checked: boolean) { + try { + if (checked) { + // Clear the profile value so it inherits the default (on) again. + try { + await clearValue.mutateAsync({ key: AUTO_REQUEST_KEY, identity: PROFILE_SCOPE }); + } catch (error) { + if (!isSettingValueMissing(error)) throw error; + } + } else { + await setValue.mutateAsync({ + key: AUTO_REQUEST_KEY, + value: false, + identity: PROFILE_SCOPE, + }); + } + toast.success("Request preference saved"); + } catch { + toast.error("Failed to save request preference"); + } finally { + // The status endpoint folds this preference into watchlist_requests. + void queryClient.invalidateQueries({ queryKey: requestKeys.status() }); + } + } + + return ( +
+ + {showControl ? ( + ( + + )} + /> + ) : ( +

+ {status?.requests_enabled !== true + ? "Requests are turned off on this server." + : canRequest + ? "Adding a title to your watchlist doesn’t request it on this server. You can still request titles from Discover." + : "Adding a title to your watchlist doesn’t request it on this server."} +

+ )} +
+
+ ); +}