feat(api): Trakt lists REST endpoints (#160)

Adds TraktController (GET/POST /api/trakt/lists, GET/PUT/DELETE by id,
POST .../match, GET /api/trakt/status) covering the API gap for SPA
parity issue #142. Add/match/delete dispatch to the same
ChannelWriter<IBackgroundServiceRequest> the classic Blazor "Trakt
Lists" page uses, gated by the existing IEntityLocker (LockTrakt/
IsTraktLocked) singleton; GET /api/trakt/status wraps IsTraktLocked as
the HTTP-observable substitute for the Blazor page's OnTraktChanged
event. URL validation mirrors AddTraktListHandler.ValidateUrl's regexes
(replicated, since that method is private to the handler and returns a
handler-private record) so an obviously-invalid URL gets a synchronous
422 before dispatch.

Adds TraktListResponseModel/PagedTraktListsResponseModel/
TraktStatusResponseModel DTOs, AddTraktListRequest/UpdateTraktListRequest,
controller tests, ApiControllerSecurityTests + OpenApiErrorResponseContractTests
coverage, and regenerates the checked-in OpenAPI document.
This commit is contained in:
2026-07-07 15:09:12 +02:00
parent e0a860e577
commit 4306b39ef8
10 changed files with 1106 additions and 0 deletions
+557
View File
@@ -5491,6 +5491,458 @@
}
}
},
"/api/trakt/lists": {
"get": {
"tags": [
"Trakt"
],
"summary": "Get paged Trakt lists",
"operationId": "GetTraktLists",
"parameters": [
{
"name": "pageNum",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
},
{
"name": "pageSize",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 100
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PagedTraktListsResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/PagedTraktListsResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PagedTraktListsResponseModel"
}
}
}
}
}
},
"post": {
"tags": [
"Trakt"
],
"summary": "Add a Trakt list by URL",
"description": "Dispatches to the same background worker channel used by the classic UI's \"Add Trakt List\" dialog; the list is fetched, saved, and matched asynchronously. Poll GET /api/trakt/status while busy.",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/AddTraktListRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/AddTraktListRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/AddTraktListRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/AddTraktListRequest"
}
}
},
"required": true
},
"responses": {
"202": {
"description": "Accepted"
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/trakt/lists/{id}": {
"get": {
"tags": [
"Trakt"
],
"summary": "Get a Trakt list by id",
"operationId": "GetTraktListById",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
},
"delete": {
"tags": [
"Trakt"
],
"summary": "Delete a Trakt list",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"202": {
"description": "Accepted"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
},
"put": {
"tags": [
"Trakt"
],
"summary": "Update a Trakt list's settings",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/UpdateTraktListRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateTraktListRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/UpdateTraktListRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/UpdateTraktListRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/trakt/lists/{id}/match": {
"post": {
"tags": [
"Trakt"
],
"summary": "Match a Trakt list's items",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"202": {
"description": "Accepted"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/trakt/status": {
"get": {
"tags": [
"Trakt"
],
"summary": "Get Trakt background operation status",
"description": "Wraps IEntityLocker.IsTraktLocked() — the HTTP-observable substitute for the Blazor page's live lock event. The SPA polls this while add/match/delete are in flight.",
"operationId": "GetTraktStatus",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/TraktStatusResponseModel"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/TraktStatusResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/TraktStatusResponseModel"
}
}
}
}
}
}
},
"/api/troubleshoot/info": {
"get": {
"tags": [
@@ -5979,6 +6431,20 @@
}
}
},
"AddTraktListRequest": {
"required": [
"url"
],
"type": "object",
"properties": {
"url": {
"type": [
"null",
"string"
]
}
}
},
"ArtworkContentTypeModel": {
"required": [
"path",
@@ -8810,6 +9276,25 @@
}
}
},
"PagedTraktListsResponseModel": {
"required": [
"totalCount",
"page"
],
"type": "object",
"properties": {
"totalCount": {
"type": "integer",
"format": "int32"
},
"page": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TraktListResponseModel"
}
}
}
},
"PlaybackOrder": {
"enum": [
"None",
@@ -9851,6 +10336,60 @@
],
"type": "string"
},
"TraktListResponseModel": {
"required": [
"id",
"traktId",
"slug",
"name",
"itemCount",
"matchCount",
"autoRefresh",
"generatePlaylist"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"traktId": {
"type": "integer",
"format": "int32"
},
"slug": {
"type": "string"
},
"name": {
"type": "string"
},
"itemCount": {
"type": "integer",
"format": "int32"
},
"matchCount": {
"type": "integer",
"format": "int32"
},
"autoRefresh": {
"type": "boolean"
},
"generatePlaylist": {
"type": "boolean"
}
}
},
"TraktStatusResponseModel": {
"required": [
"busy"
],
"type": "object",
"properties": {
"busy": {
"type": "boolean"
}
}
},
"TroubleshootingInfoResponseModel": {
"required": [
"generalJson",
@@ -10612,6 +11151,21 @@
}
}
},
"UpdateTraktListRequest": {
"required": [
"autoRefresh",
"generatePlaylist"
],
"type": "object",
"properties": {
"autoRefresh": {
"type": "boolean"
},
"generatePlaylist": {
"type": "boolean"
}
}
},
"UpdateUiSettingsRequest": {
"required": [
"isDarkMode",
@@ -10864,6 +11418,9 @@
{
"name": "Smart Collections"
},
{
"name": "Trakt"
},
{
"name": "Troubleshooting"
},