diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json
index aa0bbbf5..6363c06e 100644
--- a/docs/assets/openapi.json
+++ b/docs/assets/openapi.json
@@ -1,284 +1,3171 @@
{
+ "components": {
+ "responses": {
+ "Unauthorized": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
+ "description": "Not authenticated"
+ }
+ },
+ "securitySchemes": {
+ "bearerAuth": {
+ "description": "Value of WebUI.Token in the Authorization header. Query ?token= is also accepted by the server but is discouraged.",
+ "scheme": "bearer",
+ "type": "http"
+ }
+ }
+ },
"info": {
- "description": "API for qBittorrent + Arr automation (qBitrr C# port). Subset aligned with qBitrr 5.12.3.",
+ "description": "API for qBittorrent + Arr automation (Torrentarr \u2014 C# port of qBitrr). Aligned with qBitrr 5.12.3. When WebUI.AuthDisabled is false, most routes require a Bearer token (WebUI.Token) or a valid browser session after login. Interactive docs: GET /web/docs or GET /api/docs.",
"title": "Torrentarr API",
"version": "v1"
},
- "openapi": "3.0.1",
+ "openapi": "3.0.3",
"paths": {
+ "/": {
+ "get": {
+ "operationId": "root",
+ "responses": {
+ "302": {
+ "description": "Redirect"
+ }
+ },
+ "security": [],
+ "summary": "Redirect to WebUI",
+ "tags": [
+ "System"
+ ]
+ }
+ },
"/api/arr": {
"get": {
+ "operationId": "api_arr_list",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Arr instance list"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Configured Arr instances (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/arr/rebuild": {
+ "post": {
+ "operationId": "api_arr_rebuild",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Reload Arr configuration (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/arr/test-connection": {
+ "post": {
+ "operationId": "api_arr_test_connection",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "apiKey": {
+ "type": "string"
+ },
+ "arrType": {
+ "type": "string"
+ },
+ "instanceKey": {
+ "type": "string"
+ },
+ "uri": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Test Arr connection",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/arr/{category}/open/{kind}/{entryId}": {
+ "get": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "entryId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "302": {
+ "description": "Redirect to Arr"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Redirect to Arr UI for movie/series/artist (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/arr/{section}/restart": {
+ "post": {
+ "operationId": "api_arr_restart",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "section",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Restart Arr instance loops (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/config": {
+ "get": {
+ "operationId": "api_config_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Get configuration",
+ "tags": [
+ "WebUI"
+ ]
+ },
+ "post": {
+ "operationId": "api_config_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Update configuration",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/docs": {
+ "get": {
+ "operationId": "api_swagger_ui",
+ "responses": {
+ "200": {
+ "content": {
+ "text/html": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "HTML"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Swagger UI (/api)",
+ "tags": [
+ "System"
+ ]
+ }
+ },
+ "/api/download-update": {
+ "get": {
+ "operationId": "api_download_update",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Download update artifact (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/lidarr/{category}/albums": {
"get": {
+ "operationId": "api_lidarr_albums",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Lidarr albums (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr albums (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/lidarr/{category}/artist/{artist_id}": {
"get": {
+ "operationId": "api_lidarr_artist_detail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "artist_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Artist not found"
}
},
- "summary": "Lidarr artist detail (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr artist with albums (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/lidarr/{category}/artist/{artist_id}/thumbnail": {
"get": {
+ "operationId": "api_lidarr_artist_thumbnail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "artist_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
- "description": "OK"
+ "content": {
+ "image/*": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "Cached artist image bytes"
+ },
+ "304": {
+ "description": "Not modified (If-None-Match)"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Artist or image not found"
}
},
- "summary": "Lidarr artist thumbnail (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr artist thumbnail (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/lidarr/{category}/artists": {
"get": {
+ "operationId": "api_lidarr_artists",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "monitored",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Restrict to artists with at least one monitored album whose file is missing.",
+ "in": "query",
+ "name": "missing",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "description": "Restrict to artists with at least one album whose Reason matches. Accepts Missing, Quality, CustomFormat, Upgrade, or 'Not being searched' (also matches NULL).",
+ "in": "query",
+ "name": "reason",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr artists catalog (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/lidarr/{category}/tracks": {
+ "get": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr tracks browse (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/loglevel": {
+ "post": {
+ "operationId": "api_loglevel",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "level": {
+ "description": "CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Set console log level (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/logs": {
+ "get": {
+ "operationId": "api_logs_list",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "List log files (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/logs/{name}": {
+ "get": {
+ "operationId": "api_log_content",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "lines",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Lidarr artists (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Log content (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/logs/{name}/download": {
+ "get": {
+ "operationId": "api_log_download",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "File download"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Download log (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/meta": {
"get": {
+ "operationId": "api_meta",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "force",
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "API metadata"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Version info (/api, requires Bearer or session)",
+ "tags": [
+ "WebUI"
+ ]
}
},
- "/api/radarr/{category}/movie/{id}/thumbnail": {
+ "/api/openapi.json": {
"get": {
+ "operationId": "api_openapi_spec",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OpenAPI JSON"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "OpenAPI 3 document (/api)",
+ "tags": [
+ "System"
+ ]
+ }
+ },
+ "/api/processes": {
+ "get": {
+ "operationId": "api_processes",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "List worker processes (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/processes/restart_all": {
+ "post": {
+ "operationId": "api_processes_restart_all",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Restart/reload all processes (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/processes/{category}/{kind}/restart": {
+ "post": {
+ "operationId": "api_process_restart",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "description": "search, torrent, category, or all",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Radarr movie thumbnail (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Restart process (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/qbit/categories": {
+ "get": {
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "qBittorrent managed categories (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/radarr/{category}/movie/{id}/thumbnail": {
+ "get": {
+ "operationId": "api_radarr_movie_thumbnail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "image/*": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "Cached movie poster image bytes"
+ },
+ "304": {
+ "description": "Not modified (If-None-Match)"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Movie or image not found"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Radarr movie poster thumbnail (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/radarr/{category}/movies": {
"get": {
+ "operationId": "api_radarr_movies",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "year_min",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "year_max",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "monitored",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "has_file",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "quality_met",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "is_request",
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Radarr movies (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Radarr movies (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/sonarr/{category}/series": {
"get": {
+ "operationId": "api_sonarr_series",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Sonarr series (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Sonarr series (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/sonarr/{category}/series/{id}/thumbnail": {
"get": {
+ "operationId": "api_sonarr_series_thumbnail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
- "description": "OK"
+ "content": {
+ "image/*": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "Cached series poster image bytes"
+ },
+ "304": {
+ "description": "Not modified (If-None-Match)"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Series or image not found"
}
},
- "summary": "Sonarr series thumbnail (API)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Sonarr series poster thumbnail (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/api/status": {
"get": {
+ "operationId": "api_status",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "qBitrr and Arr status (/api)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/token": {
+ "get": {
+ "operationId": "api_token",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "System status (requires auth)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Current WebUI API token (requires auth)",
+ "tags": [
+ "Auth"
+ ]
+ }
+ },
+ "/api/torrents/distribution": {
+ "get": {
+ "operationId": "api_torrents_distribution",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Torrent counts by category and qBit instance",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/api/update": {
+ "post": {
+ "operationId": "api_update",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Trigger manual update (/api)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/health": {
"get": {
+ "operationId": "health",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "status": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
}
},
- "summary": "Health check"
+ "security": [],
+ "summary": "Health check",
+ "tags": [
+ "System"
+ ]
+ }
+ },
+ "/login": {
+ "get": {
+ "operationId": "login",
+ "responses": {
+ "302": {
+ "description": "Redirect"
+ }
+ },
+ "security": [],
+ "summary": "Login page redirect",
+ "tags": [
+ "Auth"
+ ]
+ }
+ },
+ "/signin-oidc": {
+ "get": {
+ "operationId": "web_oidc_callback_default",
+ "responses": {
+ "200": {
+ "description": "HTML or redirect"
+ },
+ "302": {
+ "description": "Redirect"
+ }
+ },
+ "security": [],
+ "summary": "OIDC callback (default WebUI.OIDC.CallbackPath; set in config if different)",
+ "tags": [
+ "Auth"
+ ]
+ }
+ },
+ "/sw.js": {
+ "get": {
+ "operationId": "sw_js",
+ "responses": {
+ "200": {
+ "description": "JavaScript"
+ }
+ },
+ "security": [],
+ "summary": "Service worker",
+ "tags": [
+ "System"
+ ]
+ }
+ },
+ "/ui": {
+ "get": {
+ "operationId": "ui",
+ "responses": {
+ "302": {
+ "description": "Redirect"
+ }
+ },
+ "security": [],
+ "summary": "WebUI entry (redirect)",
+ "tags": [
+ "System"
+ ]
}
},
"/web/arr": {
"get": {
+ "operationId": "web_arr_list",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Arr instance list with rollup counts"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Configured Arr instances (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/arr/rebuild": {
+ "post": {
+ "operationId": "web_arr_rebuild",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Reload Arr configuration (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/arr/test-connection": {
+ "post": {
+ "operationId": "web_arr_test_connection",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "apiKey": {
+ "type": "string"
+ },
+ "arrType": {
+ "type": "string"
+ },
+ "instanceKey": {
+ "type": "string"
+ },
+ "uri": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Test Arr connection",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/arr/{category}/open/{kind}/{entryId}": {
+ "get": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "entryId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "302": {
+ "description": "Redirect to Arr"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Redirect to Arr UI for movie/series/artist (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/arr/{section}/restart": {
+ "post": {
+ "operationId": "web_arr_restart",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "section",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Restart Arr instance loops (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/auth/oidc/challenge": {
+ "get": {
+ "operationId": "web_oidc_challenge",
+ "responses": {
+ "302": {
+ "description": "Redirect to IdP"
+ }
+ },
+ "security": [],
+ "summary": "Start OIDC login",
+ "tags": [
+ "Auth"
+ ]
}
},
"/web/auth/set-password": {
"post": {
+ "operationId": "web_set_password",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "password": {
+ "type": "string"
+ },
+ "setupToken": {
+ "description": "QBITRR_SETUP_TOKEN or WebUI.Token. Required unless the caller already has a valid session or bearer token.",
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ },
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "400": {
+ "description": "Bad request"
+ },
+ "403": {
+ "description": "Setup token or authenticated session required"
}
},
- "summary": "Set local password"
+ "security": [],
+ "summary": "Set local password (setup)",
+ "tags": [
+ "Auth"
+ ]
}
},
"/web/config": {
"get": {
+ "operationId": "web_config_get",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Get configuration"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Get configuration",
+ "tags": [
+ "WebUI"
+ ]
},
"post": {
+ "operationId": "web_config_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Update configuration",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/docs": {
+ "get": {
+ "operationId": "web_swagger_ui",
+ "responses": {
+ "200": {
+ "content": {
+ "text/html": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "HTML"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Swagger UI (/web)",
+ "tags": [
+ "System"
+ ]
+ }
+ },
+ "/web/download-update": {
+ "get": {
+ "operationId": "web_download_update",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Update configuration"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Download update artifact (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/lidarr/{category}/albums": {
"get": {
+ "operationId": "web_lidarr_albums",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Lidarr albums browse"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr albums (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/lidarr/{category}/artist/{artist_id}": {
"get": {
+ "operationId": "web_lidarr_artist_detail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "artist_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Artist not found"
}
},
- "summary": "Lidarr artist detail"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr artist with albums (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/lidarr/{category}/artist/{artist_id}/thumbnail": {
"get": {
+ "operationId": "web_lidarr_artist_thumbnail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "artist_id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
- "description": "OK"
+ "content": {
+ "image/*": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "Cached artist image bytes"
+ },
+ "304": {
+ "description": "Not modified (If-None-Match)"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Artist or image not found"
}
},
- "summary": "Lidarr artist thumbnail proxy"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr artist thumbnail (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/lidarr/{category}/artists": {
"get": {
+ "operationId": "web_lidarr_artists",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "monitored",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "description": "Restrict to artists with at least one monitored album whose file is missing.",
+ "in": "query",
+ "name": "missing",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "description": "Restrict to artists with at least one album whose Reason matches. Accepts Missing, Quality, CustomFormat, Upgrade, or 'Not being searched' (also matches NULL).",
+ "in": "query",
+ "name": "reason",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr artists catalog (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/lidarr/{category}/tracks": {
+ "get": {
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Lidarr artists browse"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Lidarr tracks browse (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/login": {
"post": {
+ "operationId": "web_login",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "password": {
+ "type": "string"
+ },
+ "username": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad request"
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "429": {
+ "description": "Rate limited"
+ }
+ },
+ "security": [],
+ "summary": "WebUI login",
+ "tags": [
+ "Auth"
+ ]
+ }
+ },
+ "/web/loglevel": {
+ "post": {
+ "operationId": "web_loglevel",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "level": {
+ "description": "CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACE",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Set console log level (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/logout": {
+ "post": {
+ "operationId": "web_logout",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ }
+ },
+ "security": [],
+ "summary": "WebUI logout",
+ "tags": [
+ "Auth"
+ ]
+ }
+ },
+ "/web/logs": {
+ "get": {
+ "operationId": "web_logs_list",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Local login"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "List log files (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/logs/{name}": {
+ "get": {
+ "operationId": "web_log_content",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "lines",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "text/plain": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Log content (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/logs/{name}/download": {
+ "get": {
+ "operationId": "web_log_download",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "File download"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Not found"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Download log (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/meta": {
"get": {
+ "operationId": "web_meta_public",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "force",
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
}
},
- "summary": "WebUI metadata"
+ "security": [],
+ "summary": "Version and auth flags (unauthenticated; WebUI bootstrap)",
+ "tags": [
+ "System"
+ ]
}
},
- "/web/radarr/{category}/movie/{id}/thumbnail": {
+ "/web/openapi.json": {
"get": {
+ "operationId": "web_openapi_spec",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OpenAPI JSON"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "OpenAPI 3 document (/web)",
+ "tags": [
+ "System"
+ ]
+ }
+ },
+ "/web/processes": {
+ "get": {
+ "operationId": "web_processes",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "List worker processes (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/processes/restart_all": {
+ "post": {
+ "operationId": "web_processes_restart_all",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Radarr movie thumbnail proxy"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Restart/reload all processes (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/processes/{category}/{kind}/restart": {
+ "post": {
+ "operationId": "web_process_restart",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "kind",
+ "required": true,
+ "schema": {
+ "description": "search, torrent, category, or all",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Restart process (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/qbit/categories": {
+ "get": {
+ "operationId": "web_qbit_categories",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "qBittorrent categories and seeding stats (/web only)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/radarr/{category}/movie/{id}/thumbnail": {
+ "get": {
+ "operationId": "web_radarr_movie_thumbnail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "image/*": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "Cached movie poster image bytes"
+ },
+ "304": {
+ "description": "Not modified (If-None-Match)"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Movie or image not found"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Radarr movie poster thumbnail (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/radarr/{category}/movies": {
"get": {
+ "operationId": "web_radarr_movies",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "year_min",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "year_max",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "monitored",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "has_file",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "quality_met",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "is_request",
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Radarr movies browse"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Radarr movies (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/sonarr/{category}/series": {
"get": {
+ "operationId": "web_sonarr_series",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "q",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "Sonarr series browse"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Sonarr series (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/sonarr/{category}/series/{id}/thumbnail": {
"get": {
+ "operationId": "web_sonarr_series_thumbnail",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "category",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
"responses": {
"200": {
- "description": "OK"
+ "content": {
+ "image/*": {
+ "schema": {
+ "format": "binary",
+ "type": "string"
+ }
+ }
+ },
+ "description": "Cached series poster image bytes"
+ },
+ "304": {
+ "description": "Not modified (If-None-Match)"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "description": "Series or image not found"
}
},
- "summary": "Sonarr series thumbnail proxy"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Sonarr series poster thumbnail (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
},
"/web/status": {
"get": {
+ "operationId": "web_status",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "qBitrr and Arr status (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/token": {
+ "get": {
+ "operationId": "web_token",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ }
+ },
+ "security": [
+ {},
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "API token when auth disabled or session valid; 401 with empty token if auth required and not signed in",
+ "tags": [
+ "Auth"
+ ]
+ }
+ },
+ "/web/torrents/distribution": {
+ "get": {
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Torrent distribution by category (/web)",
+ "tags": [
+ "WebUI"
+ ]
+ }
+ },
+ "/web/update": {
+ "post": {
+ "operationId": "web_update",
"responses": {
"200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "type": "object"
+ }
+ }
+ },
"description": "OK"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
}
},
- "summary": "System status (public)"
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "summary": "Trigger manual update (/web)",
+ "tags": [
+ "WebUI"
+ ]
}
}
},
"servers": [
{
- "description": "Default WebUI port",
- "url": "http://localhost:6969"
+ "url": "/"
+ }
+ ],
+ "tags": [
+ {
+ "description": "Health, redirects, public metadata, OpenAPI UI",
+ "name": "System"
+ },
+ {
+ "description": "Login, logout, OIDC, token",
+ "name": "Auth"
+ },
+ {
+ "description": "Authenticated API (Bearer or session)",
+ "name": "WebUI"
}
]
}
diff --git a/docs/development/release-process.md b/docs/development/release-process.md
index 5b35c540..01a689a3 100644
--- a/docs/development/release-process.md
+++ b/docs/development/release-process.md
@@ -346,10 +346,12 @@ docker pull feramance/torrentarr:5.5.5
The documentation site embeds an interactive Swagger UI that loads the API spec from `docs/assets/openapi.json`. When you add or change WebUI API endpoints, regenerate this file so the published docs stay in sync:
-1. From the repository root, set the export environment variable and run the export test:
+1. **From qBitrr pin (recommended for parity):** `python3 scripts/generate-openapi-from-qbitrr.py` — merges qBitrr `5.12.3` OpenAPI with Torrentarr extension paths, then edit any new route details as needed.
+2. **From running Host (Swashbuckle export):** from the repository root, set the export environment variable and run the export test:
- **Windows (PowerShell):** `$env:TORRENTARR_EXPORT_OPENAPI='1'; dotnet test tests/Torrentarr.Host.Tests --filter "FullyQualifiedName~ExportOpenApiSpec"`
- **Linux/macOS:** `TORRENTARR_EXPORT_OPENAPI=1 dotnet test tests/Torrentarr.Host.Tests --filter "FullyQualifiedName~ExportOpenApiSpec"`
-2. Commit the updated `docs/assets/openapi.json` if it changed.
+3. Run `bash scripts/check-openapi-drift.sh` to verify all qBitrr paths are covered.
+4. Commit the updated `docs/assets/openapi.json` if it changed.
Include this step in your release checklist when you have modified API routes.
diff --git a/docs/parity/certification-report.md b/docs/parity/certification-report.md
index 2c11212b..f14dea69 100644
--- a/docs/parity/certification-report.md
+++ b/docs/parity/certification-report.md
@@ -27,7 +27,7 @@ Primary tracking artifacts:
- **Category paths:** `CategoryPathHelper` + `ConfigValidationHelper` overlap validation on config save; wired into torrent/category matching.
- **Catalog rollups:** `CatalogRollupService` with qBitrr semantics + 5s TTL; integrated into `/web|api/arr`, Radarr/Sonarr/Lidarr endpoints.
- **Lidarr artists + thumbnails:** `ArrCatalogEndpoints`, `ArrThumbnailService`, frontend `getLidarrArtists` / `getLidarrArtistDetail`.
-- **OpenAPI:** expanded `docs/assets/openapi.json` (26 paths); `scripts/check-openapi-drift.sh` in CI.
+- **OpenAPI:** full `docs/assets/openapi.json` (72 paths, all qBitrr 5.12.3 paths + 6 Torrentarr extensions); `/api|web/docs` and `/api|web/openapi.json` route aliases; `scripts/check-openapi-drift.sh` and `scripts/generate-openapi-from-qbitrr.py` in CI.
### Phase 3 — Config schema
- `ExpectedConfigVersion = 6.12.2` (+1 major vs qBitrr `5.12.2`).
@@ -46,7 +46,7 @@ Backend tests (`dotnet test --filter "Category!=Live"`):
Frontend tests (`cd webui && npx vitest run`): exit code 0 (130 tests).
-OpenAPI drift: `bash scripts/check-openapi-drift.sh` — 26 Torrentarr paths ⊆ 66 qBitrr 5.12.3 paths.
+OpenAPI drift: `bash scripts/check-openapi-drift.sh` — 72 Torrentarr paths cover all 66 qBitrr 5.12.3 paths (+6 extensions).
Focused regression checks added/updated:
diff --git a/docs/parity/contributor-reference.md b/docs/parity/contributor-reference.md
index bbebe006..de19b22b 100644
--- a/docs/parity/contributor-reference.md
+++ b/docs/parity/contributor-reference.md
@@ -84,6 +84,14 @@ Upstream may ship `repair_database_targeted.py`. Torrentarr does not port that s
---
+## Database locking (`db_lock.py`)
+
+qBitrr uses a cross-process file lock around SQLite access because Arr workers are separate OS processes. Torrentarr runs workers **in-process** (`ArrWorkerManager`, `QBitCategoryWorkerManager`) with WAL mode, scoped `DbContext` instances, and `SaveChangesWithRetryAsync` for transient lock errors. Coordinated recovery after persistent errors is handled by `DatabaseRestartCoordinator` + `DatabaseRestartWatchdogService`.
+
+**Matrix:** `db_lock.py` = intentional-divergence (equivalent outcomes via WAL + in-process isolation + retry/restart). Tests: [`DatabaseRetryExtensions`](https://github.com/Feramance/Torrentarr/blob/master/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs), worker integration tests.
+
+---
+
## Policy engine test matrix
Maps upstream concepts to CI tests; live qBittorrent still needed for full ordering proof.
@@ -153,7 +161,7 @@ Compare to upstream on the [pinned tag](#upstream-qbitrr-baseline) for **behavio
**Pin:** use the [Upstream baseline](#upstream-qbitrr-baseline) tag when fetching upstream `qBitrr/openapi.json`.
-Torrentarr: [docs/assets/openapi.json](../assets/openapi.json), Swagger at `/swagger`. Comparing to upstream is a **drift check**, not a byte-identical merge.
+Torrentarr: [docs/assets/openapi.json](../assets/openapi.json) (72 paths: all qBitrr 5.12.3 paths + Torrentarr extensions), served at `/api/openapi.json` and `/web/openapi.json`; interactive docs at `/api/docs` and `/web/docs`. Regenerate from upstream pin: `python3 scripts/generate-openapi-from-qbitrr.py`. CI drift check: `bash scripts/check-openapi-drift.sh`.
**When** changing WebUI DTOs/controllers: diff paths/methods for `/web/*`, `/api/*`, auth, health.
diff --git a/docs/parity/full-parity-matrix.md b/docs/parity/full-parity-matrix.md
index 23617400..95ea4354 100644
--- a/docs/parity/full-parity-matrix.md
+++ b/docs/parity/full-parity-matrix.md
@@ -4,7 +4,7 @@ This matrix tracks strict full parity against upstream qBitrr **5.12.3** (`0b4a1
## Parity claim policy
-Use this file as the **source of truth** for how close implementation is to upstream. While Torrentarr **targets** qBitrr behavior and shares `config.toml` + SQLite compatibility, a **strict “100% parity”** claim is only defensible when **no** file row is `partial` and **no** support row is `missing` (per [certification-report.md](certification-report.md)). Public messaging should say **“aligned with / port of qBitrr”** or point readers here—**not** “complete parity”—until the matrix is closed out.
+Use this file as the **source of truth** for how close implementation is to upstream. A **strict “100% parity”** claim against qBitrr **5.12.3** is defensible when **no** file row is `partial` and **no** support row is `missing` (per [certification-report.md](certification-report.md)) — **closed out 2026-06**. Rows marked `intentional-divergence` document architectural differences with equivalent user-facing outcomes.
**Contributors:** upstream pin, test matrices, OpenAPI diffs, and internal checklists are in [contributor-reference.md](contributor-reference.md) (not needed for end users; see [overview.md](overview.md)).
@@ -20,30 +20,30 @@ Status values:
| qBitrr file | Torrentarr equivalent | Status | Required actions |
| --- | --- | --- | --- |
| `qBitrr/__init__.py` | `src/Torrentarr.Host/Program.cs`, assembly metadata | full | Version metadata via `/web/meta`; `UpdateService` reports `patched_version` semantics. |
-| `qBitrr/main.py` | `src/Torrentarr.Host/Program.cs`, `ArrWorkerManager.cs` | full | Process orchestration + worker lifecycle; Lidarr search timer starvation N/A (documented in `ArrWorkerManager`). |
-| `qBitrr/arss.py` | `TorrentPolicyHelper`, Host policy passes, worker services | full | **Evidence:** [`TorrentPolicyHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/TorrentPolicyHelperTests.cs), [contributor-reference policy matrix](contributor-reference.md#policy-engine-test-matrix). |
-| `qBitrr/qbit_category_manager.py` | `SeedingService.cs`, `CategoryPathHelper` | full | **Evidence:** [`SeedingServiceTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Services/SeedingServiceTests.cs) (HnR dead-tracker #412), subcategory matching in Host qBit categories. |
+| `qBitrr/main.py` | `src/Torrentarr.Host/Program.cs`, `ArrWorkerManager.cs`, `QBitCategoryWorkerManager.cs`, `PeriodicWalCheckpointService.cs` | full | Process orchestration + Arr/qBit-only category workers; 5-minute WAL checkpoint; config reload restarts workers. |
+| `qBitrr/arss.py` | `TorrentPolicyHelper`, `CategoryOwnershipHelper`, `TorrentProcessor`, worker services | full | **Evidence:** [`TorrentPolicyHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/TorrentPolicyHelperTests.cs), [`CategoryOwnershipHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs), [contributor-reference policy matrix](contributor-reference.md#policy-engine-test-matrix). |
+| `qBitrr/qbit_category_manager.py` | `QBitCategoryWorkerManager.cs`, `SeedingService.cs`, `CategoryOwnershipHelper.cs` | full | **Evidence:** qBit-only `ManagedCategories` workers; `MatchSubcategories`; rate limits via `ApplySeedingLimitsAsync`; [`CategoryOwnershipHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs). |
| `qBitrr/arr_tracker_index.py` | `SeedingService.cs` queue-sort tracker priority | full | Tracker priority sort in `SeedingService` + Host `ProcessTorrentPolicyAsync`. |
-| `qBitrr/config.py` | `TorrentarrConfig.cs`, `ConfigurationLoader.cs` | full | Key-by-key TOML parity; `UrlBase`, `BehindHttpsProxy`, env aliases. |
+| `qBitrr/config.py` | `TorrentarrConfig.cs`, `ConfigurationLoader.cs` | full | Key-by-key TOML parity including `MatchSubcategories`; `UrlBase`, `BehindHttpsProxy`, env aliases; hot reload restarts workers on Host. |
| `qBitrr/gen_config.py` | `ConfigurationLoader.GenerateDefaultConfig()` | full | Defaults include `UrlBase`, `ConfigVersion = 6.12.2`. |
| `qBitrr/config_version.py` | `ConfigurationLoader.ValidateConfigVersion()` | full | `ExpectedConfigVersion = 6.12.2`; migration on load. |
| `qBitrr/env_config.py` | `ConfigurationLoader` env overrides | full | `TORRENTARR_*` + `QBITRR_*` aliases including `WEBUI_URL_BASE`, `SETUP_TOKEN`. |
| `qBitrr/duration_config.py` | `DurationParser.cs` | full | **Evidence:** [`DurationParserTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/DurationParserTests.cs). |
| `qBitrr/database.py` | `TorrentarrDbContext`, `DatabaseHealthService` | full | WAL mode, startup repair, integrity checks. |
| `qBitrr/tables.py` | EF models, `TorrentarrDbContext` | full | **Evidence:** [`SchemaParityTests.cs`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Database/SchemaParityTests.cs). |
-| `qBitrr/db_lock.py` | EF/SQLite locking | full | SQLite WAL + scoped DbContext per request/worker. |
-| `qBitrr/db_recovery.py` | `DatabaseHealthService`, Host `--repair-database` | full | Integrity + VACUUM + operator repair workflow. |
+| `qBitrr/db_lock.py` | EF/SQLite WAL, `DatabaseRetryExtensions.cs`, `DatabaseRestartCoordinator` | intentional-divergence | In-process workers + WAL + scoped `DbContext` replace cross-process file lock; `SaveChangesWithRetryAsync` and coordinated restart via `DatabaseRestartWatchdogService` provide equivalent recovery semantics. |
+| `qBitrr/db_recovery.py` | `DatabaseHealthService`, Host `--repair-database`, `PeriodicWalCheckpointService` | full | Integrity + VACUUM + `RepairAsync` via SQLite backup; periodic WAL checkpoint every 5 minutes on Host. |
| `qBitrr/search_activity_store.py` | `SearchActivity` model, worker services | full | Search activity persisted and exposed via processes API. |
-| `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src` | full | **Evidence:** UrlBase end-to-end, auth bootstrap, catalog rollups, Lidarr artists/thumbnails, [`SetPasswordEndpointTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Host.Tests/Api/SetPasswordEndpointTests.cs), [openapi.json](../assets/openapi.json) + CI drift check. |
+| `qBitrr/webui.py` | Host/WebUI `Program.cs`, `webui/src`, `docs/assets/openapi.json` | full | All qBitrr 5.12.3 routes on Host; curated OpenAPI (72 paths) + `/api|web/docs` and `/api|web/openapi.json` aliases; `scripts/check-openapi-drift.sh` verifies full upstream path coverage. |
| `qBitrr/auto_update.py` | `UpdateService`, `AutoUpdateBackgroundService` | full | Check/download/apply + cron scheduling. |
-| `qBitrr/pyarr_compat.py` | `ApiClients/Arr/*.cs` | full | Arr API clients with normalized responses. |
+| `qBitrr/pyarr_compat.py` | `ApiClients/Arr/*.cs`, `HttpRetryHelper.cs` | full | Arr API clients with normalized responses and retry policies. |
| `qBitrr/ffprobe.py` | `MediaValidationService.cs` | full | ffprobe validation integration. |
| `qBitrr/versioning.py` | Host metadata + `UpdateService` | full | `/web/meta`, release check caching. |
| `qBitrr/bundled_data.py` | Host `wwwroot`, embedded defaults | full | SPA build output served from Host. |
| `qBitrr/home_path.py` | `ConfigurationLoader.GetDefaultConfigPath()` | full | Config search order + `GetDataDirectoryPath()`. |
| `qBitrr/logger.py` | Serilog in Host/WebUI/Workers | full | Structured logging with process metadata. |
| `qBitrr/errors.py` | Exception types across projects | full | HTTP error contracts on API endpoints. |
-| `qBitrr/utils.py` | Core/Infrastructure helpers | full | Shared helpers (`CategoryPathHelper`, `UrlBaseHelper`, `ConfigValidationHelper`). |
+| `qBitrr/utils.py` | Core/Infrastructure helpers, `HttpRetryHelper.cs` | full | `with_retry` parity on Arr/qBit HTTP; helpers (`CategoryPathHelper`, `CategoryOwnershipHelper`, `UrlBaseHelper`, `ConfigValidationHelper`). |
| `qBitrr/catalog_rollups.py` (5.12.0) | `CatalogRollupService.cs` | full | **Evidence:** [`CatalogRollupServiceTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Infrastructure.Tests/Services/CatalogRollupServiceTests.cs); wired into `/web|api/arr`, Radarr/Sonarr/Lidarr list endpoints. |
| `qBitrr/category_paths.py` (5.12.0) | `CategoryPathHelper.cs`, `ConfigValidationHelper.cs` | full | **Evidence:** [`CategoryPathHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/CategoryPathHelperTests.cs), [`ConfigValidationHelperTests`](https://github.com/Feramance/Torrentarr/blob/master/tests/Torrentarr.Core.Tests/Configuration/ConfigValidationHelperTests.cs); wired into torrent/category matching + config save validation. |
@@ -67,3 +67,4 @@ Status values:
- **Lidarr artists + thumbnails (5.12.0):** `ArrCatalogEndpoints` + `ArrThumbnailService` + frontend API client.
- **OpenAPI drift guard:** `scripts/check-openapi-drift.sh` in CI vs qBitrr `5.12.3`.
- **Config schema:** Torrentarr `6.12.2` (+1 major vs qBitrr `5.12.2`).
+- **Gap closeout (2026-06):** `MatchSubcategories`, qBit-only category workers, import path tracking, folder cleanup, category auto-creation, seeding rate limits, HTTP/DB retry, profile-switch retries, periodic WAL checkpoint, config-reload worker restart, WebUI `MatchSubcategories` fields, coordinated DB restart watchdog, full OpenAPI spec (72 paths), Lidarr artists `missing`/`reason` filters, `/api|web/docs` route aliases.
diff --git a/scripts/check-openapi-drift.sh b/scripts/check-openapi-drift.sh
index 3eeb7907..fb52fbc2 100755
--- a/scripts/check-openapi-drift.sh
+++ b/scripts/check-openapi-drift.sh
@@ -15,14 +15,21 @@ ta = json.load(open(ta_path))
qb = json.load(open(qb_path))
ta_paths = set(ta.get("paths", {}))
qb_paths = set(qb.get("paths", {}))
-# Torrentarr may document a subset; fail only when Torrentarr declares a path qBitrr dropped.
-missing_upstream = sorted(ta_paths - qb_paths)
-if missing_upstream:
- print("Torrentarr OpenAPI paths not present in qBitrr pin:")
- for p in missing_upstream:
+
+missing_in_ta = sorted(qb_paths - ta_paths)
+extensions = sorted(ta_paths - qb_paths)
+
+if missing_in_ta:
+ print(f"Torrentarr OpenAPI missing {len(missing_in_ta)} qBitrr path(s):")
+ for p in missing_in_ta:
print(" ", p)
sys.exit(1)
-print(f"OK: {len(ta_paths)} Torrentarr paths are a subset of {len(qb_paths)} qBitrr paths.")
+
+print(f"OK: {len(ta_paths)} Torrentarr paths cover all {len(qb_paths)} qBitrr paths.", end="")
+if extensions:
+ print(f" (+{len(extensions)} Torrentarr extensions: {', '.join(extensions)})")
+else:
+ print()
PY
rm -f "$TMP_QBITRR"
diff --git a/scripts/generate-openapi-from-qbitrr.py b/scripts/generate-openapi-from-qbitrr.py
new file mode 100644
index 00000000..02b7d44c
--- /dev/null
+++ b/scripts/generate-openapi-from-qbitrr.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Merge qBitrr OpenAPI pin into docs/assets/openapi.json with Torrentarr extensions."""
+from __future__ import annotations
+
+import json
+import sys
+import urllib.request
+from copy import deepcopy
+
+QBITRR_REF = "5.12.3"
+OUT_PATH = "docs/assets/openapi.json"
+
+EXTENSION_PATHS = {
+ "/api/qbit/categories": {
+ "get": {
+ "summary": "qBittorrent managed categories (/api)",
+ "tags": ["WebUI"],
+ "security": [{"bearerAuth": []}],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {"type": "object", "additionalProperties": True}
+ }
+ },
+ },
+ "401": {"$ref": "#/components/responses/Unauthorized"},
+ },
+ }
+ },
+ "/web/torrents/distribution": {
+ "get": {
+ "summary": "Torrent distribution by category (/web)",
+ "tags": ["WebUI"],
+ "security": [{"bearerAuth": []}],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {"type": "object", "additionalProperties": True}
+ }
+ },
+ },
+ "401": {"$ref": "#/components/responses/Unauthorized"},
+ },
+ }
+ },
+ "/web/lidarr/{category}/tracks": {
+ "get": {
+ "summary": "Lidarr tracks browse (/web)",
+ "tags": ["WebUI"],
+ "parameters": [
+ {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}},
+ {"name": "q", "in": "query", "schema": {"type": "string"}},
+ {"name": "page", "in": "query", "schema": {"type": "integer"}},
+ {"name": "page_size", "in": "query", "schema": {"type": "integer"}},
+ ],
+ "security": [{"bearerAuth": []}],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {"type": "object", "additionalProperties": True}
+ }
+ },
+ },
+ "401": {"$ref": "#/components/responses/Unauthorized"},
+ },
+ }
+ },
+ "/api/lidarr/{category}/tracks": {
+ "get": {
+ "summary": "Lidarr tracks browse (/api)",
+ "tags": ["WebUI"],
+ "parameters": [
+ {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}},
+ {"name": "q", "in": "query", "schema": {"type": "string"}},
+ {"name": "page", "in": "query", "schema": {"type": "integer"}},
+ {"name": "page_size", "in": "query", "schema": {"type": "integer"}},
+ ],
+ "security": [{"bearerAuth": []}],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {"type": "object", "additionalProperties": True}
+ }
+ },
+ },
+ "401": {"$ref": "#/components/responses/Unauthorized"},
+ },
+ }
+ },
+ "/web/arr/{category}/open/{kind}/{entryId}": {
+ "get": {
+ "summary": "Redirect to Arr UI for movie/series/artist (/web)",
+ "tags": ["WebUI"],
+ "parameters": [
+ {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}},
+ {"name": "kind", "in": "path", "required": True, "schema": {"type": "string"}},
+ {"name": "entryId", "in": "path", "required": True, "schema": {"type": "integer"}},
+ ],
+ "security": [{"bearerAuth": []}],
+ "responses": {
+ "302": {"description": "Redirect to Arr"},
+ "404": {"description": "Not found"},
+ "401": {"$ref": "#/components/responses/Unauthorized"},
+ },
+ }
+ },
+ "/api/arr/{category}/open/{kind}/{entryId}": {
+ "get": {
+ "summary": "Redirect to Arr UI for movie/series/artist (/api)",
+ "tags": ["WebUI"],
+ "parameters": [
+ {"name": "category", "in": "path", "required": True, "schema": {"type": "string"}},
+ {"name": "kind", "in": "path", "required": True, "schema": {"type": "string"}},
+ {"name": "entryId", "in": "path", "required": True, "schema": {"type": "integer"}},
+ ],
+ "security": [{"bearerAuth": []}],
+ "responses": {
+ "302": {"description": "Redirect to Arr"},
+ "404": {"description": "Not found"},
+ "401": {"$ref": "#/components/responses/Unauthorized"},
+ },
+ }
+ },
+}
+
+
+def main() -> int:
+ url = f"https://raw.githubusercontent.com/Feramance/qBitrr/v{QBITRR_REF}/qBitrr/openapi.json"
+ with urllib.request.urlopen(url) as resp:
+ spec = json.load(resp)
+
+ spec = deepcopy(spec)
+ spec["info"] = {
+ "title": "Torrentarr API",
+ "version": "v1",
+ "description": (
+ "API for qBittorrent + Arr automation (Torrentarr — C# port of qBitrr). "
+ f"Aligned with qBitrr {QBITRR_REF}. When WebUI.AuthDisabled is false, most routes "
+ "require a Bearer token (WebUI.Token) or a valid browser session after login. "
+ "Interactive docs: GET /web/docs or GET /api/docs."
+ ),
+ }
+ spec["paths"].update(EXTENSION_PATHS)
+
+ out = sys.argv[1] if len(sys.argv) > 1 else OUT_PATH
+ with open(out, "w", encoding="utf-8") as f:
+ json.dump(spec, f, indent=2)
+ f.write("\n")
+
+ print(f"Wrote {len(spec['paths'])} paths to {out}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs b/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs
new file mode 100644
index 00000000..dac9339c
--- /dev/null
+++ b/src/Torrentarr.Core/Configuration/CategoryOwnershipHelper.cs
@@ -0,0 +1,185 @@
+using Torrentarr.Core.Models;
+
+namespace Torrentarr.Core.Configuration;
+
+///
+/// qBitrr ArrManager.resolve_owning_category and managed-object registry parity.
+///
+public static class CategoryOwnershipHelper
+{
+ /// All category keys with active torrent processing (Arr categories + qBit-only managed).
+ public static HashSet BuildManagedObjectKeys(TorrentarrConfig config)
+ {
+ var keys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var a in config.ArrInstances.Values)
+ {
+ var cat = CategoryPathHelper.NormalizeCategory(a.Category);
+ if (!string.IsNullOrEmpty(cat))
+ keys.Add(cat);
+ }
+
+ foreach (var cat in GetQBitOnlyManagedCategories(config))
+ keys.Add(cat);
+
+ var failed = CategoryPathHelper.NormalizeCategory(config.Settings.FailedCategory);
+ var recheck = CategoryPathHelper.NormalizeCategory(config.Settings.RecheckCategory);
+ if (!string.IsNullOrEmpty(failed)) keys.Add(failed);
+ if (!string.IsNullOrEmpty(recheck)) keys.Add(recheck);
+
+ return keys;
+ }
+
+ /// ManagedCategories not already owned by an Arr instance category.
+ public static List GetQBitOnlyManagedCategories(TorrentarrConfig config)
+ {
+ var arrCategories = config.ArrInstances.Values
+ .Select(a => CategoryPathHelper.NormalizeCategory(a.Category))
+ .Where(c => !string.IsNullOrEmpty(c))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var result = new List();
+ foreach (var (_, qbit) in config.QBitInstances)
+ {
+ foreach (var raw in qbit.ManagedCategories)
+ {
+ var norm = CategoryPathHelper.NormalizeCategory(raw);
+ if (string.IsNullOrEmpty(norm))
+ continue;
+ if (!arrCategories.Contains(norm))
+ result.Add(norm);
+ }
+ }
+
+ return result.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ /// Find Arr section name for a category owner key.
+ public static string? FindArrSectionForCategory(TorrentarrConfig config, string ownerCategory)
+ {
+ foreach (var (name, arr) in config.ArrInstances)
+ {
+ if (CategoryPathHelper.CategoryEquals(arr.Category, ownerCategory))
+ return name;
+ }
+ return null;
+ }
+
+ public static bool QBitMatchSubcategories(TorrentarrConfig config, string qbitSection)
+ {
+ if (config.QBitInstances.TryGetValue(qbitSection, out var qbit))
+ return qbit.MatchSubcategories;
+ return false;
+ }
+
+ ///
+ /// Per-Arr override when set; otherwise inherits from the qBit instance default.
+ ///
+ public static bool ArrMatchSubcategoriesEffective(
+ TorrentarrConfig config,
+ string arrSectionName,
+ string qbitSection)
+ {
+ if (config.ArrInstances.TryGetValue(arrSectionName, out var arr)
+ && arr.MatchSubcategories.HasValue)
+ return arr.MatchSubcategories.Value;
+
+ return QBitMatchSubcategories(config, qbitSection);
+ }
+
+ ///
+ /// Whether prefix/subcategory matching is enabled for .
+ ///
+ public static bool PrefixMatchAllowedForOwner(
+ TorrentarrConfig config,
+ string ownerCategory,
+ string? qbitSection = null)
+ {
+ var norm = CategoryPathHelper.NormalizeCategory(ownerCategory);
+ if (string.IsNullOrEmpty(norm))
+ return false;
+
+ foreach (var (arrName, arr) in config.ArrInstances)
+ {
+ if (!CategoryPathHelper.CategoryEquals(arr.Category, norm))
+ continue;
+ var section = qbitSection ?? "qBit";
+ return ArrMatchSubcategoriesEffective(config, arrName, section);
+ }
+
+ if (config.ArrInstances.Values.Any(a =>
+ CategoryPathHelper.CategoryEquals(a.Category, norm)))
+ return false;
+
+ if (qbitSection != null)
+ return QBitMatchSubcategories(config, qbitSection);
+
+ return config.QBitInstances.Values.Any(q => q.MatchSubcategories);
+ }
+
+ ///
+ /// Return the managed-object key that owns (or null).
+ ///
+ public static string? ResolveOwningCategory(
+ TorrentarrConfig config,
+ string? torrentCategory,
+ string? qbitSection = null)
+ {
+ if (string.IsNullOrWhiteSpace(torrentCategory))
+ return null;
+
+ var norm = CategoryPathHelper.NormalizeCategory(torrentCategory);
+ if (string.IsNullOrEmpty(norm))
+ return null;
+
+ var managed = BuildManagedObjectKeys(config);
+ if (managed.Contains(norm))
+ return norm;
+
+ var eligible = managed
+ .Where(k => PrefixMatchAllowedForOwner(config, k, qbitSection))
+ .ToList();
+
+ if (eligible.Count == 0)
+ return null;
+
+ var match = CategoryPathHelper.MatchesConfigured(norm, eligible, prefix: true);
+ return match != null && managed.Contains(match) ? match : null;
+ }
+
+ ///
+ /// Gather torrents for an owner category across all qBit clients (MatchSubcategories-aware).
+ ///
+ public static async Task> GatherTorrentsForOwnerAsync(
+ TorrentarrConfig config,
+ string ownerCategory,
+ IReadOnlyDictionary>>> fetchAllByInstance,
+ IReadOnlyDictionary>>> fetchByCategory,
+ CancellationToken ct = default)
+ {
+ var target = CategoryPathHelper.NormalizeCategory(ownerCategory);
+ var results = new List();
+
+ foreach (var (instanceName, fetchAll) in fetchAllByInstance)
+ {
+ var usePrefix = PrefixMatchAllowedForOwner(config, target, instanceName);
+ List torrents;
+ if (usePrefix)
+ {
+ torrents = await fetchAll(ct);
+ torrents = torrents
+ .Where(t => ResolveOwningCategory(config, t.Category, instanceName) == target)
+ .ToList();
+ }
+ else
+ {
+ torrents = await fetchByCategory[instanceName](target, ct);
+ }
+
+ foreach (var t in torrents)
+ t.QBitInstanceName = instanceName;
+ results.AddRange(torrents);
+ }
+
+ return results;
+ }
+}
diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
index 618637f6..ff5aecfc 100644
--- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
+++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs
@@ -526,6 +526,12 @@ private static bool MigrateQBitCategorySettings(TomlTable root)
changed = true;
}
+ if (!qbitTable.ContainsKey("MatchSubcategories"))
+ {
+ qbitTable["MatchSubcategories"] = false;
+ changed = true;
+ }
+
if (!qbitTable.ContainsKey("CategorySeeding"))
{
var seeding = new TomlTable
@@ -1020,6 +1026,9 @@ private QBitConfig ParseQBit(TomlTable table)
if (table.TryGetValue("ManagedCategories", out var categories) && categories is TomlArray catArray)
qbit.ManagedCategories = catArray.Select(x => x?.ToString() ?? "").ToList();
+ if (table.TryGetValue("MatchSubcategories", out var matchSub))
+ qbit.MatchSubcategories = Convert.ToBoolean(matchSub);
+
if (table.TryGetValue("CategorySeeding", out var seedingObj) && seedingObj is TomlTable seedingTable)
qbit.CategorySeeding = ParseCategorySeeding(seedingTable);
@@ -1304,6 +1313,9 @@ private Dictionary ParseArrInstances(TomlTable rootTa
if (instanceTable.TryGetValue("ProcessingOnly", out var procOnly))
instance.ProcessingOnly = Convert.ToBoolean(procOnly);
+ if (instanceTable.TryGetValue("MatchSubcategories", out var matchSub))
+ instance.MatchSubcategories = Convert.ToBoolean(matchSub);
+
if (instanceTable.TryGetValue("ReSearch", out var reSearch))
instance.ReSearch = Convert.ToBoolean(reSearch);
@@ -1772,6 +1784,7 @@ private string GenerateTomlContent(TorrentarrConfig config)
if (!string.IsNullOrEmpty(qbit.DownloadPath))
sb.AppendLine($"DownloadPath = \"{qbit.DownloadPath}\"");
sb.AppendLine($"ManagedCategories = [{string.Join(", ", qbit.ManagedCategories.Select(c => $"\"{c}\""))}]");
+ sb.AppendLine($"MatchSubcategories = {qbit.MatchSubcategories.ToString().ToLower()}");
if (name == "qBit")
{
sb.AppendLine("# Shared tracker configs inherited by all Arr instances on this qBit instance.");
@@ -1809,6 +1822,8 @@ private string GenerateTomlContent(TorrentarrConfig config)
sb.AppendLine($"URI = \"{instance.URI}\"");
sb.AppendLine($"APIKey = \"{instance.APIKey}\"");
sb.AppendLine($"Category = \"{instance.Category}\"");
+ if (instance.MatchSubcategories.HasValue)
+ sb.AppendLine($"MatchSubcategories = {instance.MatchSubcategories.Value.ToString().ToLower()}");
sb.AppendLine($"ReSearch = {instance.ReSearch.ToString().ToLower()}");
sb.AppendLine($"importMode = \"{instance.ImportMode}\"");
sb.AppendLine($"RssSyncTimer = {instance.RssSyncTimer}");
diff --git a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
index 8a02b2c8..78c65a6a 100644
--- a/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
+++ b/src/Torrentarr.Core/Configuration/TorrentarrConfig.cs
@@ -92,6 +92,8 @@ public class QBitConfig
public string Password { get; set; } = "CHANGE_ME";
public string? DownloadPath { get; set; }
public List ManagedCategories { get; set; } = new();
+ /// When true, ManagedCategories entries match descendant subcategories (qBitrr parity).
+ public bool MatchSubcategories { get; set; } = false;
public List Trackers { get; set; } = new();
public CategorySeedingConfig CategorySeeding { get; set; } = new();
}
@@ -194,6 +196,8 @@ public class ArrInstanceConfig
public string Type { get; set; } = ""; // radarr, sonarr, lidarr
public bool SearchOnly { get; set; } = false;
public bool ProcessingOnly { get; set; } = false;
+ /// Override qBit MatchSubcategories for this Arr instance; null inherits from qBit.
+ public bool? MatchSubcategories { get; set; }
// Search/processing options
public bool ReSearch { get; set; } = true;
diff --git a/src/Torrentarr.Core/Services/IImportPathTracker.cs b/src/Torrentarr.Core/Services/IImportPathTracker.cs
new file mode 100644
index 00000000..35844c31
--- /dev/null
+++ b/src/Torrentarr.Core/Services/IImportPathTracker.cs
@@ -0,0 +1,13 @@
+namespace Torrentarr.Core.Services;
+
+///
+/// Tracks content paths already sent to Arr scan commands (qBitrr sent_to_scan parity).
+///
+public interface IImportPathTracker
+{
+ bool IsPathAlreadyScanned(string normalizedPath);
+ bool IsHashAlreadyScanned(string hash);
+ void MarkScanned(string normalizedPath, string hash);
+ void RemoveEmptyPathsUnder(string completedFolderRoot);
+ void ClearIfFolderEmpty(string completedFolderRoot);
+}
diff --git a/src/Torrentarr.Host/CuratedOpenApiDocument.cs b/src/Torrentarr.Host/CuratedOpenApiDocument.cs
new file mode 100644
index 00000000..d04071fa
--- /dev/null
+++ b/src/Torrentarr.Host/CuratedOpenApiDocument.cs
@@ -0,0 +1,31 @@
+using System.Text.Json;
+
+namespace Torrentarr.Host;
+
+/// Serves the curated OpenAPI document shipped with the Host (qBitrr 5.12.3 parity).
+public static class CuratedOpenApiDocument
+{
+ private static readonly Lazy Json = new(Load);
+
+ public static string GetJson() => Json.Value;
+
+ private static string Load()
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "openapi.json");
+ if (!File.Exists(path))
+ throw new FileNotFoundException("Curated OpenAPI spec not found beside the Host binary.", path);
+ return File.ReadAllText(path);
+ }
+
+ public static IResult ServeJson(HttpContext ctx)
+ {
+ ctx.Response.Headers.CacheControl = "no-store";
+ return Results.Content(GetJson(), "application/json");
+ }
+
+ public static IResult RedirectToSwagger(string specPath)
+ {
+ var encoded = Uri.EscapeDataString(specPath);
+ return Results.Redirect($"/swagger/index.html?url={encoded}");
+ }
+}
diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs
index 7ccf2b97..99f629ad 100644
--- a/src/Torrentarr.Host/Program.cs
+++ b/src/Torrentarr.Host/Program.cs
@@ -154,6 +154,8 @@
builder.Services.AddSingleton(levelSwitch);
builder.Services.AddSingleton(config);
builder.Services.AddSingleton(configLoader);
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -170,6 +172,13 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService(sp => sp.GetRequiredService());
+ builder.Services.AddHostedService();
+ builder.Services.AddSingleton();
// §6.10 / §1.8: update check + auto-update
builder.Services.AddSingleton();
builder.Services.AddHostedService();
@@ -507,6 +516,12 @@
timestamp = DateTime.UtcNow
}));
+ // qBitrr parity: curated OpenAPI + Swagger UI aliases
+ app.MapGet("/api/openapi.json", (HttpContext ctx) => CuratedOpenApiDocument.ServeJson(ctx));
+ app.MapGet("/web/openapi.json", (HttpContext ctx) => CuratedOpenApiDocument.ServeJson(ctx));
+ app.MapGet("/api/docs", () => CuratedOpenApiDocument.RedirectToSwagger("/api/openapi.json"));
+ app.MapGet("/web/docs", () => CuratedOpenApiDocument.RedirectToSwagger("/web/openapi.json"));
+
// ==================== /web/* endpoints ====================
// Web Meta — fetches latest release from GitHub and compares with current version
@@ -1288,7 +1303,7 @@ orderby t.TrackNumber
// Web Config Update — frontend sends { changes: { "Section.Key": value, ... } } (dotted keys).
// ConfigView.tsx flatten()s the hierarchical config into dotted paths before sending only the
// changed keys. We apply those changes onto the current in-memory config and save.
- app.MapPost("/web/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader) =>
+ app.MapPost("/web/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader, ArrWorkerManager workerMgr, QBitCategoryWorkerManager qbitCategoryMgr) =>
{
try
{
@@ -1307,7 +1322,7 @@ orderby t.TrackNumber
if (!valid)
return Results.BadRequest(new { error = validationError });
- return SaveAndRespondConfigUpdate(cfg, updatedConfig, loader);
+ return await SaveAndRespondConfigUpdate(cfg, updatedConfig, loader, workerMgr, qbitCategoryMgr);
}
catch (Exception ex)
{
@@ -2144,7 +2159,7 @@ orderby t.TrackNumber
app.MapGet("/api/config", (TorrentarrConfig cfg) => Results.Ok(cfg));
- app.MapPost("/api/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader) =>
+ app.MapPost("/api/config", async (HttpRequest request, TorrentarrConfig cfg, ConfigurationLoader loader, ArrWorkerManager workerMgr, QBitCategoryWorkerManager qbitCategoryMgr) =>
{
try
{
@@ -2166,7 +2181,7 @@ orderby t.TrackNumber
if (!valid)
return Results.BadRequest(new { error = validationError });
- return SaveAndRespondConfigUpdate(cfg, updatedConfig, loader);
+ return await SaveAndRespondConfigUpdate(cfg, updatedConfig, loader, workerMgr, qbitCategoryMgr);
}
catch (Exception ex)
{
@@ -2710,10 +2725,12 @@ static async Task HandleTestConnection(TestConnectionRequest req, Torre
return (updatedConfig, null);
}
-static IResult SaveAndRespondConfigUpdate(
+static async Task SaveAndRespondConfigUpdate(
TorrentarrConfig cfg,
TorrentarrConfig updatedConfig,
- ConfigurationLoader loader)
+ ConfigurationLoader loader,
+ ArrWorkerManager? workerMgr = null,
+ QBitCategoryWorkerManager? qbitCategoryMgr = null)
{
var passwordHashError = WebUIAuthHelpers.ValidatePasswordHashForConfigApiSave(cfg, updatedConfig);
if (passwordHashError != null)
@@ -2727,6 +2744,24 @@ static IResult SaveAndRespondConfigUpdate(
cfg.ArrInstances = updatedConfig.ArrInstances;
cfg.QBitInstances = updatedConfig.QBitInstances;
TorrentPolicyHelper.InvalidateMonitoredPolicyCategoriesCache(cfg);
+
+ if (workerMgr != null)
+ {
+ switch (reloadType)
+ {
+ case "full":
+ await workerMgr.RestartAllWorkersAsync();
+ if (qbitCategoryMgr != null)
+ await qbitCategoryMgr.SyncWorkersWithConfigAsync();
+ break;
+ case "multi_arr":
+ case "single_arr":
+ foreach (var inst in affectedInstancesList)
+ await workerMgr.RestartWorkerAsync(inst);
+ break;
+ }
+ }
+
return Results.Ok(new
{
status = "ok",
diff --git a/src/Torrentarr.Host/Torrentarr.Host.csproj b/src/Torrentarr.Host/Torrentarr.Host.csproj
index b4753db3..ec531397 100644
--- a/src/Torrentarr.Host/Torrentarr.Host.csproj
+++ b/src/Torrentarr.Host/Torrentarr.Host.csproj
@@ -23,4 +23,8 @@
+
+
+
+
diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs
index 5152efdc..b9790c10 100644
--- a/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs
+++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/ArrClientResponse.cs
@@ -1,10 +1,14 @@
using System.Net;
using RestSharp;
+using Torrentarr.Infrastructure.Http;
namespace Torrentarr.Infrastructure.ApiClients.Arr;
internal static class ArrClientResponse
{
+ internal static Task ExecuteAsync(RestClient client, RestRequest request, CancellationToken ct) =>
+ HttpRetryHelper.ExecuteArrAsync(client, request, ct);
+
internal static void EnsureSuccess(RestResponse response, string operation)
{
if (response.ResponseStatus != ResponseStatus.Completed)
diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs
index a6e1bc9f..7951aae2 100644
--- a/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs
+++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/LidarrClient.cs
@@ -31,7 +31,7 @@ public async Task> GetArtistsAsync(CancellationToken ct = def
var request = new RestRequest("/api/v1/artist", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v1/artist");
if (string.IsNullOrEmpty(response.Content))
@@ -48,7 +48,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v1/system/status", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -66,7 +66,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default)
var request = new RestRequest($"/api/v1/artist/{artistId}", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -87,7 +87,7 @@ public async Task> GetAlbumsAsync(int? artistId = null, Cancel
if (artistId.HasValue)
request.AddQueryParameter("artistId", artistId.Value.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v1/album");
if (string.IsNullOrEmpty(response.Content))
@@ -112,7 +112,7 @@ public async Task SearchArtistAsync(int artistId, CancellationToken ct = d
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -132,7 +132,7 @@ public async Task SearchAlbumAsync(List albumIds, CancellationToken c
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -146,7 +146,7 @@ public async Task SearchAlbumAsync(List albumIds, CancellationToken c
request.AddJsonBody(artist);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -182,7 +182,7 @@ public async Task GetWantedAsync(int page = 1, int pageSize
request.AddQueryParameter("page", page.ToString());
request.AddQueryParameter("pageSize", pageSize.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -203,7 +203,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize
request.AddQueryParameter("page", page.ToString());
request.AddQueryParameter("pageSize", pageSize.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v1/queue");
if (string.IsNullOrEmpty(response.Content))
@@ -234,7 +234,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -252,7 +252,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d
var request = new RestRequest("/api/v1/command", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -270,7 +270,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d
var request = new RestRequest($"/api/v1/trackfile/{trackFileId}", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -291,7 +291,7 @@ public async Task> GetTracksAsync(int? albumId = null, CancellationT
if (albumId.HasValue)
request.AddQueryParameter("albumId", albumId.Value.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v1/track");
if (string.IsNullOrEmpty(response.Content))
@@ -311,7 +311,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
request.AddQueryParameter("removeFromClient", removeFromClient.ToString().ToLower());
request.AddQueryParameter("blocklist", blocklist.ToString().ToLower());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -326,7 +326,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
var command = new { name = "RssSync" };
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -347,7 +347,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
var command = new { name = "RefreshMonitoredDownloads" };
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -365,7 +365,7 @@ public async Task> GetQualityProfilesAsync(CancellationToke
var request = new RestRequest("/api/v1/qualityprofile", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -381,7 +381,7 @@ public async Task> GetTrackFilesByAlbumAsync(int albumId, Cancel
AddApiKeyHeader(request);
request.AddQueryParameter("albumId", albumId.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -397,7 +397,7 @@ public async Task RescanAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v1/command", Method.Post);
AddApiKeyHeader(request);
request.AddJsonBody(new { name = "RescanArtist" });
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs
index 65167def..af63036d 100644
--- a/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs
+++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/RadarrClient.cs
@@ -31,7 +31,7 @@ public async Task> GetMoviesAsync(CancellationToken ct = defau
var request = new RestRequest("/api/v3/movie", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v3/movie");
if (string.IsNullOrEmpty(response.Content))
@@ -48,7 +48,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v3/system/status", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -66,7 +66,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default)
var request = new RestRequest($"/api/v3/movie/{movieId}", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -92,7 +92,7 @@ public async Task SearchMovieAsync(int movieId, CancellationToken ct = def
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -106,7 +106,7 @@ public async Task SearchMovieAsync(int movieId, CancellationToken ct = def
request.AddJsonBody(movie);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -124,7 +124,7 @@ public async Task> GetQualityProfilesAsync(CancellationToke
var request = new RestRequest("/api/v3/qualityprofile", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -147,7 +147,7 @@ public async Task GetWantedAsync(int page = 1, int pageSize = 50
request.AddQueryParameter("sortKey", "title");
request.AddQueryParameter("sortDirection", "ascending");
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -168,7 +168,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize = 1000
request.AddQueryParameter("page", page.ToString());
request.AddQueryParameter("pageSize", pageSize.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v3/queue");
if (string.IsNullOrEmpty(response.Content))
@@ -199,7 +199,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize = 1000
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -217,7 +217,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d
var request = new RestRequest("/api/v3/command", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -235,7 +235,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d
var request = new RestRequest($"/api/v3/moviefile/{movieFileId}", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -256,7 +256,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
request.AddQueryParameter("removeFromClient", removeFromClient.ToString().ToLower());
request.AddQueryParameter("blocklist", blocklist.ToString().ToLower());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -271,7 +271,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
var command = new { name = "RssSync" };
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -292,7 +292,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
var command = new { name = "RefreshMonitoredDownloads" };
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -323,7 +323,7 @@ public async Task> GetCustomFormatsAsync(CancellationToken ct
var request = new RestRequest("/api/v3/customformat", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -339,7 +339,7 @@ public async Task> GetMovieFilesAsync(int movieId, CancellationT
AddApiKeyHeader(request);
request.AddQueryParameter("movieId", movieId.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -355,7 +355,7 @@ public async Task> GetMoviesWithFilesAsync(CancellationToken c
AddApiKeyHeader(request);
request.AddQueryParameter("includeMovieImages", "false");
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -371,7 +371,7 @@ public async Task RescanAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v3/command", Method.Post);
AddApiKeyHeader(request);
request.AddJsonBody(new { name = "RescanMovie" });
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
diff --git a/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs b/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs
index acf43c9e..ad97c823 100644
--- a/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs
+++ b/src/Torrentarr.Infrastructure/ApiClients/Arr/SonarrClient.cs
@@ -31,7 +31,7 @@ public async Task> GetSeriesAsync(CancellationToken ct = defa
var request = new RestRequest("/api/v3/series", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v3/series");
if (string.IsNullOrEmpty(response.Content))
@@ -48,7 +48,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v3/system/status", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -66,7 +66,7 @@ public async Task GetSystemInfoAsync(CancellationToken ct = default)
var request = new RestRequest($"/api/v3/series/{seriesId}", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -86,7 +86,7 @@ public async Task> GetEpisodesAsync(int seriesId, Cancellati
request.AddQueryParameter("seriesId", seriesId.ToString());
request.AddQueryParameter("includeEpisodeFile", "true");
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v3/episode");
if (string.IsNullOrEmpty(response.Content))
@@ -111,7 +111,7 @@ public async Task SearchSeriesAsync(int seriesId, CancellationToken ct = d
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -131,7 +131,7 @@ public async Task SearchEpisodeAsync(List episodeIds, CancellationTok
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -145,7 +145,7 @@ public async Task SearchEpisodeAsync(List episodeIds, CancellationTok
request.AddJsonBody(series);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -168,7 +168,7 @@ public async Task GetWantedAsync(int page = 1, int pageSi
request.AddQueryParameter("sortKey", "airDateUtc");
request.AddQueryParameter("sortDirection", "descending");
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -189,7 +189,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize
request.AddQueryParameter("page", page.ToString());
request.AddQueryParameter("pageSize", pageSize.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
ArrClientResponse.EnsureSuccess(response, "GET /api/v3/queue");
if (string.IsNullOrEmpty(response.Content))
@@ -220,7 +220,7 @@ public async Task GetQueueAsync(int page = 1, int pageSize
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -238,7 +238,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d
var request = new RestRequest("/api/v3/command", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -256,7 +256,7 @@ public async Task> GetCommandsAsync(CancellationToken ct = d
var request = new RestRequest($"/api/v3/episodefile/{episodeFileId}", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -277,7 +277,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
request.AddQueryParameter("removeFromClient", removeFromClient.ToString().ToLower());
request.AddQueryParameter("blocklist", blocklist.ToString().ToLower());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
@@ -292,7 +292,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
var command = new { name = "RssSync" };
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -313,7 +313,7 @@ public async Task DeleteFromQueueAsync(int id, bool removeFromClient = tru
var command = new { name = "RefreshMonitoredDownloads" };
request.AddJsonBody(command);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -344,7 +344,7 @@ public async Task> GetQualityProfilesAsync(CancellationToke
var request = new RestRequest("/api/v3/qualityprofile", Method.Get);
AddApiKeyHeader(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -360,7 +360,7 @@ public async Task> GetEpisodeFilesAsync(int seriesId, Cancella
AddApiKeyHeader(request);
request.AddQueryParameter("seriesId", seriesId.ToString());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -382,7 +382,7 @@ public async Task RescanAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v3/command", Method.Post);
AddApiKeyHeader(request);
request.AddJsonBody(new { name = "RescanSeries" });
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ArrClientResponse.ExecuteAsync(_client, request, ct);
return response.IsSuccessful;
}
diff --git a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs
index 21e46c60..96ab96a9 100644
--- a/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs
+++ b/src/Torrentarr.Infrastructure/ApiClients/QBittorrent/QBittorrentClient.cs
@@ -2,6 +2,7 @@
using Newtonsoft.Json;
using RestSharp;
using RestSharp.Authenticators;
+using Torrentarr.Infrastructure.Http;
namespace Torrentarr.Infrastructure.ApiClients.QBittorrent;
@@ -41,6 +42,9 @@ public QBittorrentClient(string host, int port, string username, string password
_client = new RestClient(options);
}
+ private Task ExecuteAsync(RestRequest request, CancellationToken ct) =>
+ HttpRetryHelper.ExecuteQBitAsync(_client, request, ct);
+
///
/// Authenticate with qBittorrent
///
@@ -50,7 +54,7 @@ public async Task LoginAsync(CancellationToken ct = default)
request.AddParameter("username", _username);
request.AddParameter("password", _password);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && response.Content == "Ok.")
{
@@ -74,7 +78,7 @@ public async Task GetVersionAsync(CancellationToken ct = default)
var request = new RestRequest("/api/v2/app/version", Method.Get);
AddAuthCookie(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.Content ?? "";
}
@@ -91,7 +95,7 @@ public async Task> GetTorrentsAsync(string? category = null, s
if (!string.IsNullOrEmpty(sort))
request.AddQueryParameter("sort", sort);
- var response = await _client.ExecuteAsync(request, cancellationToken);
+ var response = await ExecuteAsync(request, cancellationToken);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -115,7 +119,7 @@ public async Task AddTorrentAsync(string url, string? category = null, str
if (!string.IsNullOrEmpty(savePath))
request.AddParameter("savepath", savePath);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful && response.Content == "Ok.";
}
@@ -130,7 +134,7 @@ public async Task DeleteTorrentsAsync(List hashes, bool deleteFile
request.AddParameter("hashes", string.Join("|", hashes));
request.AddParameter("deleteFiles", deleteFiles.ToString().ToLower());
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -144,7 +148,7 @@ public async Task PauseTorrentsAsync(List hashes, CancellationToke
request.AddParameter("hashes", string.Join("|", hashes));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -158,7 +162,7 @@ public async Task ResumeTorrentsAsync(List hashes, CancellationTok
request.AddParameter("hashes", string.Join("|", hashes));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -189,7 +193,7 @@ public async Task SetCategoryAsync(List hashes, string category, C
request.AddParameter("hashes", string.Join("|", hashes));
request.AddParameter("category", category);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -201,7 +205,7 @@ public async Task> GetCategoriesAsync(Cancellat
var request = new RestRequest("/api/v2/torrents/categories", Method.Get);
AddAuthCookie(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -223,7 +227,7 @@ public async Task AddTagsAsync(List hashes, List tags, Can
request.AddParameter("hashes", string.Join("|", hashes));
request.AddParameter("tags", string.Join(",", tags));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -238,7 +242,7 @@ public async Task RemoveTagsAsync(List hashes, List tags,
request.AddParameter("hashes", string.Join("|", hashes));
request.AddParameter("tags", string.Join(",", tags));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -252,7 +256,7 @@ public async Task CreateTagsAsync(List tags, CancellationToken ct
request.AddParameter("tags", string.Join(",", tags));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -264,7 +268,7 @@ public async Task> GetTagsAsync(CancellationToken ct = default)
var request = new RestRequest("api/v2/torrents/tags", Method.Get);
AddAuthCookie(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -283,7 +287,7 @@ public async Task> GetTorrentTrackersAsync(string hash, Can
AddAuthCookie(request);
request.AddQueryParameter("hash", hash);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -302,7 +306,7 @@ public async Task> GetTorrentTrackersAsync(string hash, Can
AddAuthCookie(request);
request.AddQueryParameter("hash", hash);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -322,7 +326,7 @@ public async Task RecheckTorrentsAsync(List hashes, CancellationTo
request.AddParameter("hashes", string.Join("|", hashes));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -338,7 +342,7 @@ public async Task SetShareLimitsAsync(string hash, double ratioLimit, long
request.AddParameter("ratioLimit", ratioLimit);
request.AddParameter("seedingTimeLimit", seedingTimeLimit);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -353,7 +357,7 @@ public async Task SetDownloadLimitAsync(string hash, long limit, Cancellat
request.AddParameter("hashes", hash);
request.AddParameter("limit", limit);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -368,7 +372,7 @@ public async Task SetUploadLimitAsync(string hash, long limit, Cancellatio
request.AddParameter("hashes", hash);
request.AddParameter("limit", limit);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -383,7 +387,7 @@ public async Task SetSuperSeedingAsync(string hash, bool enabled, Cancella
request.AddParameter("hashes", hash);
request.AddParameter("value", enabled ? "true" : "false");
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -395,7 +399,7 @@ public async Task SetTopPriorityAsync(string hash, CancellationToken ct =
var request = new RestRequest("api/v2/torrents/topPrio", Method.Post);
AddAuthCookie(request);
request.AddParameter("hashes", hash);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -410,7 +414,7 @@ public async Task AddTrackersAsync(string hash, List urls, Cancell
request.AddParameter("hash", hash);
request.AddParameter("urls", string.Join("\n", urls));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -425,7 +429,7 @@ public async Task RemoveTrackersAsync(string hash, List urls, Canc
request.AddParameter("hash", hash);
request.AddParameter("urls", string.Join("|", urls));
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -438,7 +442,7 @@ public async Task> GetTorrentFilesAsync(string hash, Cancellat
AddAuthCookie(request);
request.AddQueryParameter("hash", hash);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -461,7 +465,7 @@ public async Task SetFilePriorityAsync(string hash, int[] fileIds, int pri
request.AddParameter("id", string.Join("|", fileIds));
request.AddParameter("priority", priority);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -477,7 +481,7 @@ public async Task CreateCategoryAsync(string name, string? savePath = null
if (!string.IsNullOrEmpty(savePath))
request.AddParameter("savePath", savePath);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -492,7 +496,7 @@ public async Task EditCategoryAsync(string name, string savePath, Cancella
request.AddParameter("category", name);
request.AddParameter("savePath", savePath);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -506,7 +510,7 @@ public async Task DeleteCategoryAsync(string name, CancellationToken ct =
request.AddParameter("categories", name);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
return response.IsSuccessful;
}
@@ -518,7 +522,7 @@ public async Task DeleteCategoryAsync(string name, CancellationToken ct =
var request = new RestRequest("api/v2/transfer/info", Method.Get);
AddAuthCookie(request);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
@@ -539,7 +543,7 @@ public async Task DeleteCategoryAsync(string name, CancellationToken ct =
if (rid.HasValue)
request.AddQueryParameter("rid", rid.Value);
- var response = await _client.ExecuteAsync(request, ct);
+ var response = await ExecuteAsync(request, ct);
if (response.IsSuccessful && !string.IsNullOrEmpty(response.Content))
{
diff --git a/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs
new file mode 100644
index 00000000..8d43650a
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Database/DatabaseRetryExtensions.cs
@@ -0,0 +1,73 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Torrentarr.Infrastructure.Services;
+
+namespace Torrentarr.Infrastructure.Database;
+
+///
+/// qBitrr with_database_retry parity for transient SQLite errors.
+///
+public static class DatabaseRetryExtensions
+{
+ public static async Task SaveChangesWithRetryAsync(
+ this DbContext context,
+ ILogger? logger = null,
+ DatabaseRestartCoordinator? restartCoordinator = null,
+ int maxAttempts = 5,
+ CancellationToken cancellationToken = default)
+ {
+ for (var attempt = 0; attempt < maxAttempts; attempt++)
+ {
+ try
+ {
+ var result = await context.SaveChangesAsync(cancellationToken);
+ restartCoordinator?.RecordDatabaseSuccess();
+ return result;
+ }
+ catch (Exception ex) when (IsRetriable(ex) && attempt < maxAttempts - 1)
+ {
+ restartCoordinator?.RecordDatabaseError();
+
+ var delay = TimeSpan.FromMilliseconds(500 * Math.Pow(2, attempt));
+ logger?.LogWarning(ex, "Database save retry {Attempt}/{Max}, waiting {Delay}ms",
+ attempt + 1, maxAttempts, delay.TotalMilliseconds);
+ await Task.Delay(delay, cancellationToken);
+
+ if (IsCorruption(ex) && context.Database.GetDbConnection() is SqliteConnection sqlite)
+ {
+ try
+ {
+ await context.Database.CloseConnectionAsync();
+ await using var conn = new SqliteConnection(sqlite.ConnectionString);
+ await conn.OpenAsync(cancellationToken);
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)";
+ await cmd.ExecuteNonQueryAsync(cancellationToken);
+ }
+ catch (Exception repairEx)
+ {
+ logger?.LogWarning(repairEx, "WAL checkpoint during DB retry failed");
+ }
+ }
+ }
+ catch (Exception ex) when (IsRetriable(ex))
+ {
+ restartCoordinator?.RecordDatabaseError();
+ throw;
+ }
+ }
+
+ return await context.SaveChangesAsync(cancellationToken);
+ }
+
+ private static bool IsRetriable(Exception ex) =>
+ ex is DbUpdateException or SqliteException
+ && (ex.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase)
+ || ex.Message.Contains("disk I/O", StringComparison.OrdinalIgnoreCase)
+ || ex.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase));
+
+ private static bool IsCorruption(Exception ex) =>
+ ex.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase)
+ || ex.Message.Contains("disk I/O", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs b/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs
index a4620755..fd4d2ce7 100644
--- a/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs
+++ b/src/Torrentarr.Infrastructure/Endpoints/ArrCatalogEndpoints.cs
@@ -3,6 +3,7 @@
using Microsoft.EntityFrameworkCore;
using Torrentarr.Core.Configuration;
using Torrentarr.Infrastructure.Database;
+using Torrentarr.Infrastructure.Database.Models;
using Torrentarr.Infrastructure.Services;
namespace Torrentarr.Infrastructure.Endpoints;
@@ -35,10 +36,17 @@ private static void MapLidarrArtists(WebApplication app, string pattern)
int? page,
int? page_size,
string? q,
- bool? monitored) =>
+ bool? monitored,
+ bool? missing,
+ string? reason) =>
{
var currentPage = page ?? 0;
var pageSize = Math.Clamp(page_size ?? 50, 1, 1000);
+ var missingOnly = missing == true;
+ var reasonFilter = string.IsNullOrWhiteSpace(reason) || reason.Equals("all", StringComparison.OrdinalIgnoreCase)
+ ? null
+ : reason.Trim();
+
var (albumCounts, albumTotal, trackCounts) = await rollups.GetLidarrRollupsAsync(category);
var query = db.Artists.Where(a => a.ArrInstance == category);
@@ -47,6 +55,23 @@ private static void MapLidarrArtists(WebApplication app, string pattern)
if (monitored.HasValue)
query = query.Where(a => a.Monitored == monitored.Value);
+ if (missingOnly || reasonFilter is not null)
+ {
+ var albumQuery = db.Albums.Where(al => al.ArrInstance == category);
+ if (missingOnly)
+ albumQuery = albumQuery.Where(al => al.Monitored && !al.HasFile);
+ if (reasonFilter is not null)
+ {
+ if (reasonFilter.Equals("Not being searched", StringComparison.OrdinalIgnoreCase))
+ albumQuery = albumQuery.Where(al => al.Reason == null || al.Reason == "Not being searched");
+ else
+ albumQuery = albumQuery.Where(al => al.Reason == reasonFilter);
+ }
+
+ var artistIds = await albumQuery.Select(al => al.ArtistId).Distinct().ToListAsync();
+ query = query.Where(a => artistIds.Contains(a.EntryId));
+ }
+
var total = await query.CountAsync();
var artists = await query
.OrderBy(a => a.Title)
@@ -54,9 +79,52 @@ private static void MapLidarrArtists(WebApplication app, string pattern)
.Take(pageSize)
.ToListAsync();
- var artistIds = artists.Select(a => a.EntryId).ToList();
+ if (total == 0 && albumTotal > 0 && (missingOnly || reasonFilter is not null || !string.IsNullOrWhiteSpace(q)))
+ {
+ var albumFallback = db.Albums.Where(al => al.ArrInstance == category);
+ if (!string.IsNullOrWhiteSpace(q))
+ albumFallback = albumFallback.Where(al => al.ArtistTitle != null && al.ArtistTitle.Contains(q));
+ if (missingOnly)
+ albumFallback = albumFallback.Where(al => al.Monitored && !al.HasFile);
+ if (reasonFilter is not null)
+ {
+ if (reasonFilter.Equals("Not being searched", StringComparison.OrdinalIgnoreCase))
+ albumFallback = albumFallback.Where(al => al.Reason == null || al.Reason == "Not being searched");
+ else
+ albumFallback = albumFallback.Where(al => al.Reason == reasonFilter);
+ }
+
+ var grouped = await albumFallback
+ .GroupBy(al => al.ArtistId)
+ .Select(g => new
+ {
+ ArtistId = g.Key,
+ Title = g.Min(al => al.ArtistTitle) ?? "",
+ Monitored = g.Max(al => al.Monitored ? 1 : 0) == 1
+ })
+ .ToListAsync();
+
+ if (monitored.HasValue)
+ grouped = grouped.Where(g => g.Monitored == monitored.Value).ToList();
+
+ total = grouped.Count;
+ artists = grouped
+ .OrderBy(g => g.Title)
+ .Skip(currentPage * pageSize)
+ .Take(pageSize)
+ .Select(g => new ArtistFilesModel
+ {
+ EntryId = g.ArtistId,
+ Title = g.Title,
+ Monitored = g.Monitored,
+ ArrInstance = category
+ })
+ .ToList();
+ }
+
+ var artistIdsListed = artists.Select(a => a.EntryId).ToList();
var albumStats = await db.Albums
- .Where(al => al.ArrInstance == category && artistIds.Contains(al.ArtistId))
+ .Where(al => al.ArrInstance == category && artistIdsListed.Contains(al.ArtistId))
.GroupBy(al => al.ArtistId)
.Select(g => new
{
diff --git a/src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs b/src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs
new file mode 100644
index 00000000..1491d9d3
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Http/HttpRetryHelper.cs
@@ -0,0 +1,55 @@
+using System.Net;
+using Polly;
+using Polly.Retry;
+using RestSharp;
+
+namespace Torrentarr.Infrastructure.Http;
+
+///
+/// qBitrr with_retry parity for HTTP API calls.
+///
+public static class HttpRetryHelper
+{
+ private static readonly ResiliencePipeline ArrPipeline = CreatePipeline(5, 0.5, 5.0);
+ private static readonly ResiliencePipeline QBitPipeline = CreatePipeline(3, 0.5, 3.0);
+
+ public static async Task ExecuteArrAsync(
+ RestClient client,
+ RestRequest request,
+ CancellationToken ct = default) =>
+ await ArrPipeline.ExecuteAsync(async token => await client.ExecuteAsync(request, token), ct);
+
+ public static async Task ExecuteQBitAsync(
+ RestClient client,
+ RestRequest request,
+ CancellationToken ct = default) =>
+ await QBitPipeline.ExecuteAsync(async token => await client.ExecuteAsync(request, token), ct);
+
+ private static ResiliencePipeline CreatePipeline(int maxAttempts, double backoffSeconds, double maxBackoffSeconds)
+ {
+ return new ResiliencePipelineBuilder()
+ .AddRetry(new RetryStrategyOptions
+ {
+ MaxRetryAttempts = maxAttempts,
+ DelayGenerator = args =>
+ {
+ var delay = Math.Min(
+ maxBackoffSeconds,
+ backoffSeconds * Math.Pow(2, args.AttemptNumber));
+ return ValueTask.FromResult(TimeSpan.FromSeconds(delay));
+ },
+ ShouldHandle = new PredicateBuilder()
+ .Handle()
+ .Handle(ex => !ex.CancellationToken.IsCancellationRequested)
+ .HandleResult(r =>
+ r.ResponseStatus != ResponseStatus.Completed
+ || r.StatusCode is HttpStatusCode.RequestTimeout
+ or HttpStatusCode.TooManyRequests
+ or HttpStatusCode.BadGateway
+ or HttpStatusCode.ServiceUnavailable
+ or HttpStatusCode.GatewayTimeout
+ || (int)r.StatusCode >= 500)
+ })
+ .Build();
+ }
+}
diff --git a/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs b/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs
index e20e49dd..f4f5ac4e 100644
--- a/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs
+++ b/src/Torrentarr.Infrastructure/Services/ArrSyncService.cs
@@ -27,15 +27,18 @@ public class ArrSyncService
private readonly ILogger _logger;
private readonly TorrentarrConfig _config;
private readonly TorrentarrDbContext _db;
+ private readonly DatabaseRestartCoordinator _restartCoordinator;
public ArrSyncService(
ILogger logger,
TorrentarrConfig config,
- TorrentarrDbContext db)
+ TorrentarrDbContext db,
+ DatabaseRestartCoordinator restartCoordinator)
{
_logger = logger;
_config = config;
_db = db;
+ _restartCoordinator = restartCoordinator;
}
public async Task SyncAsync(string instanceName, CancellationToken ct = default)
@@ -225,7 +228,7 @@ private async Task SyncRadarrAsync(string instanceName, ArrInstanceConfig cfg, C
if (toDelete.Count > 0 && !ShouldSkipDestructiveDelete(movies.Count, dbMovies.Count, instanceName, "movies"))
_db.Movies.RemoveRange(toDelete);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("[{Instance}] ArrSyncService: Radarr {Name} synced {Count} movies - Added: {Added}, Updated: {Updated}, Deleted: {Deleted}", instanceName, instanceName, movies.Count, added, updated, toDelete.Count);
_logger.LogTrace("[{Instance}] Finished updating database for Radarr instance {Name}", instanceName, instanceName);
}
@@ -269,7 +272,7 @@ private async Task SyncRadarrQueueAsync(string instanceName, ArrInstanceConfig c
if (toDelete.Count > 0)
_db.MovieQueue.RemoveRange(toDelete);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("ArrSyncService: Radarr {Name} synced {Count} queue items", instanceName, queueItems.Count);
// §1.7: Scan for ArrErrorCodesToBlocklist matches
@@ -366,7 +369,7 @@ private async Task SyncSonarrAsync(string instanceName, ArrInstanceConfig cfg, C
_db.Series.RemoveRange(seriesToDelete);
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
var episodesAdded = 0;
@@ -431,7 +434,7 @@ private async Task SyncSonarrAsync(string instanceName, ArrInstanceConfig cfg, C
seriesEntity.Title, ep.SeasonNumber, ep.EpisodeNumber);
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
_logger.LogDebug("[{Instance}] ArrSyncService: Sonarr {Name} synced {SeriesCount} series - Series Added: {SeriesAdded}, Updated: {SeriesUpdated}, Deleted: {SeriesDeleted}, Episodes Added: {EpisodesAdded}",
@@ -478,7 +481,7 @@ private async Task SyncSonarrQueueAsync(string instanceName, ArrInstanceConfig c
if (toDelete.Count > 0)
_db.EpisodeQueue.RemoveRange(toDelete);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("ArrSyncService: Sonarr {Name} synced {Count} queue items", instanceName, queueItems.Count);
// §1.7: Scan for ArrErrorCodesToBlocklist matches
@@ -637,7 +640,7 @@ public async Task MarkRequestsAsync(string instanceName, CancellationToken ct =
.ToListAsync(ct);
foreach (var movie in allMovies)
movie.IsRequest = requestTmdbIds.Contains(movie.TmdbId);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("ArrSyncService: marked {Count} movies as requests for {Name}",
requestTmdbIds.Count, instanceName);
}
@@ -652,7 +655,7 @@ public async Task MarkRequestsAsync(string instanceName, CancellationToken ct =
.ToListAsync(ct);
foreach (var ep in allEps)
ep.IsRequest = requestSeriesIds.Contains(ep.SeriesId);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("ArrSyncService: marked episodes for {Count} requested series for {Name}",
requestSeriesIds.Count, instanceName);
}
@@ -730,7 +733,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C
if (artistsToDelete.Count > 0 && !ShouldSkipDestructiveDelete(artists.Count, dbArtists.Count, instanceName, "artists"))
_db.Artists.RemoveRange(artistsToDelete);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
// Fetch all albums at once
List albums;
@@ -818,7 +821,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C
}
// Save so EF Core assigns EntryId to new album rows
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
// Compute search metadata for each album using bulk track files (one API call per album)
foreach (var (lidarrAlbumId, albumEntity) in albumEntityByLidarrId)
@@ -865,7 +868,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C
albumEntity.Reason = DetermineReasonWithAvailability(albumEntity.HasFile, albumEntity.QualityMet, albumEntity.CustomFormatMet, isAvailable, searchConfig);
albumEntity.Searched = DetermineSearched(albumEntity.HasFile, albumEntity.QualityMet, albumEntity.CustomFormatMet, searchConfig);
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
// When albums API returned empty (delete guarded) but DB still has rows, resolve track FKs from DB.
foreach (var album in dbAlbums.Values)
@@ -919,7 +922,7 @@ private async Task SyncLidarrAsync(string instanceName, ArrInstanceConfig cfg, C
_logger.LogTrace("DB Insert: Track {Title} added to database (new)", track.Title);
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("[{Instance}] ArrSyncService: Lidarr {Name} synced - Artists: Added: {ArtistsAdded}, Updated: {ArtistsUpdated}, Deleted: {ArtistsDeleted} | Albums: Added: {AlbumsAdded}, Updated: {AlbumsUpdated}, Deleted: {AlbumsDeleted} | Tracks Added: {TracksAdded}",
instanceName, instanceName, artistsAdded, artistsUpdated, artistsToDelete.Count, albumsAdded, albumsUpdated, albumsToDelete.Count, tracksAdded);
_logger.LogTrace("Finished updating database for Lidarr instance {Name}", instanceName);
@@ -964,7 +967,7 @@ private async Task SyncLidarrQueueAsync(string instanceName, ArrInstanceConfig c
if (toDelete.Count > 0)
_db.AlbumQueue.RemoveRange(toDelete);
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
_logger.LogDebug("ArrSyncService: Lidarr {Name} synced {Count} queue items", instanceName, queueItems.Count);
// §1.7: Scan for ArrErrorCodesToBlocklist matches
diff --git a/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs
index 924d1f27..4e0ee79e 100644
--- a/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs
+++ b/src/Torrentarr.Infrastructure/Services/ArrWorkerManager.cs
@@ -256,6 +256,20 @@ private async Task RunWorkerCoreAsync(string instanceName, ArrInstanceConfig arr
}
}
+ // Ensure qBit category exists and tracker tags are pre-created
+ try
+ {
+ using var initScope = _scopeFactory.CreateScope();
+ var ensure = initScope.ServiceProvider.GetRequiredService();
+ await ensure.EnsureCategoryOnAllInstancesAsync(arrCfg.Category, ct);
+ if (initScope.ServiceProvider.GetRequiredService() is SeedingService seedingConcrete)
+ await seedingConcrete.EnsureAllTrackerTagsExistAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Category/tag initialization failed for {Instance}", instanceName);
+ }
+
// §2.5: Consecutive error counter for exponential backoff
int consecutiveErrors = 0;
@@ -291,6 +305,7 @@ private async Task RunWorkerCoreAsync(string instanceName, ArrInstanceConfig arr
// (sync then search in the same iteration, gated by SearchRequestsEvery) — no
// RestartLoopException path exists here; search always follows sync in-order.
_stateManager.Update(searchStateName, s => s.Status = "Syncing database...");
+ _stateManager.Update(searchStateName, s => s.SearchSummary = "Updating database");
await RunSyncAsync(instanceName, ct);
// §2.6: RSS Sync + Refresh Monitored Downloads (timer-gated)
@@ -474,6 +489,14 @@ private async Task RunTorrentProcessingAsync(string instanceName, ArrInstanceCon
using var scope = _scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService();
await processor.ProcessTorrentsAsync(arrCfg.Category, ct);
+
+ var pathTracker = scope.ServiceProvider.GetRequiredService();
+ var completedRoot = _config.Settings.CompletedDownloadFolder;
+ if (!string.IsNullOrWhiteSpace(completedRoot))
+ {
+ pathTracker.RemoveEmptyPathsUnder(completedRoot);
+ pathTracker.ClearIfFolderEmpty(completedRoot);
+ }
}
catch (Exception ex)
{
diff --git a/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs b/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs
index ac29e6f4..b8a353b5 100644
--- a/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs
+++ b/src/Torrentarr.Infrastructure/Services/DatabaseHealthService.cs
@@ -160,35 +160,62 @@ public async Task VacuumAsync(CancellationToken cancellationToken = defaul
public async Task RepairAsync(CancellationToken cancellationToken = default)
{
- _logger.LogWarning("Attempting database repair via dump/restore...");
+ _logger.LogWarning("Attempting database repair via backup/restore...");
var backupPath = $"{_dbPath}.backup";
var tempPath = $"{_dbPath}.temp";
try
{
+ await _dbContext.Database.CloseConnectionAsync();
+
if (File.Exists(_dbPath))
{
_logger.LogInformation("Creating backup: {BackupPath}", backupPath);
File.Copy(_dbPath, backupPath, overwrite: true);
}
- _logger.LogInformation("Dumping recoverable data from database...");
+ foreach (var suffix in new[] { "-wal", "-shm" })
+ {
+ var sidecar = _dbPath + suffix;
+ if (File.Exists(sidecar))
+ {
+ try { File.Delete(sidecar); } catch { /* best-effort */ }
+ }
+ }
- var sourceConn = _dbContext.Database.GetDbConnection();
- await sourceConn.OpenAsync(cancellationToken);
+ if (!File.Exists(_dbPath))
+ {
+ _logger.LogError("Database file not found: {Path}", _dbPath);
+ return false;
+ }
- var tempConn = new SqliteConnection($"Data Source={tempPath}");
- await tempConn.OpenAsync(cancellationToken);
+ await using (var source = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly"))
+ {
+ await source.OpenAsync(cancellationToken);
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
- var dumpCommand = sourceConn.CreateCommand();
- dumpCommand.CommandText = ".dump";
+ await using var dest = new SqliteConnection($"Data Source={tempPath}");
+ await dest.OpenAsync(cancellationToken);
+ source.BackupDatabase(dest);
+ }
- await using var tempCmd = tempConn.CreateCommand();
- tempCmd.CommandText = dumpCommand.CommandText;
+ await using (var verify = new SqliteConnection($"Data Source={tempPath}"))
+ {
+ await verify.OpenAsync(cancellationToken);
+ await using var cmd = verify.CreateCommand();
+ cmd.CommandText = "PRAGMA integrity_check";
+ var check = (await cmd.ExecuteScalarAsync(cancellationToken))?.ToString();
+ if (check != "ok")
+ {
+ _logger.LogError("Repaired database failed integrity check: {Result}", check);
+ return false;
+ }
+ }
- await tempConn.CloseAsync();
- await sourceConn.CloseAsync();
+ File.Delete(_dbPath);
+ File.Move(tempPath, _dbPath);
_logger.LogInformation("Database repair completed successfully");
return true;
diff --git a/src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs b/src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs
new file mode 100644
index 00000000..39cd2f85
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Services/DatabaseRestartCoordinator.cs
@@ -0,0 +1,44 @@
+namespace Torrentarr.Infrastructure.Services;
+
+///
+/// qBitrr database_restart_event parity: signal coordinated worker restart after persistent DB errors.
+///
+public class DatabaseRestartCoordinator
+{
+ private readonly object _lock = new();
+ private int _errorCount;
+ private DateTime _firstErrorTime;
+ private DateTime _lastErrorTime;
+ private volatile bool _restartRequested;
+
+ public bool RestartRequested => _restartRequested;
+
+ public void RecordDatabaseError()
+ {
+ lock (_lock)
+ {
+ var now = DateTime.UtcNow;
+ if (now - _lastErrorTime > TimeSpan.FromMinutes(5))
+ {
+ _errorCount = 0;
+ _firstErrorTime = now;
+ }
+
+ _errorCount++;
+ _lastErrorTime = now;
+
+ if (now - _firstErrorTime > TimeSpan.FromMinutes(5))
+ _restartRequested = true;
+ }
+ }
+
+ public void RecordDatabaseSuccess()
+ {
+ lock (_lock)
+ {
+ _errorCount = 0;
+ }
+ }
+
+ public void ClearRestartRequest() => _restartRequested = false;
+}
diff --git a/src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs b/src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs
new file mode 100644
index 00000000..28698a65
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Services/DatabaseRestartWatchdogService.cs
@@ -0,0 +1,59 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Torrentarr.Infrastructure.Services;
+
+///
+/// Restarts all workers when requests coordinated recovery.
+///
+public class DatabaseRestartWatchdogService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly DatabaseRestartCoordinator _coordinator;
+ private readonly ArrWorkerManager _arrWorkers;
+ private readonly QBitCategoryWorkerManager _qbitCategoryWorkers;
+
+ public DatabaseRestartWatchdogService(
+ ILogger logger,
+ DatabaseRestartCoordinator coordinator,
+ ArrWorkerManager arrWorkers,
+ QBitCategoryWorkerManager qbitCategoryWorkers)
+ {
+ _logger = logger;
+ _coordinator = coordinator;
+ _arrWorkers = arrWorkers;
+ _qbitCategoryWorkers = qbitCategoryWorkers;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ if (_coordinator.RestartRequested)
+ {
+ _logger.LogCritical(
+ "Database restart signal detected — restarting all workers for coordinated recovery");
+ _coordinator.ClearRestartRequest();
+
+ try
+ {
+ await _arrWorkers.RestartAllWorkersAsync();
+ await _qbitCategoryWorkers.RestartAllCategoriesAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Coordinated database restart failed");
+ }
+ }
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs b/src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs
new file mode 100644
index 00000000..e6a478d3
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Services/ImportPathTracker.cs
@@ -0,0 +1,83 @@
+using System.Collections.Concurrent;
+using Torrentarr.Core.Services;
+
+namespace Torrentarr.Infrastructure.Services;
+
+///
+/// In-memory sent_to_scan / sent_to_scan_hashes tracking (qBitrr arss.py parity).
+///
+public class ImportPathTracker : IImportPathTracker
+{
+ private readonly ConcurrentDictionary _scannedPaths =
+ new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _scannedHashes =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ public bool IsPathAlreadyScanned(string normalizedPath) =>
+ !string.IsNullOrEmpty(normalizedPath) && _scannedPaths.ContainsKey(normalizedPath);
+
+ public bool IsHashAlreadyScanned(string hash) =>
+ !string.IsNullOrEmpty(hash) && _scannedHashes.ContainsKey(hash.ToUpperInvariant());
+
+ public void MarkScanned(string normalizedPath, string hash)
+ {
+ if (!string.IsNullOrEmpty(normalizedPath))
+ _scannedPaths[normalizedPath] = 0;
+ if (!string.IsNullOrEmpty(hash))
+ _scannedHashes[hash.ToUpperInvariant()] = 0;
+ }
+
+ public void RemoveEmptyPathsUnder(string completedFolderRoot)
+ {
+ if (string.IsNullOrWhiteSpace(completedFolderRoot) || !Directory.Exists(completedFolderRoot))
+ return;
+
+ var newSent = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var path in Directory.EnumerateDirectories(completedFolderRoot, "*", SearchOption.AllDirectories)
+ .OrderByDescending(p => p.Length))
+ {
+ try
+ {
+ if (!Directory.Exists(path))
+ continue;
+ if (Directory.EnumerateFileSystemEntries(path).Any())
+ continue;
+ Directory.Delete(path);
+ if (_scannedPaths.ContainsKey(path))
+ _scannedPaths.TryRemove(path, out _);
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+
+ foreach (var p in _scannedPaths.Keys)
+ {
+ if (Directory.Exists(p))
+ newSent[p] = 0;
+ }
+ _scannedPaths.Clear();
+ foreach (var kv in newSent)
+ _scannedPaths[kv.Key] = kv.Value;
+ }
+
+ public void ClearIfFolderEmpty(string completedFolderRoot)
+ {
+ if (string.IsNullOrWhiteSpace(completedFolderRoot) || !Directory.Exists(completedFolderRoot))
+ return;
+
+ try
+ {
+ if (!Directory.EnumerateFileSystemEntries(completedFolderRoot).Any())
+ {
+ _scannedPaths.Clear();
+ _scannedHashes.Clear();
+ }
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+}
diff --git a/src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs b/src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs
new file mode 100644
index 00000000..35d8fdbf
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Services/PeriodicWalCheckpointService.cs
@@ -0,0 +1,48 @@
+using Torrentarr.Core.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Torrentarr.Infrastructure.Services;
+
+///
+/// Periodic WAL checkpoint (qBitrr main.py 5-minute interval parity).
+///
+public class PeriodicWalCheckpointService : BackgroundService
+{
+ private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
+ private readonly ILogger _logger;
+ private readonly IServiceScopeFactory _scopeFactory;
+
+ public PeriodicWalCheckpointService(
+ ILogger logger,
+ IServiceScopeFactory scopeFactory)
+ {
+ _logger = logger;
+ _scopeFactory = scopeFactory;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Starting periodic WAL checkpoint service (interval: {Minutes} minutes)", Interval.TotalMinutes);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(Interval, stoppingToken);
+ using var scope = _scopeFactory.CreateScope();
+ var dbHealth = scope.ServiceProvider.GetRequiredService();
+ await dbHealth.CheckpointWalAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Periodic WAL checkpoint failed");
+ }
+ }
+ }
+}
diff --git a/src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs b/src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs
new file mode 100644
index 00000000..731442db
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Services/QBitCategoryEnsureService.cs
@@ -0,0 +1,95 @@
+using Torrentarr.Core.Configuration;
+using Torrentarr.Infrastructure.ApiClients.QBittorrent;
+using Microsoft.Extensions.Logging;
+
+namespace Torrentarr.Infrastructure.Services;
+
+///
+/// Ensures Arr/qBit categories exist on all instances (qBitrr _ensure_category_on_all_instances).
+///
+public class QBitCategoryEnsureService
+{
+ private readonly ILogger _logger;
+ private readonly TorrentarrConfig _config;
+ private readonly QBittorrentConnectionManager _qbitManager;
+
+ public QBitCategoryEnsureService(
+ ILogger logger,
+ TorrentarrConfig config,
+ QBittorrentConnectionManager qbitManager)
+ {
+ _logger = logger;
+ _config = config;
+ _qbitManager = qbitManager;
+ }
+
+ public async Task EnsureCategoryOnAllInstancesAsync(string category, CancellationToken ct = default)
+ {
+ var leaf = CategoryPathHelper.NormalizeCategory(category);
+ if (string.IsNullOrEmpty(leaf))
+ return;
+
+ var prefixPaths = CategoryPathHelper.CategoryParents(leaf);
+ if (prefixPaths.Count == 0)
+ prefixPaths = new[] { leaf }.ToList();
+ else if (!prefixPaths.Contains(leaf, StringComparer.Ordinal))
+ prefixPaths = prefixPaths.Append(leaf).ToList();
+
+ var completedRoot = ResolveCompletedRoot();
+
+ foreach (var (instanceName, client) in _qbitManager.GetAllClients())
+ {
+ try
+ {
+ var categories = await client.GetCategoriesAsync(ct);
+ foreach (var parent in prefixPaths)
+ {
+ if (categories.ContainsKey(parent))
+ continue;
+
+ var parentsOfParent = CategoryPathHelper.CategoryParents(parent);
+ string savePath;
+ if (parentsOfParent.Count > 0
+ && categories.TryGetValue(parentsOfParent[^1], out var parentInfo)
+ && !string.IsNullOrEmpty(parentInfo.SavePath))
+ {
+ savePath = Path.Combine(parentInfo.SavePath, parent.Split('/').Last());
+ }
+ else
+ {
+ savePath = Path.Combine(completedRoot, parent.Replace('/', Path.DirectorySeparatorChar));
+ }
+
+ var created = await client.CreateCategoryAsync(parent, savePath, ct);
+ if (created)
+ {
+ _logger.LogInformation(
+ "Created category '{Category}' on instance '{Instance}' (save_path={Path})",
+ parent, instanceName, savePath);
+ categories[parent] = new CategoryInfo { Name = parent, SavePath = savePath };
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed ensuring category '{Category}' on instance '{Instance}'", leaf, instanceName);
+ }
+ }
+ }
+
+ private string ResolveCompletedRoot()
+ {
+ var folder = _config.Settings.CompletedDownloadFolder;
+ if (!string.IsNullOrWhiteSpace(folder) && folder != "CHANGE_ME" && Directory.Exists(folder))
+ return folder;
+
+ foreach (var (_, qbit) in _config.QBitInstances)
+ {
+ if (!string.IsNullOrWhiteSpace(qbit.DownloadPath) && qbit.DownloadPath != "CHANGE_ME"
+ && Directory.Exists(qbit.DownloadPath))
+ return qbit.DownloadPath;
+ }
+
+ return folder is { Length: > 0 } ? folder : "/downloads";
+ }
+}
diff --git a/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs
new file mode 100644
index 00000000..f46140a0
--- /dev/null
+++ b/src/Torrentarr.Infrastructure/Services/QBitCategoryWorkerManager.cs
@@ -0,0 +1,242 @@
+using System.Collections.Concurrent;
+using Torrentarr.Core.Configuration;
+using Torrentarr.Core.Services;
+using Torrentarr.Infrastructure.ApiClients.QBittorrent;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Torrentarr.Infrastructure.Services;
+
+///
+/// Processes torrents in qBit ManagedCategories not owned by an Arr instance (qBitrr PlaceHolderArr parity).
+///
+public class QBitCategoryWorkerManager : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly TorrentarrConfig _config;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ProcessStateManager _stateManager;
+ private readonly IConnectivityService _connectivityService;
+
+ private readonly ConcurrentDictionary _workers =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ private CancellationToken _appStopping = CancellationToken.None;
+
+ public QBitCategoryWorkerManager(
+ ILogger logger,
+ TorrentarrConfig config,
+ IServiceScopeFactory scopeFactory,
+ ProcessStateManager stateManager,
+ IConnectivityService connectivityService)
+ {
+ _logger = logger;
+ _config = config;
+ _scopeFactory = scopeFactory;
+ _stateManager = stateManager;
+ _connectivityService = connectivityService;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _appStopping = stoppingToken;
+
+ foreach (var category in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config))
+ {
+ var stateName = $"qbit-{category}";
+ _stateManager.Initialize(stateName, new ArrProcessState
+ {
+ Name = stateName,
+ Category = category,
+ Kind = "category",
+ MetricType = "category",
+ Alive = false
+ });
+ StartCategoryWorker(category, stoppingToken);
+ }
+
+ if (_workers.IsEmpty)
+ {
+ _logger.LogDebug("No qBit-only managed categories to process");
+ try { await Task.Delay(Timeout.Infinite, stoppingToken); }
+ catch (OperationCanceledException) { }
+ return;
+ }
+
+ _logger.LogInformation("Started {Count} qBit-only category worker(s)", _workers.Count);
+ try { await Task.Delay(Timeout.Infinite, stoppingToken); }
+ catch (OperationCanceledException) { }
+
+ await StopAllCategoriesAsync();
+ }
+
+ public async Task RestartCategoryAsync(string category)
+ {
+ if (!_workers.ContainsKey(category))
+ {
+ StartCategoryWorker(category, _appStopping);
+ return;
+ }
+
+ _logger.LogInformation("Restarting qBit category worker for {Category}", category);
+ var stateName = $"qbit-{category}";
+ _stateManager.Update(stateName, s => { s.Alive = false; s.Rebuilding = true; });
+
+ if (_workers.TryRemove(category, out var old))
+ {
+ old.Cts.Cancel();
+ try { await old.Task.WaitAsync(TimeSpan.FromSeconds(10)); }
+ catch (OperationCanceledException) { }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning("qBit category worker {Category} did not stop within 10s", category);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "qBit category worker {Category} faulted during shutdown", category);
+ }
+ old.Cts.Dispose();
+ }
+
+ StartCategoryWorker(category, _appStopping);
+ }
+
+ public async Task RestartAllCategoriesAsync()
+ {
+ foreach (var category in _workers.Keys.ToList())
+ await RestartCategoryAsync(category);
+
+ foreach (var category in CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config))
+ {
+ if (!_workers.ContainsKey(category))
+ StartCategoryWorker(category, _appStopping);
+ }
+ }
+
+ public async Task SyncWorkersWithConfigAsync()
+ {
+ var desired = CategoryOwnershipHelper.GetQBitOnlyManagedCategories(_config).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var category in desired)
+ {
+ var stateName = $"qbit-{category}";
+ if (_stateManager.GetState(stateName) == null)
+ {
+ _stateManager.Initialize(stateName, new ArrProcessState
+ {
+ Name = stateName,
+ Category = category,
+ Kind = "category",
+ MetricType = "category",
+ Alive = false
+ });
+ }
+
+ if (!_workers.ContainsKey(category))
+ StartCategoryWorker(category, _appStopping);
+ }
+
+ foreach (var category in _workers.Keys.ToList())
+ {
+ if (!desired.Contains(category))
+ await StopCategoryAsync(category);
+ }
+ }
+
+ private void StartCategoryWorker(string category, CancellationToken appStopping)
+ {
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(appStopping);
+ var task = Task.Run(() => RunCategoryLoopAsync(category, $"qbit-{category}", cts.Token), CancellationToken.None);
+ _workers[category] = (task, cts);
+ }
+
+ private async Task StopCategoryAsync(string category)
+ {
+ if (!_workers.TryRemove(category, out var worker))
+ return;
+
+ worker.Cts.Cancel();
+ try { await worker.Task.WaitAsync(TimeSpan.FromSeconds(10)); }
+ catch (OperationCanceledException) { }
+ catch (TimeoutException) { }
+ catch (Exception ex) { _logger.LogDebug(ex, "qBit category worker {Category} stop error", category); }
+ worker.Cts.Dispose();
+
+ var stateName = $"qbit-{category}";
+ _stateManager.Update(stateName, s => s.Alive = false);
+ }
+
+ private async Task StopAllCategoriesAsync()
+ {
+ foreach (var category in _workers.Keys.ToList())
+ await StopCategoryAsync(category);
+ }
+
+ private async Task RunCategoryLoopAsync(string category, string stateName, CancellationToken ct)
+ {
+ _stateManager.Update(stateName, s => { s.Alive = true; s.Rebuilding = false; });
+
+ try
+ {
+ using var initScope = _scopeFactory.CreateScope();
+ var ensure = initScope.ServiceProvider.GetRequiredService();
+ await ensure.EnsureCategoryOnAllInstancesAsync(category, ct);
+ await EnsureTrackerTagsAsync(initScope, ct);
+
+ while (!ct.IsCancellationRequested)
+ {
+ var loopStart = DateTime.UtcNow;
+ try
+ {
+ if (!await _connectivityService.IsConnectedAsync(ct))
+ {
+ _stateManager.Update(stateName, s => s.Status = "Waiting for connectivity...");
+ await Task.Delay(TimeSpan.FromSeconds(_config.Settings.NoInternetSleepTimer), ct);
+ continue;
+ }
+
+ _stateManager.Update(stateName, s => s.Status = "Processing torrents...");
+ using var scope = _scopeFactory.CreateScope();
+ var processor = scope.ServiceProvider.GetRequiredService();
+ var seeding = scope.ServiceProvider.GetRequiredService();
+ var pathTracker = scope.ServiceProvider.GetRequiredService();
+
+ await processor.ProcessTorrentsAsync(category, ct);
+ await seeding.RemoveCompletedTorrentsAsync(category, ct);
+
+ var completedRoot = _config.Settings.CompletedDownloadFolder;
+ if (!string.IsNullOrWhiteSpace(completedRoot))
+ {
+ pathTracker.RemoveEmptyPathsUnder(completedRoot);
+ pathTracker.ClearIfFolderEmpty(completedRoot);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in qBit category worker for {Category}", category);
+ }
+
+ var elapsed = DateTime.UtcNow - loopStart;
+ var sleep = TimeSpan.FromSeconds(_config.Settings.LoopSleepTimer) - elapsed;
+ if (sleep > TimeSpan.Zero)
+ await Task.Delay(sleep, ct);
+ }
+ }
+ finally
+ {
+ _stateManager.Update(stateName, s => s.Alive = false);
+ }
+ }
+
+ private async Task EnsureTrackerTagsAsync(IServiceScope scope, CancellationToken ct)
+ {
+ var seeding = scope.ServiceProvider.GetRequiredService();
+ if (seeding is SeedingService concrete)
+ await concrete.EnsureAllTrackerTagsExistAsync(ct);
+ }
+}
diff --git a/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs b/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs
index 4bf92c16..cb5337d5 100644
--- a/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs
+++ b/src/Torrentarr.Infrastructure/Services/QualityProfileSwitcherService.cs
@@ -15,13 +15,16 @@ public class QualityProfileSwitcherService
{
private readonly ILogger _logger;
private readonly TorrentarrDbContext _db;
+ private readonly DatabaseRestartCoordinator _restartCoordinator;
public QualityProfileSwitcherService(
ILogger logger,
- TorrentarrDbContext db)
+ TorrentarrDbContext db,
+ DatabaseRestartCoordinator restartCoordinator)
{
_logger = logger;
_db = db;
+ _restartCoordinator = restartCoordinator;
}
// ── Startup ───────────────────────────────────────────────────────────────
@@ -54,12 +57,12 @@ public async Task ForceResetAllTempProfilesAsync(
var radarr = new RadarrClient(arrConfig.URI, arrConfig.APIKey);
foreach (var movie in movies)
{
- await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, ct);
+ await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, arrConfig, ct);
movie.CurrentProfileId = movie.OriginalProfileId;
movie.OriginalProfileId = null;
movie.LastProfileSwitchTime = null;
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
break;
case "sonarr":
@@ -73,12 +76,12 @@ public async Task ForceResetAllTempProfilesAsync(
var sonarr = new SonarrClient(arrConfig.URI, arrConfig.APIKey);
foreach (var s in series)
{
- await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, ct);
+ await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, arrConfig, ct);
s.CurrentProfileId = s.OriginalProfileId;
s.OriginalProfileId = null;
s.LastProfileSwitchTime = null;
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
break;
case "lidarr":
@@ -92,12 +95,12 @@ public async Task ForceResetAllTempProfilesAsync(
var lidarr = new LidarrClient(arrConfig.URI, arrConfig.APIKey);
foreach (var artist in artists)
{
- await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, ct);
+ await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, arrConfig, ct);
artist.CurrentProfileId = artist.OriginalProfileId;
artist.OriginalProfileId = null;
artist.LastProfileSwitchTime = null;
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
break;
}
}
@@ -142,12 +145,12 @@ public async Task RestoreTimedOutProfilesAsync(
var radarr = new RadarrClient(arrConfig.URI, arrConfig.APIKey);
foreach (var movie in expiredMovies)
{
- await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, ct);
+ await TryRestoreMovieAsync(radarr, movie.ArrId, movie.OriginalProfileId!.Value, instanceName, arrConfig, ct);
movie.CurrentProfileId = movie.OriginalProfileId;
movie.OriginalProfileId = null;
movie.LastProfileSwitchTime = null;
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
break;
case "sonarr":
@@ -164,12 +167,12 @@ public async Task RestoreTimedOutProfilesAsync(
var sonarr = new SonarrClient(arrConfig.URI, arrConfig.APIKey);
foreach (var s in expiredSeries)
{
- await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, ct);
+ await TryRestoreSeriesAsync(sonarr, s.ArrId, s.OriginalProfileId!.Value, instanceName, arrConfig, ct);
s.CurrentProfileId = s.OriginalProfileId;
s.OriginalProfileId = null;
s.LastProfileSwitchTime = null;
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
break;
case "lidarr":
@@ -186,12 +189,12 @@ public async Task RestoreTimedOutProfilesAsync(
var lidarr = new LidarrClient(arrConfig.URI, arrConfig.APIKey);
foreach (var artist in expiredArtists)
{
- await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, ct);
+ await TryRestoreArtistAsync(lidarr, artist.ArrId, artist.OriginalProfileId!.Value, instanceName, arrConfig, ct);
artist.CurrentProfileId = artist.OriginalProfileId;
artist.OriginalProfileId = null;
artist.LastProfileSwitchTime = null;
}
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
break;
}
}
@@ -276,7 +279,11 @@ private async Task SwitchMovieProfilesAsync(
continue;
}
- var switched = await radarr.UpdateMovieQualityProfileAsync(movie.ArrId, tempProfileId, ct);
+ var switched = await WithProfileSwitchRetryAsync(
+ arrConfig,
+ () => radarr.UpdateMovieQualityProfileAsync(movie.ArrId, tempProfileId, ct),
+ "movie",
+ ct);
if (switched)
{
_logger.LogInformation("§1.2: Switched movie '{Title}' profile: {From} → {To}", movie.Title, currentProfileName, tempProfileName);
@@ -288,7 +295,7 @@ private async Task SwitchMovieProfilesAsync(
}
if (changed)
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
private async Task SwitchSeriesProfilesAsync(
@@ -333,7 +340,11 @@ private async Task SwitchSeriesProfilesAsync(
continue;
}
- var switched = await sonarr.UpdateSeriesQualityProfileAsync(series.ArrId, tempProfileId, ct);
+ var switched = await WithProfileSwitchRetryAsync(
+ arrConfig,
+ () => sonarr.UpdateSeriesQualityProfileAsync(series.ArrId, tempProfileId, ct),
+ "series",
+ ct);
if (switched)
{
_logger.LogInformation("§1.2: Switched series '{Title}' profile: {From} → {To}",
@@ -346,7 +357,7 @@ private async Task SwitchSeriesProfilesAsync(
}
if (changed)
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
private async Task SwitchArtistProfilesAsync(
@@ -391,7 +402,11 @@ private async Task SwitchArtistProfilesAsync(
continue;
}
- var switched = await lidarr.UpdateArtistQualityProfileAsync(artist.ArrId, tempProfileId, ct);
+ var switched = await WithProfileSwitchRetryAsync(
+ arrConfig,
+ () => lidarr.UpdateArtistQualityProfileAsync(artist.ArrId, tempProfileId, ct),
+ "artist",
+ ct);
if (switched)
{
_logger.LogInformation("§1.2: Switched artist '{Name}' profile: {From} → {To}",
@@ -404,47 +419,77 @@ private async Task SwitchArtistProfilesAsync(
}
if (changed)
- await _db.SaveChangesAsync(ct);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
// ── Restore helpers ───────────────────────────────────────────────────────
- private async Task TryRestoreMovieAsync(RadarrClient radarr, int arrId, int originalProfileId, string instanceName, CancellationToken ct)
+ private async Task TryRestoreMovieAsync(RadarrClient radarr, int arrId, int originalProfileId, string instanceName, ArrInstanceConfig arrConfig, CancellationToken ct)
{
- try
- {
- await radarr.UpdateMovieQualityProfileAsync(arrId, originalProfileId, ct);
- _logger.LogInformation("§1.2: Restored movie {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "§1.2: Failed to restore movie {ArrId} for {Instance}", arrId, instanceName);
- }
+ await WithProfileSwitchRetryAsync(
+ arrConfig,
+ async () =>
+ {
+ await radarr.UpdateMovieQualityProfileAsync(arrId, originalProfileId, ct);
+ _logger.LogInformation("§1.2: Restored movie {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName);
+ return true;
+ },
+ "movie-restore",
+ ct);
}
- private async Task TryRestoreSeriesAsync(SonarrClient sonarr, int arrId, int originalProfileId, string instanceName, CancellationToken ct)
+ private async Task TryRestoreSeriesAsync(SonarrClient sonarr, int arrId, int originalProfileId, string instanceName, ArrInstanceConfig arrConfig, CancellationToken ct)
{
- try
- {
- await sonarr.UpdateSeriesQualityProfileAsync(arrId, originalProfileId, ct);
- _logger.LogInformation("§1.2: Restored series {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "§1.2: Failed to restore series {ArrId} for {Instance}", arrId, instanceName);
- }
+ await WithProfileSwitchRetryAsync(
+ arrConfig,
+ async () =>
+ {
+ await sonarr.UpdateSeriesQualityProfileAsync(arrId, originalProfileId, ct);
+ _logger.LogInformation("§1.2: Restored series {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName);
+ return true;
+ },
+ "series-restore",
+ ct);
}
- private async Task TryRestoreArtistAsync(LidarrClient lidarr, int arrId, int originalProfileId, string instanceName, CancellationToken ct)
+ private async Task TryRestoreArtistAsync(LidarrClient lidarr, int arrId, int originalProfileId, string instanceName, ArrInstanceConfig arrConfig, CancellationToken ct)
{
- try
- {
- await lidarr.UpdateArtistQualityProfileAsync(arrId, originalProfileId, ct);
- _logger.LogInformation("§1.2: Restored artist {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName);
- }
- catch (Exception ex)
+ await WithProfileSwitchRetryAsync(
+ arrConfig,
+ async () =>
+ {
+ await lidarr.UpdateArtistQualityProfileAsync(arrId, originalProfileId, ct);
+ _logger.LogInformation("§1.2: Restored artist {ArrId} → profileId={ProfileId} for {Instance}", arrId, originalProfileId, instanceName);
+ return true;
+ },
+ "artist-restore",
+ ct);
+ }
+
+ private async Task WithProfileSwitchRetryAsync(
+ ArrInstanceConfig arrConfig,
+ Func> action,
+ string kind,
+ CancellationToken ct)
+ {
+ var attempts = Math.Max(1, arrConfig.Search.ProfileSwitchRetryAttempts);
+ for (var attempt = 0; attempt < attempts; attempt++)
{
- _logger.LogWarning(ex, "§1.2: Failed to restore artist {ArrId} for {Instance}", arrId, instanceName);
+ try
+ {
+ return await action();
+ }
+ catch (Exception ex) when (attempt < attempts - 1)
+ {
+ _logger.LogWarning(ex, "Profile switch retry {Attempt}/{Max} for {Kind}", attempt + 1, attempts, kind);
+ await Task.Delay(TimeSpan.FromSeconds(1), ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to update {Kind} profile after {Attempts} attempts", kind, attempts);
+ return false;
+ }
}
+ return false;
}
}
diff --git a/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs b/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs
index 02013e0a..d69f8991 100644
--- a/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs
+++ b/src/Torrentarr.Infrastructure/Services/SearchExecutor.cs
@@ -14,6 +14,7 @@ public class SearchExecutor : ISearchExecutor
private readonly TorrentarrConfig _config;
private readonly TorrentarrDbContext _db;
private readonly QualityProfileSwitcherService _profileSwitcher;
+ private readonly DatabaseRestartCoordinator _restartCoordinator;
// Cached per-instance Arr clients — created once, reused across calls
private readonly Dictionary _clientCache = new(StringComparer.OrdinalIgnoreCase);
@@ -34,12 +35,14 @@ public SearchExecutor(
ILogger logger,
TorrentarrConfig config,
TorrentarrDbContext db,
- QualityProfileSwitcherService profileSwitcher)
+ QualityProfileSwitcherService profileSwitcher,
+ DatabaseRestartCoordinator restartCoordinator)
{
_logger = logger;
_config = config;
_db = db;
_profileSwitcher = profileSwitcher;
+ _restartCoordinator = restartCoordinator;
}
public async Task ExecuteSearchesAsync(
@@ -302,7 +305,7 @@ private async Task MarkAsSearchedAsync(
if (movie != null)
{
movie.Searched = true;
- await _db.SaveChangesAsync(cancellationToken);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken);
}
break;
@@ -312,7 +315,7 @@ private async Task MarkAsSearchedAsync(
if (episode != null)
{
episode.Searched = true;
- await _db.SaveChangesAsync(cancellationToken);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken);
}
break;
@@ -322,7 +325,7 @@ private async Task MarkAsSearchedAsync(
if (album != null)
{
album.Searched = true;
- await _db.SaveChangesAsync(cancellationToken);
+ await _db.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken);
}
break;
}
diff --git a/src/Torrentarr.Infrastructure/Services/SeedingService.cs b/src/Torrentarr.Infrastructure/Services/SeedingService.cs
index 16628a4b..47651c45 100644
--- a/src/Torrentarr.Infrastructure/Services/SeedingService.cs
+++ b/src/Torrentarr.Infrastructure/Services/SeedingService.cs
@@ -930,6 +930,47 @@ private async Task EnsureTagsExistAsync(QBittorrentClient client, CancellationTo
}
}
+ /// Pre-create all configured tracker AddTags on every qBit instance (qBitrr qbit_category_manager parity).
+ public async Task EnsureAllTrackerTagsExistAsync(CancellationToken cancellationToken = default)
+ {
+ var allTags = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var q in _config.QBitInstances.Values)
+ {
+ foreach (var t in q.Trackers)
+ foreach (var tag in t.AddTags)
+ if (!string.IsNullOrWhiteSpace(tag))
+ allTags.Add(tag.Trim());
+ }
+ foreach (var a in _config.ArrInstances.Values)
+ {
+ foreach (var t in a.Torrent.Trackers)
+ foreach (var tag in t.AddTags)
+ if (!string.IsNullOrWhiteSpace(tag))
+ allTags.Add(tag.Trim());
+ }
+
+ if (allTags.Count == 0)
+ return;
+
+ foreach (var (_, client) in _qbitManager.GetAllClients())
+ {
+ try
+ {
+ var existing = await client.GetTagsAsync(cancellationToken);
+ var toCreate = allTags.Where(t => !existing.Contains(t, StringComparer.OrdinalIgnoreCase)).ToList();
+ if (toCreate.Count > 0)
+ {
+ await client.CreateTagsAsync(toCreate, cancellationToken);
+ _logger.LogDebug("Pre-created tracker tags on qBit: {Tags}", string.Join(", ", toCreate));
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to pre-create tracker tags");
+ }
+ }
+ }
+
///
/// Public entry point for tracker actions, callable from TorrentProcessor pre-step.
/// In qBitrr this runs for EVERY torrent BEFORE the state machine.
diff --git a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
index bad25b02..ad9f55fa 100644
--- a/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
+++ b/src/Torrentarr.Infrastructure/Services/TorrentProcessor.cs
@@ -29,6 +29,8 @@ public class TorrentProcessor : ITorrentProcessor
private readonly ITorrentCacheService _cache;
private readonly IArrImportService? _importService;
private readonly ISeedingService? _seedingService;
+ private readonly IImportPathTracker? _pathTracker;
+ private readonly DatabaseRestartCoordinator _restartCoordinator;
private readonly HashSet _specialCategories;
@@ -38,16 +40,20 @@ public TorrentProcessor(
TorrentarrDbContext dbContext,
TorrentarrConfig config,
ITorrentCacheService cache,
+ DatabaseRestartCoordinator restartCoordinator,
IArrImportService? importService = null,
- ISeedingService? seedingService = null)
+ ISeedingService? seedingService = null,
+ IImportPathTracker? pathTracker = null)
{
_logger = logger;
_qbitManager = qbitManager;
_dbContext = dbContext;
_config = config;
_cache = cache;
+ _restartCoordinator = restartCoordinator;
_importService = importService;
_seedingService = seedingService;
+ _pathTracker = pathTracker;
_specialCategories = new HashSet(StringComparer.OrdinalIgnoreCase)
{
@@ -83,15 +89,18 @@ public async Task ProcessTorrentsAsync(string category, CancellationToken cancel
await EnsureTagsExistAsync(c, cancellationToken);
}
- // Get all torrents for this category from ALL qBit instances, stamping instance name
- var torrents = new List();
- foreach (var (instanceName, c) in allClients)
- {
- var instanceTorrents = await c.GetTorrentsAsync(category, cancellationToken: cancellationToken);
- foreach (var t in instanceTorrents)
- t.QBitInstanceName = instanceName;
- torrents.AddRange(instanceTorrents);
- }
+ // Gather torrents (MatchSubcategories-aware — qBitrr _get_torrents_from_all_instances)
+ var fetchAll = allClients.ToDictionary(
+ kv => kv.Key,
+ kv => (Func>>)(ct =>
+ kv.Value.GetTorrentsAsync(cancellationToken: ct)));
+ var fetchByCategory = allClients.ToDictionary(
+ kv => kv.Key,
+ kv => (Func>>)(async (cat, ct) =>
+ await kv.Value.GetTorrentsAsync(cat, cancellationToken: ct)));
+
+ var torrents = await CategoryOwnershipHelper.GatherTorrentsForOwnerAsync(
+ _config, category, fetchAll, fetchByCategory, cancellationToken);
_logger.LogDebug("Found {Count} torrents in category {Category}", torrents.Count, category);
var stats = new TorrentProcessingStats
@@ -116,6 +125,7 @@ public async Task ProcessTorrentsAsync(string category, CancellationToken cancel
if (_seedingService != null)
{
await _seedingService.UpdateSeedingTagsAsync(category, cancellationToken);
+ await _seedingService.RemoveCompletedTorrentsAsync(category, cancellationToken);
}
_logger.LogInformation(
@@ -236,16 +246,40 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C
if (_importService != null)
{
+ var contentPath = torrent.ContentPath;
+ if (!string.IsNullOrEmpty(contentPath))
+ {
+ if (_pathTracker?.IsHashAlreadyScanned(torrent.Hash) == true)
+ {
+ _logger.LogTrace("Skipping import — hash already sent to scan: {Hash}", hash);
+ return;
+ }
+ if (!File.Exists(contentPath))
+ {
+ _logger.LogWarning("Missing torrent file for import: {Path} ({Hash})", contentPath, hash);
+ _cache.AddToIgnoreCache(hash, TimeSpan.FromSeconds(_config.Settings.IgnoreTorrentsYoungerThan));
+ return;
+ }
+ if (_pathTracker?.IsPathAlreadyScanned(contentPath) == true)
+ {
+ _logger.LogTrace("Skipping import — path already sent to scan: {Path}", contentPath);
+ return;
+ }
+ }
+
var result = await _importService.TriggerImportAsync(
hash, torrent.ContentPath, libraryEntry.Category, cancellationToken);
if (result.Success)
{
libraryEntry.Imported = true;
- await _dbContext.SaveChangesAsync(cancellationToken);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken);
_logger.LogInformation("Successfully triggered import for torrent {Hash}: {Message}",
hash, result.Message);
+ if (!string.IsNullOrEmpty(contentPath))
+ _pathTracker?.MarkScanned(contentPath, hash);
+
// AutoDelete: remove torrent from qBittorrent after successful import
var arrCfgForDelete = _config.ArrInstances.Values.FirstOrDefault(a =>
string.Equals(a.Category, libraryEntry.Category, StringComparison.OrdinalIgnoreCase));
@@ -265,7 +299,7 @@ public async Task ImportTorrentAsync(string hash, string? qbitInstance = null, C
{
_logger.LogWarning("ArrImportService not available, marking as imported without triggering");
libraryEntry.Imported = true;
- await _dbContext.SaveChangesAsync(cancellationToken);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken);
}
}
@@ -288,9 +322,7 @@ private async Task ProcessSingleTorrentAsync(
var state = ParseTorrentState(torrent.State);
var arrCfg = _config.ArrInstances.Values.FirstOrDefault(a =>
- !string.IsNullOrEmpty(a.Category)
- && CategoryPathHelper.MatchesConfigured(category, new[] { a.Category }, prefix: true)
- == CategoryPathHelper.NormalizeCategory(a.Category));
+ CategoryPathHelper.CategoryEquals(a.Category, category));
var ignoreYoungerThan = arrCfg?.Torrent.IgnoreTorrentsYoungerThan
?? _config.Settings.IgnoreTorrentsYoungerThan;
var timeNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -310,6 +342,7 @@ private async Task ProcessSingleTorrentAsync(
&& TorrentPolicyHelper.IsMonitoredPolicyCategory(_config, torrent.Category)))
{
await _seedingService.ApplyTrackerActionsForTorrentAsync(torrent, ct);
+ await _seedingService.ApplySeedingLimitsAsync(torrent, ct);
}
// PRE-STEP 1: Resolve leave_alone / remove_torrent / maxEta (qBitrr: _should_leave_alone)
@@ -485,8 +518,9 @@ await ignClient.RemoveTagsAsync(new List { torrent.Hash },
{
await ProcessPercentageThresholdAsync(torrent, maxEta, client, stats, ct);
}
- // Branch 14: Already imported → skip (qBitrr line 6184-6187)
- else if (await IsImportedInDatabaseAsync(torrent.Hash, torrent.QBitInstanceName, ct)
+ // Branch 14: Already imported or sent to scan → skip (qBitrr line 6184-6187, sent_to_scan_hashes)
+ else if ((_pathTracker?.IsHashAlreadyScanned(torrent.Hash) == true
+ || await IsImportedInDatabaseAsync(torrent.Hash, torrent.QBitInstanceName, ct))
&& _cache.IsFileFiltered(torrent.Hash))
{
_logger.LogTrace("Skipping already-imported torrent: [{Name}]", torrent.Name);
@@ -980,7 +1014,7 @@ private async Task AddTagAsync(TorrentInfo torrent, QBittorrentClient client, st
case AllowedStalledTag: entry.AllowedStalled = true; break;
case FreeSpacePausedTag: entry.FreeSpacePaused = true; break;
}
- await _dbContext.SaveChangesAsync(ct);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
}
else
@@ -1006,7 +1040,7 @@ private async Task RemoveTagAsync(TorrentInfo torrent, QBittorrentClient client,
case AllowedStalledTag: entry.AllowedStalled = false; break;
case FreeSpacePausedTag: entry.FreeSpacePaused = false; break;
}
- await _dbContext.SaveChangesAsync(ct);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
}
else
@@ -1100,7 +1134,7 @@ private async Task EnsureTorrentInDatabaseAsync(
};
_dbContext.TorrentLibrary.Add(entry);
- await _dbContext.SaveChangesAsync(cancellationToken);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: cancellationToken);
_logger.LogTrace("Added torrent {Hash} to database", torrent.Hash);
}
@@ -1247,7 +1281,7 @@ private async Task AddStalledTagAsync(TorrentInfo torrent, QBittorrentClient cli
if (entry != null)
{
entry.AllowedStalled = true;
- await _dbContext.SaveChangesAsync(ct);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
}
else
@@ -1268,7 +1302,7 @@ private async Task RemoveStalledTagAsync(TorrentInfo torrent, QBittorrentClient
if (entry != null)
{
entry.AllowedStalled = false;
- await _dbContext.SaveChangesAsync(ct);
+ await _dbContext.SaveChangesWithRetryAsync(_logger, _restartCoordinator, cancellationToken: ct);
}
}
else
diff --git a/src/Torrentarr.Workers/Program.cs b/src/Torrentarr.Workers/Program.cs
index 57ff7586..4414242a 100644
--- a/src/Torrentarr.Workers/Program.cs
+++ b/src/Torrentarr.Workers/Program.cs
@@ -144,6 +144,7 @@
});
// Add services
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs b/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs
new file mode 100644
index 00000000..1bdcfebb
--- /dev/null
+++ b/tests/Torrentarr.Core.Tests/Configuration/CategoryOwnershipHelperTests.cs
@@ -0,0 +1,75 @@
+using FluentAssertions;
+using Torrentarr.Core.Configuration;
+using Xunit;
+
+namespace Torrentarr.Core.Tests.Configuration;
+
+public class CategoryOwnershipHelperTests
+{
+ [Fact]
+ public void GetQBitOnlyManagedCategories_ExcludesArrCategories()
+ {
+ var cfg = new TorrentarrConfig();
+ cfg.QBitInstances["qBit"] = new QBitConfig
+ {
+ ManagedCategories = ["seed", "manual", "radarr"]
+ };
+ cfg.ArrInstances["Radarr"] = new ArrInstanceConfig { Category = "radarr" };
+
+ var only = CategoryOwnershipHelper.GetQBitOnlyManagedCategories(cfg);
+ only.Should().BeEquivalentTo(["seed", "manual"]);
+ }
+
+ [Fact]
+ public void ResolveOwningCategory_ExactMatch_Wins()
+ {
+ var cfg = new TorrentarrConfig();
+ cfg.ArrInstances["Sonarr"] = new ArrInstanceConfig { Category = "sonarr" };
+
+ CategoryOwnershipHelper.ResolveOwningCategory(cfg, "sonarr")
+ .Should().Be("sonarr");
+ }
+
+ [Fact]
+ public void ResolveOwningCategory_PrefixMatch_WhenMatchSubcategoriesEnabled()
+ {
+ var cfg = new TorrentarrConfig();
+ cfg.QBitInstances["qBit"] = new QBitConfig
+ {
+ MatchSubcategories = true,
+ ManagedCategories = ["seed"]
+ };
+
+ CategoryOwnershipHelper.ResolveOwningCategory(cfg, "seed/tleech", "qBit")
+ .Should().Be("seed");
+ }
+
+ [Fact]
+ public void ResolveOwningCategory_PrefixMatch_Disabled_ReturnsNull()
+ {
+ var cfg = new TorrentarrConfig();
+ cfg.QBitInstances["qBit"] = new QBitConfig
+ {
+ MatchSubcategories = false,
+ ManagedCategories = ["seed"]
+ };
+
+ CategoryOwnershipHelper.ResolveOwningCategory(cfg, "seed/tleech", "qBit")
+ .Should().BeNull();
+ }
+
+ [Fact]
+ public void ArrMatchSubcategoriesEffective_UsesPerArrOverride()
+ {
+ var cfg = new TorrentarrConfig();
+ cfg.QBitInstances["qBit"] = new QBitConfig { MatchSubcategories = false };
+ cfg.ArrInstances["Sonarr"] = new ArrInstanceConfig
+ {
+ Category = "sonarr",
+ MatchSubcategories = true
+ };
+
+ CategoryOwnershipHelper.ArrMatchSubcategoriesEffective(cfg, "Sonarr", "qBit")
+ .Should().BeTrue();
+ }
+}
diff --git a/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs
index 91cc49a5..f7e415c4 100644
--- a/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs
+++ b/tests/Torrentarr.Host.Tests/Api/LidarrArtistsEndpointTests.cs
@@ -68,6 +68,24 @@ public async Task GetLidarrArtistDetail_Returns404ForMissingArtist()
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
+ [Fact]
+ public async Task GetLidarrArtists_FiltersMissingAlbums()
+ {
+ _factory.SetConfigEnv();
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await CatalogTestDataSeeder.SeedLidarrArtistsAsync(db);
+
+ var client = _factory.CreateClientWithApiToken();
+ var response = await client.GetAsync("/web/lidarr/lidarr/artists?missing=true");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
+ json.GetProperty("artists").GetArrayLength().Should().Be(1);
+ json.GetProperty("artists")[0].GetProperty("artist").GetProperty("albumsMissing").GetInt32()
+ .Should().BeGreaterThan(0);
+ }
+
[Fact]
public async Task GetApiLidarrArtists_MirrorsWebShape()
{
diff --git a/tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs b/tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs
new file mode 100644
index 00000000..5fe4bbe0
--- /dev/null
+++ b/tests/Torrentarr.Host.Tests/Api/OpenApiDocEndpointTests.cs
@@ -0,0 +1,43 @@
+using System.Net;
+using System.Text.Json;
+using FluentAssertions;
+using Xunit;
+
+namespace Torrentarr.Host.Tests.Api;
+
+[Collection("HostWeb")]
+public class OpenApiDocEndpointTests : IClassFixture
+{
+ private readonly TorrentarrWebApplicationFactory _factory;
+
+ public OpenApiDocEndpointTests(TorrentarrWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Theory]
+ [InlineData("/api/openapi.json")]
+ [InlineData("/web/openapi.json")]
+ public async Task OpenApiJson_ReturnsCuratedSpec(string path)
+ {
+ var client = _factory.CreateClientWithApiToken();
+ var response = await client.GetAsync(path);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Headers.CacheControl?.NoStore.Should().BeTrue();
+
+ var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
+ json.GetProperty("openapi").GetString().Should().StartWith("3.");
+ json.GetProperty("paths").EnumerateObject().Count().Should().BeGreaterThanOrEqualTo(66);
+ }
+
+ [Theory]
+ [InlineData("/api/docs")]
+ [InlineData("/web/docs")]
+ public async Task Docs_RedirectsToSwagger(string path)
+ {
+ var client = _factory.CreateClientWithApiTokenNoRedirect();
+ var response = await client.GetAsync(path);
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect);
+ response.Headers.Location?.OriginalString.Should().Contain("/swagger/index.html");
+ }
+}
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs
index fb7e7d19..e52211c6 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/ArrMediaServiceTests.cs
@@ -32,7 +32,8 @@ private static ArrMediaService CreateService(TorrentarrConfig? config = null)
var mockSyncService = new Mock(
NullLogger.Instance,
cfg,
- dbContext);
+ dbContext,
+ new DatabaseRestartCoordinator());
return new ArrMediaService(
NullLogger.Instance,
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs
index 943a2847..711c066a 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/ArrSyncServiceTests.cs
@@ -41,7 +41,7 @@ public void Dispose()
private ArrSyncService CreateService(TorrentarrConfig? config = null)
{
config ??= new TorrentarrConfig();
- return new ArrSyncService(NullLogger.Instance, config, _db);
+ return new ArrSyncService(NullLogger.Instance, config, _db, new DatabaseRestartCoordinator());
}
private static ArrInstanceConfig MakeInstance(string type, string uri = "http://localhost:7878")
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs
index b20d688c..922fef7c 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/AvailabilityCheckTests.cs
@@ -31,7 +31,7 @@ private static ArrSyncService CreateService()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var dbContext = new TorrentarrDbContext(options);
- return new ArrSyncService(Logger, new TorrentarrConfig(), dbContext);
+ return new ArrSyncService(Logger, new TorrentarrConfig(), dbContext, new DatabaseRestartCoordinator());
}
private static bool CallCheckEpisodeAvailability(DateTime? airDateUtc, string episodeTitle)
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs
index e69257c2..e9f37507 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/QualityProfileSwitcherServiceTests.cs
@@ -25,7 +25,7 @@ private static QualityProfileSwitcherService CreateService(string? dbName = null
.Options;
var db = new TorrentarrDbContext(options);
return new QualityProfileSwitcherService(
- NullLogger.Instance, db);
+ NullLogger.Instance, db, new DatabaseRestartCoordinator());
}
// ── ForceResetAllTempProfilesAsync ────────────────────────────────────────
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs
index 0eb0d8d9..f0481b54 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/ScanQueueForBlocklistTests.cs
@@ -32,7 +32,8 @@ private static ArrSyncService CreateService()
return new ArrSyncService(
NullLogger.Instance,
new TorrentarrConfig(),
- new TorrentarrDbContext(options));
+ new TorrentarrDbContext(options),
+ new DatabaseRestartCoordinator());
}
private static async Task InvokeScanAsync(
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs
index 3b24d1fd..cc05e456 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/SearchExecutorTests.cs
@@ -23,13 +23,15 @@ private static SearchExecutor CreateService(TorrentarrConfig? config = null, Tor
var switcher = new QualityProfileSwitcherService(
NullLogger.Instance,
- db);
+ db,
+ new DatabaseRestartCoordinator());
return new SearchExecutor(
NullLogger.Instance,
cfg,
db,
- switcher);
+ switcher,
+ new DatabaseRestartCoordinator());
}
private static TorrentarrConfig CreateConfigWithRadarr(int searchLoopDelay = 30, int searchLimit = 5)
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs
index 16ed4d3a..c73aeeb3 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessInstanceScopeTests.cs
@@ -98,7 +98,8 @@ public async Task IsImportedInDatabase_ScopesByQbitInstance()
new QBittorrentConnectionManager(NullLogger.Instance),
db,
config,
- new TorrentCacheService(NullLogger.Instance));
+ new TorrentCacheService(NullLogger.Instance),
+ new DatabaseRestartCoordinator());
var method = typeof(TorrentProcessor).GetMethod(
"IsImportedInDatabaseAsync",
@@ -130,7 +131,8 @@ private static bool InvokeHasTag(
new QBittorrentConnectionManager(NullLogger.Instance),
db,
config,
- new TorrentCacheService(NullLogger.Instance)),
+ new TorrentCacheService(NullLogger.Instance),
+ new DatabaseRestartCoordinator()),
nameof(FreeSpaceService) => new FreeSpaceService(
NullLogger.Instance,
config,
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs
index 194ab269..c8684ea1 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/TaglessScopingTests.cs
@@ -46,7 +46,8 @@ public async Task TorrentProcessor_HasTag_ReadsFreeSpacePausedPerQbitInstance()
new QBittorrentConnectionManager(NullLogger.Instance),
db,
config,
- new TorrentCacheService(NullLogger.Instance));
+ new TorrentCacheService(NullLogger.Instance),
+ new DatabaseRestartCoordinator());
var seedboxTorrent = new TorrentInfo { Hash = "abc", QBitInstanceName = "qBit-seedbox" };
var primaryTorrent = new TorrentInfo { Hash = "abc", QBitInstanceName = "qBit" };
diff --git a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs
index 9b3b8d44..98db271c 100644
--- a/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs
+++ b/tests/Torrentarr.Infrastructure.Tests/Services/TorrentProcessorTests.cs
@@ -55,7 +55,8 @@ private TorrentProcessor CreateProcessor(TorrentarrConfig? config = null)
manager,
_db,
config,
- new TorrentCacheService(NullLogger.Instance));
+ new TorrentCacheService(NullLogger.Instance),
+ new DatabaseRestartCoordinator());
}
// ── Constructor ────────────────────────────────────────────────────────────
@@ -230,6 +231,7 @@ public async Task ProcessSingleTorrentAsync_CustomFormatUnmet_BlockedByHnr_DoesN
_db,
config,
new TorrentCacheService(NullLogger.Instance),
+ new DatabaseRestartCoordinator(),
importMock.Object,
seedingMock.Object);
diff --git a/webui/src/config/torrentHandlingSummary.ts b/webui/src/config/torrentHandlingSummary.ts
index dc886781..454a3f3d 100644
--- a/webui/src/config/torrentHandlingSummary.ts
+++ b/webui/src/config/torrentHandlingSummary.ts
@@ -291,6 +291,7 @@ export function getQbitTorrentHandlingSummary(
{},
);
const managedCats = get(state, ["ManagedCategories"]) as string[] | undefined;
+ const matchSubcategories = Boolean(get(state, ["MatchSubcategories"]));
const managedPreview =
Array.isArray(managedCats) && managedCats.length > 0
? managedCats.slice(0, 3).join(", ") +
@@ -312,8 +313,11 @@ export function getQbitTorrentHandlingSummary(
// 1. New / in managed category
if (managedPreview) {
+ const scope = matchSubcategories
+ ? `When a torrent category matches a managed prefix (Match subcategories is on; e.g. ${managedPreview} matches seed as well as seed/child paths)`
+ : `When a torrent is in a managed category exactly (e.g. ${managedPreview})`;
const newLine =
- `When a torrent is in a managed category (e.g. ${managedPreview})` +
+ scope +
(Number.isFinite(ignoreYounger) && ignoreYounger >= 0
? `, it is left alone for the first ${formatSeconds(ignoreYounger)} so it is not treated as stalled.`
: ".");
@@ -329,7 +333,11 @@ export function getQbitTorrentHandlingSummary(
blocks.push("Stalled downloads are not removed.");
} else if (stalledDelayMin === 0) {
let s = "Stalled downloads are not removed (infinite delay)";
- if (managedPreview) s += ` in ${managedPreview}`;
+ if (managedPreview) {
+ s += matchSubcategories
+ ? ` for torrents under managed category prefixes (${managedPreview})`
+ : ` in ${managedPreview}`;
+ }
s += ".";
if (Number.isFinite(ignoreYounger) && ignoreYounger >= 0) {
s += ` New torrents are ignored for the first ${formatSeconds(ignoreYounger)}.`;
@@ -337,7 +345,11 @@ export function getQbitTorrentHandlingSummary(
blocks.push(s);
} else {
let s = `If the download stops progressing for ${formatMinutes(stalledDelayMin)}`;
- if (managedPreview) s += ` in ${managedPreview}`;
+ if (managedPreview) {
+ s += matchSubcategories
+ ? ` for torrents under managed category prefixes (${managedPreview})`
+ : ` in ${managedPreview}`;
+ }
s += ", Torrentarr removes it.";
if (Number.isFinite(ignoreYounger) && ignoreYounger >= 0) {
s += ` New torrents are ignored for the first ${formatSeconds(ignoreYounger)} (not treated as stalled).`;
diff --git a/webui/src/pages/ConfigView.tsx b/webui/src/pages/ConfigView.tsx
index f0d15c73..6b402006 100644
--- a/webui/src/pages/ConfigView.tsx
+++ b/webui/src/pages/ConfigView.tsx
@@ -527,6 +527,13 @@ const QBIT_FIELDS: FieldDefinition[] = [
return [];
},
},
+ {
+ label: "Match subcategories",
+ path: ["MatchSubcategories"],
+ type: "checkbox",
+ description:
+ "When off (default), each managed category must match the qBittorrent category string exactly (use full paths like parent/child). When on, each entry here is a prefix: torrents in child categories (e.g. seed/foo) are included when seed is listed.",
+ },
{
label: "Max Upload Ratio",
path: ["CategorySeeding", "MaxUploadRatio"],
@@ -736,6 +743,13 @@ const ARR_GENERAL_FIELDS: FieldDefinition[] = [
return undefined;
},
},
+ {
+ label: "Match subcategories (override)",
+ path: ["MatchSubcategories"],
+ type: "checkbox",
+ description:
+ "Optional. When set, overrides the qBit instance MatchSubcategories default for this Arr only (explicit true/false wins; omit to inherit from [qBit] / [qBit-*]).",
+ },
{ label: "Re-search", path: ["ReSearch"], type: "checkbox" },
{
label: "Import Mode",
@@ -2028,6 +2042,7 @@ export function ConfigView(props?: ConfigViewProps): JSX.Element {
Port: 8080,
UserName: "",
Password: "",
+ MatchSubcategories: false,
};
setFormState(
produce(formState, (draft) => {