diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index 5ef9771..7ae3834 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -181,6 +181,16 @@ "retention_days": { "minimum": 1, "type": "integer" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -270,7 +280,19 @@ }, "AutomationRule": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } }, "AutomationRunResponse": { "allOf": [ @@ -330,6 +352,16 @@ "properties": { "name": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -583,6 +615,16 @@ "type": "object" }, "type": "array" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "required": [ @@ -682,6 +724,16 @@ }, "name": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -695,6 +747,16 @@ "event_type": { "description": "Optional event type filter. Empty clears all active-profile operation logs.", "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -824,6 +886,16 @@ "manual" ], "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -1185,6 +1257,16 @@ "sidebar_shortcuts_expanded": { "description": "Stores whether the sidebar keyboard shortcut help is expanded for the active profile.", "type": "boolean" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -1233,13 +1315,35 @@ }, "timeout_seconds": { "type": "integer" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" }, "RatioGroup": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } }, "RssConfigResponse": { "allOf": [ @@ -1443,6 +1547,16 @@ "surge_refill_batch_size": { "type": "integer", "minimum": 1 + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -1456,6 +1570,16 @@ "up": { "description": "Bytes per second, 0 unlimited", "type": "integer" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -1592,6 +1716,16 @@ "maximum": 3, "minimum": 0, "type": "integer" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -2188,6 +2322,16 @@ }, "name": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } } }, @@ -2203,6 +2347,16 @@ }, "new_name": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } } }, @@ -2220,6 +2374,21 @@ } } ] + }, + "ProfileSelector": { + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id for profile-scoped API operations. If omitted, the user active profile is used for backward compatibility; fallback profile id 1 is used only when no active profile exists and the user can access it." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name for profile-scoped API operations. profile_id has priority when both are supplied." + } + } } }, "securitySchemes": { @@ -2240,6 +2409,28 @@ "name": "session", "type": "apiKey" } + }, + "parameters": { + "ProfileId": { + "name": "profile_id", + "in": "query", + "required": false, + "description": "Optional rTorrent profile id for profile-scoped API operations. If omitted, the user active profile is used for backward compatibility; fallback profile id 1 is used only when no active profile exists and the user can access it.", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + "ProfileName": { + "name": "profile_name", + "in": "query", + "required": false, + "description": "Optional rTorrent profile name for profile-scoped API operations. profile_id has priority when both are supplied.", + "schema": { + "type": "string" + } + } } }, "info": { @@ -2267,7 +2458,15 @@ "sessionCookie": [] } ], - "summary": "pyTorrent application status" + "summary": "pyTorrent application status", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/auth/login": { @@ -2283,6 +2482,16 @@ }, "username": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "required": [ @@ -2346,7 +2555,15 @@ "description": "Authentication disabled" } }, - "summary": "Log in with username and password" + "summary": "Log in with username and password", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/auth/logout": { @@ -2378,7 +2595,15 @@ "sessionCookie": [] } ], - "summary": "Log out current user" + "summary": "Log out current user", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/auth/me": { @@ -2425,7 +2650,15 @@ "sessionCookie": [] } ], - "summary": "Get current user" + "summary": "Get current user", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/auth/users": { @@ -2472,7 +2705,15 @@ "sessionCookie": [] } ], - "summary": "List users" + "summary": "List users", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -2480,7 +2721,19 @@ "application/json": { "schema": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -2525,7 +2778,15 @@ "sessionCookie": [] } ], - "summary": "Create user" + "summary": "Create user", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/auth/users/{user_id}": { @@ -2538,6 +2799,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -2593,6 +2860,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -2600,7 +2873,19 @@ "application/json": { "schema": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -2659,6 +2944,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -2707,6 +2998,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -2717,6 +3014,16 @@ "name": { "example": "automation", "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -2790,6 +3097,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -2858,7 +3171,15 @@ "sessionCookie": [] } ], - "summary": "List automation rules and history" + "summary": "List automation rules and history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -2898,7 +3219,15 @@ "sessionCookie": [] } ], - "summary": "Create or update automation rule" + "summary": "Create or update automation rule", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/automations/check": { @@ -2940,7 +3269,15 @@ "sessionCookie": [] } ], - "summary": "Run automation check immediately" + "summary": "Run automation check immediately", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/automations/export": { @@ -2972,7 +3309,15 @@ "sessionCookie": [] } ], - "summary": "Export automation rules" + "summary": "Export automation rules", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/automations/history": { @@ -3036,7 +3381,15 @@ "sessionCookie": [] } ], - "summary": "Clear automation execution history" + "summary": "Clear automation execution history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/automations/import": { @@ -3048,6 +3401,12 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -3055,7 +3414,19 @@ "application/json": { "schema": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -3101,6 +3472,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -3143,6 +3520,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -3204,7 +3587,15 @@ "sessionCookie": [] } ], - "summary": "List backups" + "summary": "List backups", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -3262,7 +3653,15 @@ "sessionCookie": [] } ], - "summary": "Create backup" + "summary": "Create backup", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/backup/app": { @@ -3339,6 +3738,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } }, @@ -3406,6 +3813,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } }, @@ -3439,6 +3854,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] }, "post": { @@ -3490,6 +3913,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } }, @@ -3512,7 +3943,15 @@ "sessionCookie": [] } ], - "summary": "Get automatic backup settings" + "summary": "Get automatic backup settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -3552,7 +3991,15 @@ "sessionCookie": [] } ], - "summary": "Save automatic backup settings" + "summary": "Save automatic backup settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/backup/{backup_id}": { @@ -3565,6 +4012,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -3624,6 +4077,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -3667,6 +4126,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -3709,6 +4174,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -3774,7 +4245,15 @@ "sessionCookie": [] } ], - "summary": "Clear all cleanup-supported history" + "summary": "Clear all cleanup-supported history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/automations": { @@ -3796,7 +4275,15 @@ "sessionCookie": [] } ], - "summary": "Clear automation execution history" + "summary": "Clear automation execution history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/cache": { @@ -3829,7 +4316,15 @@ "sessionCookie": [] } ], - "summary": "Clear active profile cache" + "summary": "Clear active profile cache", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/database/vacuum": { @@ -3845,6 +4340,16 @@ "properties": { "force": { "type": "boolean" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } } } @@ -3904,6 +4409,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } }, @@ -3926,7 +4439,15 @@ "sessionCookie": [] } ], - "summary": "Clear finished job history" + "summary": "Clear finished job history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/operation-logs": { @@ -3949,7 +4470,15 @@ "sessionCookie": [] } ], - "summary": "Clear operation logs" + "summary": "Clear operation logs", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/planner": { @@ -3982,7 +4511,15 @@ "sessionCookie": [] } ], - "summary": "Clear Planner action history" + "summary": "Clear Planner action history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/poller-diagnostics": { @@ -4028,6 +4565,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } }, @@ -4050,7 +4595,15 @@ "sessionCookie": [] } ], - "summary": "Clear Smart Queue history" + "summary": "Clear Smart Queue history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/cleanup/summary": { @@ -4072,7 +4625,15 @@ "sessionCookie": [] } ], - "summary": "Cleanup summary" + "summary": "Cleanup summary", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/download-planner": { @@ -4089,14 +4650,34 @@ "description": "OK" } }, - "summary": "Manage download planner settings" + "summary": "Manage download planner settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -4114,7 +4695,15 @@ "description": "OK" } }, - "summary": "Manage download planner settings" + "summary": "Manage download planner settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/download-planner/check": { @@ -4123,7 +4712,19 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -4141,7 +4742,15 @@ "description": "OK" } }, - "summary": "Run download planner check" + "summary": "Run download planner check", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/download-planner/history": { @@ -4195,7 +4804,15 @@ "sessionCookie": [] } ], - "summary": "Clear Planner action history" + "summary": "Clear Planner action history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/download-planner/override": { @@ -4217,6 +4834,16 @@ }, "up_limit": { "type": "integer" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -4242,7 +4869,15 @@ "description": "OK" } }, - "summary": "Set download planner override" + "summary": "Set download planner override", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/download-planner/preview": { @@ -4259,6 +4894,12 @@ "minimum": 1, "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4305,7 +4946,15 @@ "sessionCookie": [] } ], - "summary": "Application health check" + "summary": "Application health check", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/health/nagios": { @@ -4323,7 +4972,15 @@ "sessionCookie": [] } ], - "summary": "Nagios-compatible health check" + "summary": "Nagios-compatible health check", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/jobs": { @@ -4344,6 +5001,12 @@ "default": 0, "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4416,7 +5079,15 @@ "sessionCookie": [] } ], - "summary": "Clear finished job history" + "summary": "Clear finished job history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/jobs/{job_id}/cancel": { @@ -4429,6 +5100,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4481,6 +5158,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4533,6 +5216,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4609,7 +5298,15 @@ "sessionCookie": [] } ], - "summary": "List labels" + "summary": "List labels", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -4664,7 +5361,15 @@ "sessionCookie": [] } ], - "summary": "Create or update label" + "summary": "Create or update label", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/labels/{label_id}": { @@ -4677,6 +5382,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4743,7 +5454,15 @@ "sessionCookie": [] } ], - "summary": "OpenAPI schema" + "summary": "OpenAPI schema", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/operation-logs": { @@ -4784,6 +5503,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4833,7 +5558,15 @@ "sessionCookie": [] } ], - "summary": "Apply operation log retention" + "summary": "Apply operation log retention", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/operation-logs/clear": { @@ -4866,7 +5599,15 @@ "sessionCookie": [] } ], - "summary": "Clear operation logs" + "summary": "Clear operation logs", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/operation-logs/settings": { @@ -4899,7 +5640,15 @@ "sessionCookie": [] } ], - "summary": "Save operation log retention settings" + "summary": "Save operation log retention settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/path/browse": { @@ -4911,6 +5660,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -4952,7 +5707,15 @@ "sessionCookie": [] } ], - "summary": "Get active default download path" + "summary": "Get active default download path", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/poller/settings": { @@ -4975,7 +5738,15 @@ "description": "Get poller settings" } }, - "summary": "Get poller settings" + "summary": "Get poller settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "description": "Save poller settings", @@ -4996,7 +5767,15 @@ "description": "Save poller settings" } }, - "summary": "Save poller settings" + "summary": "Save poller settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/port-check": { @@ -5018,7 +5797,15 @@ "sessionCookie": [] } ], - "summary": "Read cached incoming port check status" + "summary": "Read cached incoming port check status", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "responses": { @@ -5038,7 +5825,15 @@ "sessionCookie": [] } ], - "summary": "Run incoming port check immediately, bypassing cache" + "summary": "Run incoming port check immediately, bypassing cache", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/preferences": { @@ -5072,7 +5867,15 @@ "sessionCookie": [] } ], - "summary": "Get preferences" + "summary": "Get preferences", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -5124,7 +5927,15 @@ "sessionCookie": [] } ], - "summary": "Save preferences" + "summary": "Save preferences", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/preferences/table-columns/recommended": { @@ -5135,7 +5946,19 @@ "application/json": { "schema": { "additionalProperties": false, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -5180,7 +6003,15 @@ "sessionCookie": [] } ], - "summary": "Apply recommended table columns" + "summary": "Apply recommended table columns", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/profiles": { @@ -5220,7 +6051,15 @@ "sessionCookie": [] } ], - "summary": "List rTorrent profiles" + "summary": "List rTorrent profiles", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -5278,7 +6117,15 @@ "sessionCookie": [] } ], - "summary": "Create rTorrent profile" + "summary": "Create rTorrent profile", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/profiles/diagnostics": { @@ -5301,7 +6148,15 @@ "description": "Diagnostics result" } }, - "summary": "Active profile diagnostics" + "summary": "Active profile diagnostics", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/profiles/export": { @@ -5324,7 +6179,15 @@ "description": "Profiles export" } }, - "summary": "Export profiles" + "summary": "Export profiles", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/profiles/import": { @@ -5340,6 +6203,16 @@ "type": "object" }, "type": "array" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -5365,7 +6238,15 @@ "description": "OK" } }, - "summary": "Import profiles" + "summary": "Import profiles", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/profiles/test": { @@ -5390,6 +6271,16 @@ }, "timeout_seconds": { "type": "number" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -5415,7 +6306,15 @@ "description": "OK" } }, - "summary": "Test rTorrent profile" + "summary": "Test rTorrent profile", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/profiles/{profile_id}": { @@ -5428,6 +6327,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -5483,6 +6388,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -5553,6 +6464,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -5623,7 +6540,15 @@ "description": "Diagnostics result" } }, - "summary": "Profile diagnostics" + "summary": "Profile diagnostics", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/ratio-groups": { @@ -5660,7 +6585,15 @@ "sessionCookie": [] } ], - "summary": "List ratio groups" + "summary": "List ratio groups", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "requestBody": { @@ -5715,7 +6648,15 @@ "sessionCookie": [] } ], - "summary": "Create or update ratio group" + "summary": "Create or update ratio group", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/ratio-groups/check": { @@ -5724,7 +6665,19 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -5742,7 +6695,15 @@ "description": "OK" } }, - "summary": "Run ratio groups check" + "summary": "Run ratio groups check", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rss": { @@ -5764,7 +6725,15 @@ "sessionCookie": [] } ], - "summary": "List RSS feeds and rules" + "summary": "List RSS feeds and rules", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rss/check": { @@ -5818,7 +6787,15 @@ "sessionCookie": [] } ], - "summary": "Manually check RSS feeds" + "summary": "Manually check RSS feeds", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rss/feeds": { @@ -5828,7 +6805,19 @@ "application/json": { "schema": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -5861,7 +6850,15 @@ "sessionCookie": [] } ], - "summary": "Add or update RSS feed" + "summary": "Add or update RSS feed", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rss/feeds/{feed_id}": { @@ -5874,6 +6871,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -5898,7 +6901,19 @@ "application/json": { "schema": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -5931,7 +6946,15 @@ "sessionCookie": [] } ], - "summary": "Add or update RSS rule" + "summary": "Add or update RSS rule", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rss/rules/test": { @@ -5940,7 +6963,19 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -5958,7 +6993,15 @@ "description": "OK" } }, - "summary": "Test RSS rule" + "summary": "Test RSS rule", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rss/rules/{rule_id}": { @@ -5971,6 +7014,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -6008,7 +7057,15 @@ "description": "Get startup rTorrent config" } }, - "summary": "Get startup rTorrent config" + "summary": "Get startup rTorrent config", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] }, "post": { "description": "Save startup rTorrent config", @@ -6029,7 +7086,15 @@ "description": "Save startup rTorrent config" } }, - "summary": "Save startup rTorrent config" + "summary": "Save startup rTorrent config", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rtorrent-config/generate": { @@ -6052,7 +7117,15 @@ "description": "Generate startup rTorrent config" } }, - "summary": "Generate startup rTorrent config" + "summary": "Generate startup rTorrent config", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/rtorrent-config/reset": { @@ -6085,7 +7158,15 @@ "sessionCookie": [] } ], - "summary": "Reset startup rTorrent config overrides" + "summary": "Reset startup rTorrent config overrides", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/smart-queue": { @@ -6100,6 +7181,12 @@ "minimum": 1, "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -6159,7 +7246,15 @@ "sessionCookie": [] } ], - "summary": "Save Smart Queue settings" + "summary": "Save Smart Queue settings", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/smart-queue/check": { @@ -6191,7 +7286,15 @@ "sessionCookie": [] } ], - "summary": "Run Smart Queue check immediately" + "summary": "Run Smart Queue check immediately", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/smart-queue/exclusion": { @@ -6209,6 +7312,16 @@ }, "reason": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "required": [ @@ -6263,7 +7376,15 @@ "sessionCookie": [] } ], - "summary": "Set Smart Queue exclusion" + "summary": "Set Smart Queue exclusion", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/smart-queue/history": { @@ -6317,7 +7438,15 @@ "sessionCookie": [] } ], - "summary": "Clear Smart Queue history for active profile" + "summary": "Clear Smart Queue history for active profile", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/speed/limits": { @@ -6359,7 +7488,15 @@ "sessionCookie": [] } ], - "summary": "Queue global speed limit change" + "summary": "Queue global speed limit change", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/static_hash": { @@ -6393,7 +7530,15 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/system/disk": { @@ -6435,7 +7580,15 @@ "sessionCookie": [] } ], - "summary": "Disk usage for monitored paths" + "summary": "Disk usage for monitored paths", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/system/status": { @@ -6478,7 +7631,15 @@ "sessionCookie": [] } ], - "summary": "rTorrent/system status" + "summary": "rTorrent/system status", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrent-stats": { @@ -6490,6 +7651,12 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -6554,7 +7721,15 @@ "sessionCookie": [] } ], - "summary": "Get cached torrent snapshot" + "summary": "Get cached torrent snapshot", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrents/add": { @@ -6585,6 +7760,16 @@ "type": "array" } ] + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -6611,6 +7796,16 @@ }, "uris": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -6645,7 +7840,15 @@ "sessionCookie": [] } ], - "summary": "Add magnet links or torrent files" + "summary": "Add magnet links or torrent files", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrents/create": { @@ -6687,6 +7890,16 @@ }, "trackers": { "type": "string" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -6708,7 +7921,15 @@ "description": "Created torrent file" } }, - "summary": "Create torrent file" + "summary": "Create torrent file", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrents/preview": { @@ -6717,7 +7938,19 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -6735,7 +7968,15 @@ "description": "OK" } }, - "summary": "Preview torrent files" + "summary": "Preview torrent files", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrents/torrent-files.zip": { @@ -6744,7 +7985,19 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -6762,7 +8015,15 @@ "description": "OK" } }, - "summary": "Export selected torrent files as ZIP" + "summary": "Export selected torrent files as ZIP", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrents/torrent-files.zip/link": { @@ -6781,6 +8042,16 @@ "items": { "type": "string" } + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "required": [ @@ -6811,7 +8082,15 @@ } } } - } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/api/torrents/{action_name}": { @@ -6838,6 +8117,12 @@ ], "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -6868,6 +8153,16 @@ }, "remove_data": { "type": "boolean" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "type": "object" @@ -6927,6 +8222,12 @@ "minimum": 64, "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -6981,6 +8282,12 @@ ], "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -7033,6 +8340,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7077,6 +8390,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -7088,6 +8407,16 @@ "properties": { "file_index": { "type": "integer" + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } }, "required": [ @@ -7131,13 +8460,31 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -7170,6 +8517,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -7185,6 +8538,16 @@ "type": "integer" }, "nullable": true + }, + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." } } } @@ -7225,13 +8588,31 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -7262,6 +8643,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -7324,6 +8711,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7359,6 +8752,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7396,6 +8795,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7442,6 +8847,12 @@ "schema": { "type": "integer" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7496,6 +8907,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7538,6 +8955,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7567,6 +8990,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7603,6 +9032,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7653,6 +9088,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "requestBody": { @@ -7660,7 +9101,19 @@ "application/json": { "schema": { "additionalProperties": true, - "type": "object" + "type": "object", + "properties": { + "profile_id": { + "type": "integer", + "default": 1, + "minimum": 1, + "description": "Optional rTorrent profile id. Overrides active profile for this request." + }, + "profile_name": { + "type": "string", + "description": "Optional rTorrent profile name. Used only when profile_id is not provided." + } + } } } }, @@ -7713,6 +9166,12 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7755,6 +9214,12 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7807,6 +9272,12 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7859,6 +9330,12 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7901,7 +9378,15 @@ "description": "Traffic history" } }, - "summary": "Traffic history" + "summary": "Traffic history", + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] } }, "/download/{token}": { @@ -7916,6 +9401,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -7977,6 +9468,12 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" } ], "responses": { @@ -8054,6 +9551,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } }, @@ -8097,6 +9602,14 @@ { "sessionCookie": [] } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } ] } } diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py index 6116b4f..8bc9c79 100644 --- a/pytorrent/routes/_shared.py +++ b/pytorrent/routes/_shared.py @@ -23,7 +23,7 @@ from flask import Blueprint, jsonify, request, abort, send_file, redirect, Respo from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR from ..db import connect, utcnow from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin -from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance +from ..services import auth, preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance from ..services.torrent_cache import torrent_cache from ..services.torrent_summary import cached_summary from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs @@ -39,6 +39,78 @@ from .auth_api import register_auth_routes register_auth_routes(bp) + + +def _request_profile_selector() -> tuple[int | None, str]: + """Return the optional profile selector supplied by external API clients.""" + payload = {} + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + try: + payload = request.get_json(silent=True) or {} + except Exception: + payload = {} + profile_id = request.args.get("profile_id") or request.form.get("profile_id") or payload.get("profile_id") or request.headers.get("X-PyTorrent-Profile-Id") + profile_name = request.args.get("profile_name") or request.form.get("profile_name") or payload.get("profile_name") or request.headers.get("X-PyTorrent-Profile-Name") or "" + try: + return (int(profile_id), "") if profile_id not in (None, "") else (None, str(profile_name or "").strip()) + except (TypeError, ValueError): + raise ValueError("profile_id must be an integer") + + +def _profile_by_name(profile_name: str, user_id: int | None = None): + name = str(profile_name or "").strip() + if not name: + return None + user_id = user_id or default_user_id() + visible = auth.visible_profile_ids(user_id) + with connect() as conn: + if visible is None: + return conn.execute( + "SELECT * FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", + (name,), + ).fetchone() + if not visible: + return None + placeholders = ",".join("?" for _ in visible) + return conn.execute( + f"SELECT * FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", + (*tuple(visible), name), + ).fetchone() + + +def request_profile(require_write: bool = False): + """Resolve API profile context from profile_id/profile_name, then active profile for compatibility.""" + try: + profile_id, profile_name = _request_profile_selector() + except ValueError: + raise + user_id = default_user_id() + profile = None + if profile_id: + profile = preferences.get_profile(int(profile_id), user_id) + elif profile_name: + profile = _profile_by_name(profile_name, user_id) + else: + profile = preferences.active_profile(user_id) + if not profile and auth.can_access_profile(1, user_id): + profile = preferences.get_profile(1, user_id) + if not profile and (profile_id or profile_name): + abort(404) + if not profile: + return None + pid = int(profile["id"]) + if require_write and not auth.can_write_profile(pid, user_id): + abort(403) + if not require_write and not auth.can_access_profile(pid, user_id): + abort(403) + return profile + + +def request_profile_id(require_write: bool = False) -> int | None: + profile = request_profile(require_write=require_write) + return int(profile["id"]) if profile else None + + def _job_profile_id(job_id: str) -> int | None: with connect() as conn: row = conn.execute("SELECT profile_id FROM jobs WHERE id=?", (job_id,)).fetchone() diff --git a/pytorrent/routes/automations.py b/pytorrent/routes/automations.py index d4f5447..aeb517a 100644 --- a/pytorrent/routes/automations.py +++ b/pytorrent/routes/automations.py @@ -10,7 +10,7 @@ def _automation_user_id() -> int: @bp.get('/automations') def automations_get(): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({'rules': [], 'history': [], 'error': 'No profile'}) try: @@ -26,7 +26,7 @@ def automations_get(): @bp.get('/automations/export') def automations_export(): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -39,7 +39,7 @@ def automations_export(): @bp.post('/automations/import') def automations_import(): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -55,7 +55,7 @@ def automations_import(): @bp.post('/automations') def automations_save(): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -69,7 +69,7 @@ def automations_save(): @bp.delete('/automations/') def automations_delete(rule_id: int): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -83,7 +83,7 @@ def automations_delete(rule_id: int): @bp.post('/automations//run') def automations_run_rule(rule_id: int): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -100,7 +100,7 @@ def automations_run_rule(rule_id: int): @bp.post('/automations/check') def automations_check(): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -117,7 +117,7 @@ def automations_check(): @bp.delete('/automations/history') def automations_history_clear(): from ..services import automation_rules - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: diff --git a/pytorrent/routes/backup.py b/pytorrent/routes/backup.py index 37113c4..71a27e4 100644 --- a/pytorrent/routes/backup.py +++ b/pytorrent/routes/backup.py @@ -4,8 +4,8 @@ from ._shared import * from ..services import auth -def _active_profile_id() -> int | None: - profile = preferences.active_profile() +def _active_profile_id(require_write: bool = False) -> int | None: + profile = request_profile(require_write=require_write) return int(profile["id"]) if profile else None @@ -27,7 +27,7 @@ def backup_list(): @bp.post("/backup/profile") def backup_create_profile(): data = request.get_json(silent=True) or {} - pid = _active_profile_id() + pid = _active_profile_id(require_write=True) if not pid: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -84,7 +84,7 @@ def profile_backup_settings_get(): @bp.post("/backup/profile/settings") def profile_backup_settings_save(): data = request.get_json(silent=True) or {} - pid = _active_profile_id() + pid = _active_profile_id(require_write=True) if not pid: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -104,7 +104,7 @@ def backup_preview(backup_id: int): @bp.post("/backup//restore") def backup_restore(backup_id: int): try: - pid = _active_profile_id() + pid = _active_profile_id(require_write=True) return ok({"result": backup_service.restore_backup(backup_id, default_user_id(), profile_id=pid)}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400 diff --git a/pytorrent/routes/operation_logs.py b/pytorrent/routes/operation_logs.py index d7a9695..1fb9739 100644 --- a/pytorrent/routes/operation_logs.py +++ b/pytorrent/routes/operation_logs.py @@ -5,7 +5,7 @@ from ..services import operation_logs def _active_profile_or_400(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return None return profile diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index 1b2cb19..d113ff9 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -16,7 +16,7 @@ def ok(payload=None): def _profile_or_error(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return None, (jsonify({"ok": False, "error": "No profile"}), 400) return profile, None diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index 029e4fb..86f6835 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -6,7 +6,15 @@ from ..services import auth @bp.get("/profiles") def profiles_list(): - return ok({"profiles": preferences.list_profiles(), "active": preferences.active_profile()}) + profiles = [] + for row in preferences.list_profiles(): + item = dict(row) + settings = backup_service.get_auto_backup_settings(default_user_id(), "profile", int(item.get("id") or 0)) + item["profile_backup_enabled"] = bool(settings.get("enabled")) + item["profile_backup_interval_hours"] = settings.get("interval_hours") + item["profile_backup_retention_days"] = settings.get("retention_days") + profiles.append(item) + return ok({"profiles": profiles, "active": preferences.active_profile()}) @@ -89,25 +97,25 @@ def profiles_import(): @bp.get("/preferences") def prefs_get(): - return ok({"preferences": preferences.get_preferences()}) + return ok({"preferences": preferences.get_preferences(profile_id=request_profile_id())}) @bp.post("/preferences") def prefs_save(): - return ok({"preferences": preferences.save_preferences(request.json or {})}) + return ok({"preferences": preferences.save_preferences(request.json or {}, profile_id=request_profile_id(require_write=True))}) @bp.post("/preferences/table-columns/recommended") def prefs_table_columns_recommended(): # Note: Applies the backend-owned recommended desktop and mobile column layout. - return ok({"preferences": preferences.apply_recommended_table_columns()}) + return ok({"preferences": preferences.apply_recommended_table_columns(profile_id=request_profile_id(require_write=True))}) @bp.get("/labels") def labels_list(): - profile = preferences.active_profile() + profile = request_profile() pid = profile["id"] if profile else None if not pid: return ok({"labels": []}) @@ -128,7 +136,7 @@ def labels_list(): @bp.post("/labels") def labels_save(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -150,7 +158,7 @@ def labels_save(): @bp.delete("/labels/") def labels_delete(label_id: int): - profile = preferences.active_profile() + profile = request_profile() pid = profile["id"] if profile else None if not pid or not auth.can_write_profile(int(pid), default_user_id()): return jsonify({"ok": False, "error": "No write access to profile"}), 403 @@ -162,7 +170,7 @@ def labels_delete(label_id: int): @bp.get("/ratio-groups") def ratio_groups_list(): - profile = preferences.active_profile() + profile = request_profile() pid = profile["id"] if profile else None with connect() as conn: rows = conn.execute( @@ -182,7 +190,7 @@ def ratio_groups_list(): @bp.post("/ratio-groups") def ratio_groups_save(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -212,7 +220,7 @@ def ratio_groups_save(): @bp.post("/ratio-groups/check") def ratio_groups_check(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 return ok({"result": ratio_rules.check(profile, default_user_id())}) diff --git a/pytorrent/routes/rss.py b/pytorrent/routes/rss.py index 7bf984c..d96da64 100644 --- a/pytorrent/routes/rss.py +++ b/pytorrent/routes/rss.py @@ -4,7 +4,7 @@ from ._shared import * def _active_profile_or_400(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return None return profile @@ -117,7 +117,7 @@ def rss_rule_test(): @bp.post("/rss/check") def rss_check(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 return ok(rss_service.check(profile, only_due=False)) diff --git a/pytorrent/routes/smart_queue.py b/pytorrent/routes/smart_queue.py index 63529cb..a1646bd 100644 --- a/pytorrent/routes/smart_queue.py +++ b/pytorrent/routes/smart_queue.py @@ -5,7 +5,7 @@ from ._shared import * @bp.get('/smart-queue') def smart_queue_get(): from ..services import smart_queue - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({'settings': {}, 'exclusions': [], 'error': 'No profile'}) try: @@ -23,7 +23,7 @@ def smart_queue_get(): @bp.post('/smart-queue') def smart_queue_save(): from ..services import smart_queue - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({'settings': {}, 'error': 'No profile'}) try: @@ -37,7 +37,7 @@ def smart_queue_save(): @bp.post('/smart-queue/check') def smart_queue_check(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({'result': {'ok': False, 'error': 'No profile'}}) if str(request.args.get('sync') or '').lower() in {'1', 'true', 'yes'}: @@ -66,7 +66,7 @@ def smart_queue_check(): @bp.post('/smart-queue/exclusion') def smart_queue_exclusion(): from ..services import smart_queue - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 data = request.get_json(silent=True) or {} @@ -79,7 +79,7 @@ def smart_queue_exclusion(): @bp.delete('/smart-queue/history') def smart_queue_history_clear(): from ..services import smart_queue - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index 99ab086..30982c6 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -7,7 +7,7 @@ from ..services.frontend_assets import static_hash @bp.get("/system/disk") def system_disk(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}) try: @@ -19,7 +19,7 @@ def system_disk(): @bp.get("/system/status") def system_status(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}) try: @@ -80,7 +80,7 @@ def health_check_nagios(): @bp.get("/app/status") def app_status(): started = time.perf_counter() - profile = preferences.active_profile() + profile = request_profile() proc = psutil.Process(os.getpid()) try: jobs = list_jobs(10, 0) @@ -178,7 +178,7 @@ def cleanup_status(): @bp.post("/cleanup/cache") def cleanup_profile_cache(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 profile_id = int(profile["id"]) @@ -225,7 +225,7 @@ def cleanup_database_vacuum(): @bp.post("/cleanup/smart-queue") def cleanup_smart_queue(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 profile_id = int(profile["id"]) @@ -243,7 +243,7 @@ def cleanup_smart_queue(): @bp.post("/cleanup/operation-logs") def cleanup_operation_logs(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 # Note: Operation log cleanup removes only profile-scoped log entries; torrents, jobs and settings stay intact. @@ -254,7 +254,7 @@ def cleanup_operation_logs(): @bp.post("/cleanup/planner") def cleanup_planner(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 # Note: Planner cleanup removes only the active profile action history, not saved Planner settings. @@ -264,7 +264,7 @@ def cleanup_planner(): @bp.post("/cleanup/automations") def cleanup_automations(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 profile_id = int(profile["id"]) @@ -284,7 +284,7 @@ def cleanup_automations(): @bp.post("/cleanup/poller-diagnostics") def cleanup_poller_diagnostics(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 profile_id = int(profile["id"]) @@ -295,7 +295,7 @@ def cleanup_poller_diagnostics(): @bp.post("/cleanup/all") def cleanup_all(): deleted_jobs = clear_jobs() - active_profile = preferences.active_profile() + active_profile = request_profile() active_profile_id = int(active_profile["id"]) if active_profile else 0 deleted_logs = operation_logs.clear(active_profile_id) if active_profile_id else 0 deleted_planner = download_planner.clear_history(active_profile_id) if active_profile_id else 0 @@ -371,7 +371,7 @@ def _annotate_path_directories(profile: dict, payload: dict) -> dict: @bp.get("/path/default") def path_default(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -383,7 +383,7 @@ def path_default(): @bp.get("/path/browse") def path_browse(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 base = request.args.get("path") or "" @@ -395,7 +395,7 @@ def path_browse(): @bp.post("/path/directories") def path_directory_create(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 require_profile_write(profile.get("id")) @@ -410,7 +410,7 @@ def path_directory_create(): @bp.post("/path/directories/rename") def path_directory_rename(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 require_profile_write(profile.get("id")) @@ -429,7 +429,7 @@ def path_directory_rename(): @bp.get('/rtorrent-config') def rtorrent_config_get(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -440,7 +440,7 @@ def rtorrent_config_get(): @bp.post('/rtorrent-config') def rtorrent_config_save(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -457,7 +457,7 @@ def rtorrent_config_save(): @bp.post('/rtorrent-config/reset') def rtorrent_config_reset(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -468,7 +468,7 @@ def rtorrent_config_reset(): @bp.post('/rtorrent-config/generate') def rtorrent_config_generate(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({'ok': False, 'error': 'No profile'}), 400 try: @@ -481,7 +481,7 @@ def rtorrent_config_generate(): @bp.get('/traffic/history') def traffic_history_get(): from ..services import traffic_history - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}}) range_name = request.args.get('range') or '7d' diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index e9d839e..146e094 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -7,7 +7,7 @@ from ..services.reverse_dns import attach_reverse_dns @bp.get("/torrents") def torrents(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"}) rows = torrent_cache.snapshot(profile["id"]) @@ -23,7 +23,7 @@ def torrents(): @bp.get("/trackers/summary") def trackers_summary(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"}) try: @@ -78,7 +78,7 @@ def tracker_favicon_query(): @bp.get("/torrent-stats") def torrent_stats_get(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return ok({"stats": {}, "error": "No profile"}) force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"} @@ -92,7 +92,7 @@ def torrent_stats_get(): @bp.get("/torrents//files") def torrent_files(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 return ok({"files": rtorrent.torrent_files(profile, torrent_hash)}) @@ -101,7 +101,7 @@ def torrent_files(torrent_hash: str): @bp.get("/torrents//files//mediainfo") def torrent_file_media_info(torrent_hash: str, file_index: int): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -124,7 +124,7 @@ def torrent_file_media_info(torrent_hash: str, file_index: int): @bp.post("/torrents//files/priority") def torrent_file_priority(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -139,7 +139,7 @@ def torrent_file_priority(torrent_hash: str): @bp.get("/torrents//files/tree") def torrent_file_tree(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)}) @@ -148,7 +148,7 @@ def torrent_file_tree(torrent_hash: str): @bp.post("/torrents//files/folder-priority") def torrent_folder_priority(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -214,7 +214,7 @@ def _send_staged_file(profile: dict, path: str, download_name: str, local: bool @bp.post("/torrents//files//download-link") def torrent_file_download_link(torrent_hash: str, file_index: int): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -238,7 +238,7 @@ def torrent_file_download_link_from_body(torrent_hash: str): @bp.post("/torrents//files/download.zip/link") def torrent_files_download_zip_link(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -254,7 +254,7 @@ def torrent_files_download_zip_link(torrent_hash: str): @bp.get("/torrents//torrent-file/link") def torrent_file_export_link(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -267,7 +267,7 @@ def torrent_file_export_link(torrent_hash: str): @bp.post("/torrents/torrent-files.zip/link") def torrent_files_export_zip_link(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -284,7 +284,7 @@ def torrent_files_export_zip_link(): @bp.get("/torrents//files//download") def torrent_file_download(torrent_hash: str, file_index: int): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -377,7 +377,7 @@ def _stream_torrent_files_zip(profile: dict, items: list[dict]): @bp.post("/torrents//files/download.zip") def torrent_files_download_zip(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -393,7 +393,7 @@ def torrent_files_download_zip(torrent_hash: str): @bp.get("/torrents//torrent-file") def torrent_file_export(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -406,7 +406,7 @@ def torrent_file_export(torrent_hash: str): @bp.post("/torrents/torrent-files.zip") def torrent_files_export_zip(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -455,7 +455,7 @@ def torrent_files_export_zip(): @bp.get("/torrents//chunks") def torrent_chunks(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -467,7 +467,7 @@ def torrent_chunks(torrent_hash: str): @bp.post("/torrents//chunks/") def torrent_chunk_action(torrent_hash: str, action_name: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -480,7 +480,7 @@ def torrent_chunk_action(torrent_hash: str, action_name: str): @bp.get("/torrents//peers") def torrent_peers(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 peers = rtorrent.torrent_peers(profile, torrent_hash) @@ -496,7 +496,7 @@ def torrent_peers(torrent_hash: str): @bp.get("/torrents//trackers") def torrent_trackers(torrent_hash: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)}) @@ -505,7 +505,7 @@ def torrent_trackers(torrent_hash: str): @bp.post("/torrents//trackers/") def torrent_tracker_action(torrent_hash: str, action_name: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 try: @@ -518,7 +518,7 @@ def torrent_tracker_action(torrent_hash: str, action_name: str): @bp.post("/torrents/") def torrent_action(action_name: str): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} @@ -547,7 +547,7 @@ def torrent_action(action_name: str): @bp.post("/torrents/create") def torrent_create(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {}) @@ -577,7 +577,7 @@ def torrent_create(): @bp.post("/torrents/add") def torrent_add(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 job_ids = [] @@ -634,7 +634,7 @@ def torrent_add(): @bp.post("/torrents/preview") def torrent_preview(): - profile = preferences.active_profile() + profile = request_profile() existing_hashes = set() if profile: try: @@ -664,7 +664,7 @@ def torrent_preview(): @bp.post("/speed/limits") def speed_limits(): - profile = preferences.active_profile() + profile = request_profile() if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 data = request.get_json(silent=True) or {} diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index b04ed65..535eb57 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -728,12 +728,45 @@ def install_guards(app) -> None: def _request_profile_id() -> int | None: if request.view_args and request.view_args.get("profile_id"): return int(request.view_args["profile_id"]) + payload = {} try: payload = request.get_json(silent=True) or {} - if payload.get("profile_id"): - return int(payload.get("profile_id")) except Exception: - pass + payload = {} + raw_id = ( + request.args.get("profile_id") + or request.form.get("profile_id") + or payload.get("profile_id") + or request.headers.get("X-PyTorrent-Profile-Id") + ) + if raw_id not in (None, ""): + try: + return int(raw_id) + except (TypeError, ValueError): + return None + raw_name = ( + request.args.get("profile_name") + or request.form.get("profile_name") + or payload.get("profile_name") + or request.headers.get("X-PyTorrent-Profile-Name") + ) + if raw_name: + from . import preferences + visible = visible_profile_ids(current_user_id()) + with connect() as conn: + if visible is None: + row = conn.execute("SELECT id FROM rtorrent_profiles WHERE lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", (str(raw_name).strip(),)).fetchone() + elif visible: + placeholders = ",".join("?" for _ in visible) + row = conn.execute( + f"SELECT id FROM rtorrent_profiles WHERE id IN ({placeholders}) AND lower(name)=lower(?) ORDER BY is_default DESC, id LIMIT 1", + (*tuple(visible), str(raw_name).strip()), + ).fetchone() + else: + row = None + return int(row["id"]) if row else None from . import preferences profile = preferences.active_profile() - return int(profile["id"]) if profile else None + if profile: + return int(profile["id"]) + return 1 if can_access_profile(1) else None diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 5afef88..2742a6b 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -175,8 +175,8 @@ def create_app_backup(name: str, user_id: int | None = None, automatic: bool = F def create_profile_backup(name: str, profile_id: int, user_id: int | None = None, automatic: bool = False) -> dict: user_id = user_id or auth.current_user_id() or default_user_id() - if not auth.can_access_profile(profile_id, user_id): - raise PermissionError("No access to profile") + if not auth.can_write_profile(profile_id, user_id): + raise PermissionError("No write access to profile") payload = {"version": 2, "backup_type": "profile", "source_profile_id": int(profile_id), "created_at": utcnow(), "automatic": bool(automatic), "tables": {}} with connect() as conn: for table in PROFILE_BACKUP_TABLES: diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 5d40bdf..3b4dc97 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -491,9 +491,9 @@ def get_preferences(user_id: int | None = None, profile_id: int | None = None): merged.update(get_disk_monitor_preferences(profile_id, user_id)) return merged -def save_preferences(data: dict, user_id: int | None = None): +def save_preferences(data: dict, user_id: int | None = None, profile_id: int | None = None): user_id = user_id or auth.current_user_id() or default_user_id() - profile_id = _active_profile_id_for_user(user_id) + profile_id = profile_id or _active_profile_id_for_user(user_id) allowed_theme = data.get("theme") if data.get("theme") in {"light", "dark"} else None bootstrap_theme = data.get("bootstrap_theme") if data.get("bootstrap_theme") in BOOTSTRAP_THEMES else None font_family = data.get("font_family") if data.get("font_family") in FONT_FAMILIES else None diff --git a/pytorrent/static/js/authUsers.js b/pytorrent/static/js/authUsers.js index 9ff7b70..0c21338 100644 --- a/pytorrent/static/js/authUsers.js +++ b/pytorrent/static/js/authUsers.js @@ -1,2 +1,2 @@ // User management stays separate from Smart Queue logic. -export const authUsersSource = " function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : '
No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation must fail loudly when the token is already gone.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n function renderAuthProviderInfo(auth={}){\n const box=$('authProviderInfo');\n if(!box) return;\n const provider=auth.provider || window.PYTORRENT.authProvider || 'local';\n const isTinyAuth=provider==='tinyauth';\n const isExternal=!!auth.external || isTinyAuth || provider==='proxy';\n $('authPassword')?.classList.toggle('d-none', isTinyAuth);\n if(!isExternal){ box.classList.add('d-none'); box.innerHTML=''; return; }\n const permission=auth.auto_create_role==='admin' ? 'admin: all profiles with full access' : (auth.auto_create_permission==='none' ? 'user: no profile access' : `user: ${auth.auto_create_permission==='full'?'Full':'R/O'} on all profiles`);\n const lines=[\n `Authentication provider: ${provider==='tinyauth'?'TinyAuth':'proxy header'}`,\n auth.auto_create ? `Auto-created users get ${permission}.` : 'Automatic user creation is disabled.',\n auth.bypass_enabled ? `Auth bypass enabled for ${auth.bypass_hosts?.length ? auth.bypass_hosts.join(', ') : 'configured hosts'} as ${auth.bypass_user || 'admin'}.` : 'Auth bypass is disabled.',\n isTinyAuth ? 'TinyAuth manages passwords; pyTorrent password changes are hidden.' : ''\n ].filter(Boolean);\n box.classList.remove('d-none');\n box.innerHTML=`
External authentication
    ${lines.map(line=>`
  • ${esc(line)}
  • `).join('')}
`;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n const authInfo=usersJson.auth||{};\n renderAuthProviderInfo(authInfo);\n if($('authProfile')) $('authProfile').innerHTML=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile '+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=`
`;\n return [esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?responsiveTable(['User','Role','Active','Profile rights','API tokens','Actions'],rows,'auth-users-table'):'
No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n if(!$('authPassword')?.classList.contains('d-none')) payload.password=$('authPassword')?.value||'';\n // Note: TinyAuth keeps passwords outside pyTorrent, so the hidden field is never submitted.\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n"; +export const authUsersSource = " function renderGeneratedToken(token){\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Generated tokens are shown inline to avoid stacking another modal over the Users panel.\n box.classList.remove('d-none');\n box.innerHTML=`
New API tokenThis token is shown once. Copy it now before refreshing the page.
`;\n $('authTokenInlineCopy')?.addEventListener('click',()=>copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy failed','danger')));\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n }\n function tokenRow(t,userId){\n const last=t.last_used_at ? humanDateCell(t.last_used_at) : 'never';\n return `
${esc(t.name||'API token')}${esc(t.token_prefix||'')} · created ${humanDateCell(t.created_at)} · last used ${last}
`;\n }\n async function showAuthTokens(userId){\n try{\n const j=await (await fetch(`/api/auth/users/${userId}/tokens`)).json();\n if(!j.ok) throw new Error(j.error||'Cannot load API tokens');\n const box=$('authTokenInline');\n if(!box) return;\n // Note: Token lists stay inline in Users to keep user management fast and avoid nested modals.\n const tokens=j.tokens||[];\n box.classList.remove('d-none');\n box.innerHTML=`
API tokensActive tokens for this user. Secrets are never shown after creation.
${tokens.length ? tokens.map(t=>tokenRow(t,userId)).join('') : '
No API tokens.
'}`;\n $('authTokenInlineClose')?.addEventListener('click',()=>box.classList.add('d-none'));\n box.querySelectorAll('.auth-token-delete').forEach(btn=>btn.addEventListener('click',async()=>{ if(!confirm('Delete this API token?')) return; await deleteAuthToken(btn.dataset.userId, btn.dataset.tokenId); await showAuthTokens(btn.dataset.userId); }));\n }catch(e){ toast(e.message,'danger'); }\n }\n async function deleteAuthToken(userId, tokenId){\n // Note: Token revocation must fail loudly when the token is already gone.\n const j=await post(`/api/auth/users/${userId}/tokens/${tokenId}`, {}, 'DELETE');\n toast('API token deleted','success');\n await loadAuthUsers();\n return j;\n }\n function renderAuthProviderInfo(auth={}){\n const box=$('authProviderInfo');\n if(!box) return;\n const provider=auth.provider || window.PYTORRENT.authProvider || 'local';\n const isTinyAuth=provider==='tinyauth';\n const isExternal=!!auth.external || isTinyAuth || provider==='proxy';\n $('authPassword')?.classList.toggle('d-none', isTinyAuth);\n if(!isExternal){ box.classList.add('d-none'); box.innerHTML=''; return; }\n const permission=auth.auto_create_role==='admin' ? 'admin: all profiles with full access' : (auth.auto_create_permission==='none' ? 'user: no profile access' : `user: ${auth.auto_create_permission==='full'?'Full':'R/O'} on all profiles`);\n const lines=[\n `Authentication provider: ${provider==='tinyauth'?'TinyAuth':'proxy header'}`,\n auth.auto_create ? `Auto-created users get ${permission}.` : 'Automatic user creation is disabled.',\n auth.bypass_enabled ? `Auth bypass enabled for ${auth.bypass_hosts?.length ? auth.bypass_hosts.join(', ') : 'configured hosts'} as ${auth.bypass_user || 'admin'}.` : 'Auth bypass is disabled.',\n isTinyAuth ? 'TinyAuth manages passwords; pyTorrent password changes are hidden.' : ''\n ].filter(Boolean);\n box.classList.remove('d-none');\n box.innerHTML=`
External authentication
    ${lines.map(line=>`
  • ${esc(line)}
  • `).join('')}
`;\n }\n async function loadAuthUsers(){\n if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return;\n const [usersRes, profilesRes]=await Promise.all([fetch('/api/auth/users'), fetch('/api/profiles')]);\n const usersJson=await usersRes.json();\n const profilesJson=await profilesRes.json();\n const profiles=profilesJson.profiles||[];\n const authInfo=usersJson.auth||{};\n renderAuthProviderInfo(authInfo);\n if($('authProfile')) $('authProfile').innerHTML=``+profiles.map(p=>``).join('');\n const rows=(usersJson.users||[]).map(u=>{\n const perms=(u.permissions||[]).map(p=>`${p.profile_id?('profile #'+p.profile_id):'all'}: ${p.access_level==='full'?'Full':'R/O'}`).join(', ') || (u.role==='admin'?'all: Full':'none');\n const tokenText=(u.api_tokens||0) ? `${u.api_tokens} active` : 'none';\n const actions=`
`;\n return [esc(u.id),esc(u.username),esc(u.role),u.is_active?'yes':'no',esc(perms),``,actions];\n });\n $('authUsersManager').innerHTML=rows.length?responsiveTable(['ID','User','Role','Active','Profile rights','API tokens','Actions'],rows,'auth-users-table'):'
No users.
';\n }\n async function generateAuthToken(userId){\n const name=prompt('Token name', 'API token');\n if(name===null) return;\n try{\n const j=await post(`/api/auth/users/${userId}/tokens`, {name:name||'API token'});\n const token=j.token?.token||'';\n renderGeneratedToken(token);\n await copyText(token).then(()=>toast('API token copied','success')).catch(()=>toast('Copy the API token from the Users panel','warning'));\n await loadAuthUsers();\n }catch(e){ toast(e.message,'danger'); }\n }\n function resetAuthUserForm(){ ['authUserId','authUsername','authPassword'].forEach(id=>{ if($(id)) $(id).value=''; }); if($('authRole')) $('authRole').value='user'; if($('authProfile')) $('authProfile').value='0'; if($('authAccess')) $('authAccess').value='ro'; if($('authActive')) $('authActive').checked=true; $('authUserCancelBtn')?.classList.add('d-none'); }\n function editAuthUser(user){ if(!user) return; if($('authUserId')) $('authUserId').value=user.id||''; if($('authUsername')) $('authUsername').value=user.username||''; if($('authPassword')) $('authPassword').value=''; if($('authRole')) $('authRole').value=user.role||'user'; if($('authActive')) $('authActive').checked=!!user.is_active; const perm=(user.permissions||[])[0]||{profile_id:0,access_level:'ro'}; if($('authProfile')) $('authProfile').value=String(perm.profile_id||0); if($('authAccess')) $('authAccess').value=perm.access_level||'ro'; $('authUserCancelBtn')?.classList.remove('d-none'); }\n async function saveAuthUser(){\n const id=$('authUserId')?.value||'';\n const role=$('authRole')?.value||'user';\n const payload={username:$('authUsername')?.value||'',role,is_active:!!$('authActive')?.checked,permissions:role==='admin'?[]:[{profile_id:Number($('authProfile')?.value||0),access_level:$('authAccess')?.value||'ro'}]};\n if(!$('authPassword')?.classList.contains('d-none')) payload.password=$('authPassword')?.value||'';\n // Note: TinyAuth keeps passwords outside pyTorrent, so the hidden field is never submitted.\n try{ await post(id?`/api/auth/users/${id}`:'/api/auth/users',payload,id?'PUT':'POST'); toast('User saved','success'); resetAuthUserForm(); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); }\n }\n"; diff --git a/pytorrent/static/js/profileList.js b/pytorrent/static/js/profileList.js index 5b192ee..7adbc61 100644 --- a/pytorrent/static/js/profileList.js +++ b/pytorrent/static/js/profileList.js @@ -1 +1 @@ -export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` · slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
`; }).join('')||'No profiles.'; }\n"; +export const profileListSource = " function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` · slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; const backupBadge=p.profile_backup_enabled?` profile backup on`:''; return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${esc(st)}ID ${esc(p.id)} · ${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
`; }).join('')||'No profiles.'; }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 524f64d..4247025 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -5884,3 +5884,15 @@ body.compact-torrent-list .mobile-progress .torrent-progress { font-size: 0.82rem; gap: 0.45rem 0.85rem; } + +.profile-id-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.35rem; + border: 1px solid var(--bs-border-color); + border-radius: 999px; + color: var(--bs-secondary-color); + font-family: var(--bs-font-monospace); + font-size: 0.72rem; + font-weight: 600; +} diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 3ff4c03..b6e227d 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -168,7 +168,7 @@