From a73aeb5544f1738a4567bd8b286574c1e5424b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 19:46:16 +0200 Subject: [PATCH 1/6] profile id support in api requests --- pytorrent/openapi/openapi.json | 1705 ++++++++++++++++++++++++++-- pytorrent/routes/_shared.py | 74 +- pytorrent/routes/automations.py | 16 +- pytorrent/routes/backup.py | 10 +- pytorrent/routes/operation_logs.py | 2 +- pytorrent/routes/planner.py | 2 +- pytorrent/routes/profiles.py | 28 +- pytorrent/routes/rss.py | 4 +- pytorrent/routes/smart_queue.py | 10 +- pytorrent/routes/system.py | 38 +- pytorrent/routes/torrents.py | 52 +- pytorrent/services/auth.py | 41 +- pytorrent/services/backup.py | 4 +- pytorrent/services/preferences.py | 4 +- pytorrent/static/js/authUsers.js | 2 +- pytorrent/static/js/profileList.js | 2 +- pytorrent/static/styles.css | 12 + pytorrent/templates/index.html | 2 +- 18 files changed, 1823 insertions(+), 185 deletions(-) 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 @@ From d5fa689dad7f3cc5ca957cf0656f45d76c182c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 22:28:39 +0200 Subject: [PATCH 2/6] fix in u --- pytorrent/routes/planner.py | 3 ++- pytorrent/services/poller_control.py | 13 ++++++++++++- pytorrent/static/js/appStatus.js | 2 +- pytorrent/static/js/planner.js | 2 +- pytorrent/static/js/pollerSettings.js | 2 +- pytorrent/static/js/profileList.js | 2 +- pytorrent/static/js/profiles.js | 2 +- pytorrent/static/js/toolsModal.js | 2 +- pytorrent/static/styles.css | 10 ++++++++++ 9 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index d113ff9..e2412a3 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -95,7 +95,8 @@ def poller_settings_get(): if error: return error pid = int(profile["id"]) - return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)}) + settings = poller_control.get_settings(pid) + return ok({"settings": settings, "runtime": poller_control.snapshot(pid, settings)}) @bp.post("/poller/settings") diff --git a/pytorrent/services/poller_control.py b/pytorrent/services/poller_control.py index 32e386a..5a0067e 100644 --- a/pytorrent/services/poller_control.py +++ b/pytorrent/services/poller_control.py @@ -385,9 +385,20 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool return dict(state.stats) -def snapshot(profile_id: int) -> dict: +def snapshot(profile_id: int, settings: dict | None = None) -> dict: state = state_for(profile_id) + effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id) data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count}) + runtime_ready = bool(state.stats) or state.tick_count > 0 + # Note: Snapshot includes saved intervals even before the first runtime tick so diagnostics never render as an empty zero-only panel. + data.setdefault("runtime_ready", runtime_ready) + data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))) + data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting")) + data.setdefault("live_stats_interval_seconds", effective_live_interval(effective_settings, state)) + data.setdefault("torrent_list_interval_seconds", effective_list_interval(effective_settings, state)) + data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS) + if not runtime_ready: + data["last_ok"] = None # Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats. data.update({ "live_poll_count": state.live_poll_count, diff --git a/pytorrent/static/js/appStatus.js b/pytorrent/static/js/appStatus.js index bf760a8..96a5649 100644 --- a/pytorrent/static/js/appStatus.js +++ b/pytorrent/static/js/appStatus.js @@ -1 +1 @@ -export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'),\n diagCard('Mode', rt.adaptive_mode||'-'),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', rt.rtorrent_call_count||0)\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';"; +export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerPending=runtimeReady?'':'waiting';\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', pollerPending || rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal')),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', pollerPending || `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', pollerPending || `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', pollerPending || fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', pollerPending || (rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';"; diff --git a/pytorrent/static/js/planner.js b/pytorrent/static/js/planner.js index ae348f6..6ec8aac 100644 --- a/pytorrent/static/js/planner.js +++ b/pytorrent/static/js/planner.js @@ -1 +1 @@ -export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/pollerSettings.js b/pytorrent/static/js/pollerSettings.js index 8fbe08d..d35164f 100644 --- a/pytorrent/static/js/pollerSettings.js +++ b/pytorrent/static/js/pollerSettings.js @@ -1 +1 @@ -export const pollerSettingsSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||3),idle_interval_seconds:Number($('pollerIdle')?.value||15),error_interval_seconds:Number($('pollerError')?.value||30),live_stats_interval_seconds:Number($('pollerLiveStats')?.value||3),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||30),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n const POLLER_SAFE_BASELINE={pollerActive:3,pollerIdle:15,pollerError:30,pollerLiveStats:3,pollerTorrentList:30,pollerSystem:5,pollerTracker:300,pollerDisk:60,pollerQueue:15,pollerHeartbeat:15};\n const POLLER_SAFE_LABELS={pollerActive:'Active interval',pollerIdle:'Idle interval',pollerError:'Error interval',pollerLiveStats:'Live stats',pollerTorrentList:'Torrent list',pollerSystem:'System stats',pollerTracker:'Tracker stats',pollerDisk:'Disk stats',pollerQueue:'Queue/job stats',pollerHeartbeat:'Heartbeat'};\n function pollerInputNumber(id){ return Number($(id)?.value||0); }\n function updatePollerSafeFallbackPreview(){ const box=$('pollerSafeFallbackPreview'); if(!box) return; const enabled=$('pollerSafeFallback')?.checked!==false; const changes=Object.entries(POLLER_SAFE_BASELINE).filter(([id,min])=>pollerInputNumber(id)>0 && pollerInputNumber(id)`${POLLER_SAFE_LABELS[id]} ${esc(pollerInputNumber(id))}s → ${esc(min)}s`); if(!enabled){ box.innerHTML='Safe fallback mode is off. Saved values can use the normal backend limits, including more aggressive intervals.'; return; } box.innerHTML=changes.length?`Safe fallback will change: ${changes.join(', ')}. Other values stay unchanged.`:'Safe fallback is on. Current values are already within the safe baseline, so saving will keep them unchanged.'; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); updatePollerSafeFallbackPreview(); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??3); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??15); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??30); $('pollerLiveStats')&&($('pollerLiveStats').value=st.live_stats_interval_seconds??3); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??30); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??5); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??300); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||60); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??15); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??15); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??8000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??2); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); updatePollerSafeFallbackPreview(); }\n function pollerDiagnosticItem(label,value){ return `${esc(label)}: ${value}`; }\n function pollerDiagnosticGroup(title,items){ return `${esc(title)}${items.join('')}`; }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; const live=[pollerDiagnosticItem('Polls',esc(rt.live_poll_count||0)),pollerDiagnosticItem('Last',`${esc(rt.last_live_duration_ms||0)} ms`),pollerDiagnosticItem('Updated',esc(rt.last_live_updated_count||0)),pollerDiagnosticItem('Full refresh',rt.last_live_requires_full_refresh?'yes':'no'),pollerDiagnosticItem('Interval',`${esc(rt.live_stats_interval_seconds||rt.effective_interval_seconds||0)}s`)]; const full=[pollerDiagnosticItem('Polls',esc(rt.list_poll_count||0)),pollerDiagnosticItem('Last',`${esc(rt.last_list_duration_ms||0)} ms`),pollerDiagnosticItem('Added/updated/removed',`${esc(rt.last_list_added_count||0)}/${esc(rt.last_list_updated_count||0)}/${esc(rt.last_list_removed_count||0)}`),pollerDiagnosticItem('Interval',`${esc(rt.torrent_list_interval_seconds||0)}s`)]; const runtime=[pollerDiagnosticItem('Duration',`${esc(rt.duration_ms||rt.last_tick_ms||0)} ms`),pollerDiagnosticItem('Gap',`${esc(rt.last_tick_gap_ms||0)} ms`),pollerDiagnosticItem('Payload',esc(fmtBytes(rt.emitted_payload_size||0))),pollerDiagnosticItem('rTorrent calls',esc(rt.rtorrent_call_count||0)),pollerDiagnosticItem('Skipped',esc(rt.skipped_emissions||0)),pollerDiagnosticItem('Ticks',esc(rt.tick_count||0))]; const state=[pollerDiagnosticItem('Mode',esc(mode)),pollerDiagnosticItem('Adaptive',adaptive?'on':'off'),pollerDiagnosticItem('OK',rt.last_ok?'yes':'no'),pollerDiagnosticItem('Minimum',`${esc(rt.configured_min_interval_seconds||0)}s`)]; return [pollerDiagnosticGroup('Live poller',live),pollerDiagnosticGroup('Full poller',full),pollerDiagnosticGroup('Runtime',runtime),pollerDiagnosticGroup('State',state)].join(''); }\n async function loadPollerSettings(){ "; +export const pollerSettingsSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||3),idle_interval_seconds:Number($('pollerIdle')?.value||15),error_interval_seconds:Number($('pollerError')?.value||30),live_stats_interval_seconds:Number($('pollerLiveStats')?.value||3),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||30),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n let lastPollerRuntime = {};\n function hasPollerRuntime(rt={}){ return !!(rt && (rt.runtime_ready || Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0)); }\n function currentPollerSettingsFromForm(){ return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,live_stats_interval_seconds:Number($('pollerLiveStats')?.value||3),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||30),configured_min_interval_seconds:Number($('pollerActive')?.min||0.5)}; }\n function pollerRuntimeWithSettings(st={},rt={}){\n // Note: Manual refresh can arrive before the backend tick rebuilds counters; keep the last live runtime in that case.\n const runtime = hasPollerRuntime(rt) ? rt : (hasPollerRuntime(lastPollerRuntime) ? lastPollerRuntime : (rt || {}));\n const merged = {...runtime};\n if(merged.adaptive_enabled===undefined) merged.adaptive_enabled = st.adaptive_enabled;\n if(!Number(merged.live_stats_interval_seconds||0)) merged.live_stats_interval_seconds = st.live_stats_interval_seconds;\n if(!Number(merged.torrent_list_interval_seconds||0)) merged.torrent_list_interval_seconds = st.torrent_list_interval_seconds;\n if(!Number(merged.configured_min_interval_seconds||0)) merged.configured_min_interval_seconds = 0.5;\n if(!hasPollerRuntime(merged)) merged.runtime_ready = false;\n if(hasPollerRuntime(merged)) lastPollerRuntime = {...merged};\n return merged;\n }\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const pending=rt.runtime_ready===false && !hasPollerRuntime(rt); const mode=pending?'waiting':(adaptive?(rt.adaptive_mode||'normal'):'fixed'); badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'||mode==='waiting'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n const POLLER_SAFE_BASELINE={pollerActive:3,pollerIdle:15,pollerError:30,pollerLiveStats:3,pollerTorrentList:30,pollerSystem:5,pollerTracker:300,pollerDisk:60,pollerQueue:15,pollerHeartbeat:15};\n const POLLER_SAFE_LABELS={pollerActive:'Active interval',pollerIdle:'Idle interval',pollerError:'Error interval',pollerLiveStats:'Live stats',pollerTorrentList:'Torrent list',pollerSystem:'System stats',pollerTracker:'Tracker stats',pollerDisk:'Disk stats',pollerQueue:'Queue/job stats',pollerHeartbeat:'Heartbeat'};\n function pollerInputNumber(id){ return Number($(id)?.value||0); }\n function updatePollerSafeFallbackPreview(){ const box=$('pollerSafeFallbackPreview'); if(!box) return; const enabled=$('pollerSafeFallback')?.checked!==false; const changes=Object.entries(POLLER_SAFE_BASELINE).filter(([id,min])=>pollerInputNumber(id)>0 && pollerInputNumber(id)`${POLLER_SAFE_LABELS[id]} ${esc(pollerInputNumber(id))}s \u2192 ${esc(min)}s`); if(!enabled){ box.innerHTML='Safe fallback mode is off. Saved values can use the normal backend limits, including more aggressive intervals.'; return; } box.innerHTML=changes.length?`Safe fallback will change: ${changes.join(', ')}. Other values stay unchanged.`:'Safe fallback is on. Current values are already within the safe baseline, so saving will keep them unchanged.'; }\n function fillPoller(st,rt){ const settings=st || currentPollerSettingsFromForm(); const merged=pollerRuntimeWithSettings(settings,rt||{}); if(!st){ if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if($('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); updatePollerSafeFallbackPreview(); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!settings.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=settings.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=settings.active_interval_seconds??3); $('pollerIdle')&&($('pollerIdle').value=settings.idle_interval_seconds??15); $('pollerError')&&($('pollerError').value=settings.error_interval_seconds??30); $('pollerLiveStats')&&($('pollerLiveStats').value=settings.live_stats_interval_seconds??3); $('pollerTorrentList')&&($('pollerTorrentList').value=settings.torrent_list_interval_seconds??30); $('pollerSystem')&&($('pollerSystem').value=settings.system_stats_interval_seconds??5); $('pollerTracker')&&($('pollerTracker').value=settings.tracker_stats_interval_seconds??300); $('pollerDisk')&&($('pollerDisk').value=settings.disk_stats_interval_seconds||60); $('pollerQueue')&&($('pollerQueue').value=settings.queue_stats_interval_seconds??15); $('pollerHeartbeat')&&($('pollerHeartbeat').value=settings.heartbeat_interval_seconds??15); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=settings.slow_response_threshold_ms??8000); $('pollerSlowdown')&&($('pollerSlowdown').value=settings.slowdown_multiplier??2); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=settings.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); updatePollerSafeFallbackPreview(); }\n function pollerDiagnosticItem(label,value){ return `${esc(label)}: ${value}`; }\n function pollerDiagnosticGroup(title,items){ return `${esc(title)}${items.join('')}`; }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const pending=rt.runtime_ready===false && !hasPollerRuntime(rt); const mode=pending?'waiting':(adaptive?(rt.adaptive_mode||'normal'):'fixed interval'); const duration=pending?'waiting':`${esc(rt.duration_ms||rt.last_tick_ms||0)} ms`; const gap=pending?'waiting':`${esc(rt.last_tick_gap_ms||0)} ms`; const calls=pending?'waiting':esc(rt.rtorrent_call_count||0); const payload=pending?'waiting':esc(fmtBytes(rt.emitted_payload_size||0)); const live=[pollerDiagnosticItem('Polls',esc(rt.live_poll_count||0)),pollerDiagnosticItem('Last',pending?'waiting':`${esc(rt.last_live_duration_ms||0)} ms`),pollerDiagnosticItem('Updated',esc(rt.last_live_updated_count||0)),pollerDiagnosticItem('Full refresh',rt.last_live_requires_full_refresh?'yes':'no'),pollerDiagnosticItem('Interval',`${esc(rt.live_stats_interval_seconds||rt.effective_interval_seconds||0)}s`)]; const full=[pollerDiagnosticItem('Polls',esc(rt.list_poll_count||0)),pollerDiagnosticItem('Last',pending?'waiting':`${esc(rt.last_list_duration_ms||0)} ms`),pollerDiagnosticItem('Added/updated/removed',`${esc(rt.last_list_added_count||0)}/${esc(rt.last_list_updated_count||0)}/${esc(rt.last_list_removed_count||0)}`),pollerDiagnosticItem('Interval',`${esc(rt.torrent_list_interval_seconds||0)}s`)]; const runtime=[pollerDiagnosticItem('Duration',duration),pollerDiagnosticItem('Gap',gap),pollerDiagnosticItem('Payload',payload),pollerDiagnosticItem('rTorrent calls',calls),pollerDiagnosticItem('Skipped',esc(rt.skipped_emissions||0)),pollerDiagnosticItem('Ticks',esc(rt.tick_count||0))]; const state=[pollerDiagnosticItem('Mode',esc(mode)),pollerDiagnosticItem('Adaptive',adaptive?'on':'off'),pollerDiagnosticItem('OK',pending?'waiting':(rt.last_ok?'yes':'no')),pollerDiagnosticItem('Minimum',`${esc(rt.configured_min_interval_seconds||0)}s`)]; return [pollerDiagnosticGroup('Live poller',live),pollerDiagnosticGroup('Full poller',full),pollerDiagnosticGroup('Runtime',runtime),pollerDiagnosticGroup('State',state)].join(''); }\n async function loadPollerSettings(){ "; diff --git a/pytorrent/static/js/profileList.js b/pytorrent/static/js/profileList.js index 7adbc61..41fc4cf 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; 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"; +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?` \u00b7 ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; const backupBadge=p.profile_backup_enabled?` `:''; return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${esc(st)}ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
`; }).join('')||'No profiles.'; }\n"; diff --git a/pytorrent/static/js/profiles.js b/pytorrent/static/js/profiles.js index 5d47b01..0f3b4dc 100644 --- a/pytorrent/static/js/profiles.js +++ b/pytorrent/static/js/profiles.js @@ -1 +1 @@ -export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n 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?` \u00b7 ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` \u00b7 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)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=profileDiagnosticStatusLabel(d.status || (d.ok?'normal':'error')); const cls=profileDiagnosticStatusClass(status); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',`${esc(status)}`],['rTorrent',esc(d.version||'-')],['Library',esc(d.library_version||'-')],['Response',d.response_time_ms!=null?`${esc(d.response_time_ms)} ms`:'-'],['Slow threshold',d.slow_threshold_ms!=null?`${esc(d.slow_threshold_ms)} ms`:'-'],['Default path',esc(paths.default_directory||'-')],['CWD',esc(paths.cwd||'-')],['Write',esc(Object.values(wp)[0]||'-')],['Free disk',esc(firstDisk.free_h||firstDisk.error||'-')]]; box.classList.remove('text-muted'); box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${v}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n restoreProfileScopedViewPrefs();\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n clearProfileScopedTorrentView('Loading torrents...');\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; +export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n 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?` \u00b7 ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; const backupBadge=p.profile_backup_enabled?` `:''; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${esc(st)}${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
`; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=profileDiagnosticStatusLabel(d.status || (d.ok?'normal':'error')); const cls=profileDiagnosticStatusClass(status); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',`${esc(status)}`],['rTorrent',esc(d.version||'-')],['Library',esc(d.library_version||'-')],['Response',d.response_time_ms!=null?`${esc(d.response_time_ms)} ms`:'-'],['Slow threshold',d.slow_threshold_ms!=null?`${esc(d.slow_threshold_ms)} ms`:'-'],['Default path',esc(paths.default_directory||'-')],['CWD',esc(paths.cwd||'-')],['Write',esc(Object.values(wp)[0]||'-')],['Free disk',esc(firstDisk.free_h||firstDisk.error||'-')]]; box.classList.remove('text-muted'); box.innerHTML=`
${cards.map(([k,v])=>`
${esc(k)}${v}
`).join('')}
${d.error?`
${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n restoreProfileScopedViewPrefs();\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n clearProfileScopedTorrentView('Loading torrents...');\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=`
${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='
Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=`
${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `
Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open.
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `
Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n"; diff --git a/pytorrent/static/js/toolsModal.js b/pytorrent/static/js/toolsModal.js index 26a84db..a20890c 100644 --- a/pytorrent/static/js/toolsModal.js +++ b/pytorrent/static/js/toolsModal.js @@ -1 +1 @@ -export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); "; +export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),j.runtime||null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); "; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 4247025..5b15e3e 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -5885,6 +5885,16 @@ body.compact-torrent-list .mobile-progress .torrent-progress { gap: 0.45rem 0.85rem; } + +.profile-backup-icon { + align-items: center; + color: var(--bs-info); + display: inline-flex; + font-size: 0.82rem; + margin-left: 0.25rem; + vertical-align: middle; +} + .profile-id-badge { display: inline-flex; align-items: center; From 03ce088d24de57b3e293fdbd6c07bd223a2c9a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 22:45:44 +0200 Subject: [PATCH 3/6] fix in u --- pytorrent/routes/planner.py | 1 + pytorrent/routes/profiles.py | 16 ++++++++++++++++ pytorrent/routes/system.py | 6 ++++++ pytorrent/static/js/appStatus.js | 2 +- pytorrent/static/js/diagnosticsDashboard.js | 2 +- pytorrent/static/js/profileList.js | 2 +- pytorrent/static/js/ratioTools.js | 2 +- pytorrent/static/styles.css | 2 +- pytorrent/templates/index.html | 2 +- 9 files changed, 29 insertions(+), 6 deletions(-) diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index e2412a3..a1bc924 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -2,6 +2,7 @@ from __future__ import annotations from flask import Blueprint, jsonify, request +from ._shared import request_profile from ..services import preferences, download_planner, poller_control from ..services.auth import current_user_id diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index 86f6835..9accebd 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -218,6 +218,22 @@ def ratio_groups_save(): +@bp.delete("/ratio-groups/") +def ratio_groups_delete(group_id: int): + profile = request_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + if not auth.can_write_profile(int(profile["id"]), default_user_id()): + return jsonify({"ok": False, "error": "No write access to profile"}), 403 + with connect() as conn: + # Note: Deleting a ratio group removes only the group definition and its assignment links; history stays as an audit trail. + deleted = conn.execute("DELETE FROM ratio_groups WHERE id=? AND profile_id=?", (int(group_id), int(profile["id"]))).rowcount + conn.execute("DELETE FROM ratio_assignments WHERE group_id=? AND profile_id=?", (int(group_id), int(profile["id"]))) + if not deleted: + return jsonify({"ok": False, "error": "Ratio group not found"}), 404 + return ratio_groups_list() + + @bp.post("/ratio-groups/check") def ratio_groups_check(): profile = request_profile() diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index 30982c6..aa1694a 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -120,6 +120,12 @@ def app_status(): status["speed_peaks"] = speed_peaks.current(profile["id"]) except Exception as exc: status["speed_peaks"] = {"error": str(exc)} + try: + # Note: App status carries poller settings and runtime so the panel still renders when the separate poller endpoint is unavailable. + poller_settings = poller_control.get_settings(int(profile["id"])) + status["poller"] = {"settings": poller_settings, "runtime": poller_control.snapshot(int(profile["id"]), poller_settings)} + except Exception as exc: + status["poller"] = {"settings": {}, "runtime": {}, "error": str(exc)} try: prefs = preferences.get_preferences() status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False) diff --git a/pytorrent/static/js/appStatus.js b/pytorrent/static/js/appStatus.js index 96a5649..495d21f 100644 --- a/pytorrent/static/js/appStatus.js +++ b/pytorrent/static/js/appStatus.js @@ -1 +1 @@ -export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerPending=runtimeReady?'':'waiting';\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', pollerPending || rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal')),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', pollerPending || `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', pollerPending || `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', pollerPending || fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', pollerPending || (rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';"; +export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,pollerResponse]=await Promise.all([\n fetch('/api/app/status',{cache:'no-store'}).then(r=>r.json()),\n fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()).catch(()=>({ok:false}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const pollerBundle=(pollerResponse && pollerResponse.ok!==false) ? pollerResponse : (st.poller||{});\n const rt=pollerBundle.runtime||{}, ps=pollerBundle.settings||{};\n // Note: App status uses embedded poller data as a fallback, so one failing endpoint cannot leave Runtime poller empty.\n const intervalValue=(runtimeKey,settingsKey)=>rt[runtimeKey] ?? ps[settingsKey] ?? '-';\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0 || Number(rt.last_tick_ms||0)>0);\n const waiting=!runtimeReady && rt.runtime_ready===false;\n const mode=waiting?'waiting':(rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal'));\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', mode),\n diagCard('Live interval', `${intervalValue('live_stats_interval_seconds','live_stats_interval_seconds')}s`),\n diagCard('List interval', `${intervalValue('torrent_list_interval_seconds','torrent_list_interval_seconds')}s`),\n diagCard('Last tick', waiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', waiting?'waiting':`${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', waiting?'waiting':fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', waiting?'waiting':(rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`
    ${panes.map((p,i)=>`
  • `).join('')}
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';"; \ No newline at end of file diff --git a/pytorrent/static/js/diagnosticsDashboard.js b/pytorrent/static/js/diagnosticsDashboard.js index 2cca75e..9406bee 100644 --- a/pytorrent/static/js/diagnosticsDashboard.js +++ b/pytorrent/static/js/diagnosticsDashboard.js @@ -1 +1 @@ -export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\n"; +export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerWaiting=!pollerReady && rt.runtime_ready===false;\n const pollerCards=[diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'), diagCard('Mode', pollerWaiting?'waiting':(rt.adaptive_mode||'-')), diagCard('Effective interval', `${rt.effective_interval_seconds??rt.live_stats_interval_seconds??ps.live_stats_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', pollerWaiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', pollerWaiting?'waiting':`${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', pollerWaiting?'waiting':fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', pollerWaiting?'waiting':(rt.rtorrent_call_count||0)), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\n"; diff --git a/pytorrent/static/js/profileList.js b/pytorrent/static/js/profileList.js index 41fc4cf..2897ef8 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?` \u00b7 ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; const backupBadge=p.profile_backup_enabled?` `:''; return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${esc(st)}ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 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(){\n const j=await (await fetch('/api/profiles')).json();\n profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p]));\n const active=String(j.active?.id ?? activeProfileId ?? '');\n const rows=j.profiles||[];\n const statusMap=new Map();\n try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){}\n $('profileList').innerHTML=rows.map(p=>{\n const d=statusMap.get(String(p.id))||{};\n const st=profileDiagnosticStatusLabel(d.status || 'unknown');\n const cls=profileDiagnosticStatusClass(st);\n const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:'';\n const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:'';\n const isActive=String(p.id)===active;\n const backupIcon=p.profile_backup_enabled?``:'';\n return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
${backupIcon}
`;\n }).join('')||'No profiles.';\n }\n"; \ No newline at end of file diff --git a/pytorrent/static/js/ratioTools.js b/pytorrent/static/js/ratioTools.js index 870b63e..84ac9e6 100644 --- a/pytorrent/static/js/ratioTools.js +++ b/pytorrent/static/js/ratioTools.js @@ -1 +1 @@ -export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`
Groups
${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.owner_name||'-'),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; +export const ratioToolsSource = " async function deleteRatioGroup(groupId, groupName){\n if(!groupId) return;\n if(!confirm(`Delete ratio group \"${groupName || groupId}\"? Assigned torrents will lose only this group link.`)) return;\n try{\n await post(`/api/ratio-groups/${encodeURIComponent(groupId)}`,{},'DELETE');\n toast('Ratio group deleted','success');\n await loadRatios();\n }catch(e){ toast(e.message,'danger'); }\n }\n async function loadRatios(){\n const j=await (await fetch('/api/ratio-groups')).json();\n const groups=j.groups||[], history=j.history||[];\n if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>``).join('');\n if($('ratioManager')){\n const groupRows=groups.map(g=>[\n esc(g.name),\n esc(g.owner_name||'-'),\n esc(g.min_ratio),\n esc(g.max_ratio),\n esc(g.seed_time_minutes||g.min_seed_time_minutes||0),\n esc(g.action),\n esc(g.move_path||''),\n esc(g.set_label||''),\n g.enabled?'yes':'no',\n ``\n ]);\n const historyRows=history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]);\n $('ratioManager').innerHTML=`
Groups
${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled','Delete'],groupRows)}
Applied history
${table(['Time','Torrent','Group','Action','Status','Reason'],historyRows)}`;\n }\n }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios);\n $('ratioManager')?.addEventListener('click',e=>{ const btn=e.target.closest('.ratio-group-delete'); if(btn) deleteRatioGroup(btn.dataset.ratioGroupId, btn.dataset.ratioGroupName); });\n $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); });\n $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); });\n $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n"; \ No newline at end of file diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 5b15e3e..9f36fd9 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -5891,7 +5891,7 @@ body.compact-torrent-list .mobile-progress .torrent-progress { color: var(--bs-info); display: inline-flex; font-size: 0.82rem; - margin-left: 0.25rem; + margin-inline: 0.15rem 0.25rem; vertical-align: middle; } diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index b6e227d..de858d2 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -349,7 +349,7 @@
rTorrent config
Grouped rTorrent runtime settings with inline recommendations and compatibility status.
Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.
No changes
Loading config...
Cleanup / retention
One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.
Loading cleanup data...
Backup / restore
Profile backup restores only the active profile context. Application backup restores global application data and is available only to admins.
Creates and restores settings for the currently selected profile. User-scoped preferences are remapped to the current user where needed.
Admin-only full application backup. Restore can replace users, permissions, profiles and global application settings.
-
pyTorrent status
Focused diagnostics for the pyTorrent process and active SCGI/XML-RPC connection. Duplicated storage, jobs and queue details are managed in their dedicated tools.
Open this tab to load diagnostics.
+
pyTorrent status
Focused diagnostics for the pyTorrent process and active SCGI/XML-RPC connection. Duplicated storage, jobs and queue details are managed in their dedicated tools.
Open this tab to load diagnostics.
From 48f68cf125aa5e10f5c2a9ba967f67254515e6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 22:57:29 +0200 Subject: [PATCH 4/6] fix in planner --- pytorrent/routes/planner.py | 1 + pytorrent/static/js/planner.js | 2 +- pytorrent/static/js/plannerActions.js | 2 +- pytorrent/static/js/plannerToolsUi.js | 2 +- pytorrent/static/js/profileList.js | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index a1bc924..c964ce5 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -33,6 +33,7 @@ def download_planner_get(): @bp.post("/download-planner") def download_planner_save(): + # Note: Planner settings are saved through one canonical endpoint to avoid hidden frontend/backend fallbacks. profile, error = _profile_or_error() if error: return error diff --git a/pytorrent/static/js/planner.js b/pytorrent/static/js/planner.js index 6ec8aac..c3a956f 100644 --- a/pytorrent/static/js/planner.js +++ b/pytorrent/static/js/planner.js @@ -1 +1 @@ -export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const payload=plannerPayload(); const j=await post('/api/download-planner',payload); fillPlanner(j.settings||payload); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/plannerActions.js b/pytorrent/static/js/plannerActions.js index e21f8b1..f74fc22 100644 --- a/pytorrent/static/js/plannerActions.js +++ b/pytorrent/static/js/plannerActions.js @@ -1 +1 @@ -export const plannerActionsSource = " function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} · next change ${esc(plannerDateText(preview.next_change_at))} · DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' · pauses downloads':''}${preview.manual_override_until?' · override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerActionsSource = " function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} · next change ${esc(plannerDateText(preview.next_change_at))} · DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' · pauses downloads':''}${preview.manual_override_until?' · override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const payload=plannerPayload(); const j=await post('/api/download-planner',payload); fillPlanner(j.settings||payload); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/plannerToolsUi.js b/pytorrent/static/js/plannerToolsUi.js index 048d3c8..2706785 100644 --- a/pytorrent/static/js/plannerToolsUi.js +++ b/pytorrent/static/js/plannerToolsUi.js @@ -1 +1 @@ -export const plannerToolsUiSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n"; +export const plannerToolsUiSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n
    \n
  • \n
  • \n
\n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n"; diff --git a/pytorrent/static/js/profileList.js b/pytorrent/static/js/profileList.js index 2897ef8..82a1b2f 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(){\n const j=await (await fetch('/api/profiles')).json();\n profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p]));\n const active=String(j.active?.id ?? activeProfileId ?? '');\n const rows=j.profiles||[];\n const statusMap=new Map();\n try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){}\n $('profileList').innerHTML=rows.map(p=>{\n const d=statusMap.get(String(p.id))||{};\n const st=profileDiagnosticStatusLabel(d.status || 'unknown');\n const cls=profileDiagnosticStatusClass(st);\n const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:'';\n const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:'';\n const isActive=String(p.id)===active;\n const backupIcon=p.profile_backup_enabled?``:'';\n return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
${backupIcon}
`;\n }).join('')||'No profiles.';\n }\n"; \ No newline at end of file +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(){\n const j=await (await fetch('/api/profiles')).json();\n profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p]));\n const active=String(j.active?.id ?? activeProfileId ?? '');\n const rows=j.profiles||[];\n const statusMap=new Map();\n try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){}\n $('profileList').innerHTML=rows.map(p=>{\n const d=statusMap.get(String(p.id))||{};\n const st=profileDiagnosticStatusLabel(d.status || 'unknown');\n const cls=profileDiagnosticStatusClass(st);\n const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:'';\n const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:'';\n const isActive=String(p.id)===active;\n const backupIcon=p.profile_backup_enabled?``:'';\n return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}ID ${esc(p.id)} \u00b7 ${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold}
${backupIcon}
`;\n }).join('')||'No profiles.';\n }\n"; \ No newline at end of file From 99692ef217f4c54206b1c6bf0cc50a6751c77e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 23:01:00 +0200 Subject: [PATCH 5/6] update openapi --- pytorrent/openapi/openapi.json | 91 ++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index 7ae3834..1643400 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -6659,6 +6659,97 @@ ] } }, + "/api/ratio-groups/{group_id}": { + "delete": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "properties": { + "groups": { + "items": { + "$ref": "#/components/schemas/RatioGroup" + }, + "type": "array" + }, + "history": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "No profile" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "No write access to profile" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Ratio group not found" + } + }, + "security": [ + { + "sessionCookie": [] + } + ], + "summary": "Delete ratio group", + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + }, + "description": "Ratio group id" + }, + { + "$ref": "#/components/parameters/ProfileId" + }, + { + "$ref": "#/components/parameters/ProfileName" + } + ] + } + }, "/api/ratio-groups/check": { "post": { "requestBody": { From b98505fd315e68d21dc43f91fbbf9a47fa5ea8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 17 Jun 2026 09:02:41 +0200 Subject: [PATCH 6/6] fix planner --- pytorrent/__init__.py | 2 - pytorrent/cli.py | 2 - pytorrent/config.py | 1 - pytorrent/db.py | 1 - pytorrent/logging_config.py | 3 - pytorrent/migrations.py | 2 - pytorrent/routes/__init__.py | 20 ++++++ pytorrent/routes/_shared.py | 35 +++++----- pytorrent/routes/api.py | 11 +--- pytorrent/routes/auth_api.py | 2 - pytorrent/routes/automations.py | 1 - pytorrent/routes/backup.py | 2 - pytorrent/routes/main.py | 4 -- pytorrent/routes/operation_logs.py | 1 - pytorrent/routes/planner.py | 11 ++-- pytorrent/routes/profiles.py | 2 - pytorrent/routes/rss.py | 2 - pytorrent/routes/smart_queue.py | 3 +- pytorrent/routes/system.py | 3 - pytorrent/routes/torrents.py | 2 - pytorrent/services/auth.py | 8 --- pytorrent/services/automation_rules.py | 2 - pytorrent/services/background_automations.py | 1 - pytorrent/services/background_cache_warmup.py | 1 - pytorrent/services/backup.py | 1 - pytorrent/services/database_maintenance.py | 2 - pytorrent/services/disk_guard.py | 2 - pytorrent/services/download_planner.py | 5 +- pytorrent/services/frontend_assets.py | 2 - pytorrent/services/geoip.py | 3 +- pytorrent/services/operation_logs.py | 1 - pytorrent/services/pdf_preview_links.py | 8 --- pytorrent/services/poller_control.py | 9 --- pytorrent/services/port_check.py | 4 -- pytorrent/services/preferences.py | 3 - pytorrent/services/profile_speed_limits.py | 1 - pytorrent/services/ratio_rules.py | 2 - pytorrent/services/retention.py | 2 - pytorrent/services/reverse_dns.py | 1 - pytorrent/services/rss.py | 1 - pytorrent/services/rtorrent/README.md | 10 --- pytorrent/services/rtorrent/__init__.py | 7 +- pytorrent/services/rtorrent/chunks.py | 7 -- pytorrent/services/rtorrent/client.py | 1 - pytorrent/services/rtorrent/config.py | 1 - pytorrent/services/rtorrent/diagnostics.py | 1 - pytorrent/services/rtorrent/files.py | 2 - pytorrent/services/rtorrent/shared.py | 2 - pytorrent/services/rtorrent/system.py | 5 -- pytorrent/services/rtorrent/torrents.py | 32 +-------- pytorrent/services/smart_queue.py | 2 - pytorrent/services/speed_peaks.py | 2 - pytorrent/services/startup_config.py | 2 - pytorrent/services/torrent_cache.py | 2 - pytorrent/services/torrent_creator.py | 1 - pytorrent/services/torrent_meta.py | 1 - pytorrent/services/torrent_stats.py | 2 - pytorrent/services/torrent_summary.py | 1 - pytorrent/services/tracker_cache.py | 2 - pytorrent/services/traffic_history.py | 2 - pytorrent/services/websocket.py | 12 ---- pytorrent/services/workers.py | 26 +------- pytorrent/static/js/plannerActions.js | 2 +- pytorrent/static/styles.css | 66 +++++++++---------- pytorrent/utils.py | 1 - 65 files changed, 82 insertions(+), 279 deletions(-) create mode 100644 pytorrent/routes/__init__.py delete mode 100644 pytorrent/services/rtorrent/README.md diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 5f423b9..da2a0b5 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -124,10 +124,8 @@ def create_app() -> Flask: from .routes.main import bp as main_bp from .routes.api import bp as api_bp - from .routes.planner import bp as planner_api_bp app.register_blueprint(main_bp) app.register_blueprint(api_bp) - app.register_blueprint(planner_api_bp) register_error_pages(app) init_db() from .services.speed_peaks import load_cache diff --git a/pytorrent/cli.py b/pytorrent/cli.py index 77f4c39..c2d4a67 100644 --- a/pytorrent/cli.py +++ b/pytorrent/cli.py @@ -1,10 +1,8 @@ from __future__ import annotations - import argparse import getpass import sys import json - from .db import connect, init_db, utcnow from .services.auth import password_hash from .services import tracker_cache diff --git a/pytorrent/config.py b/pytorrent/config.py index 45810c0..5af67e3 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -1,5 +1,4 @@ from __future__ import annotations - import os import secrets from pathlib import Path diff --git a/pytorrent/db.py b/pytorrent/db.py index 24df03f..dfb6d96 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -1,5 +1,4 @@ from __future__ import annotations - import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone diff --git a/pytorrent/logging_config.py b/pytorrent/logging_config.py index c5026c4..d211e10 100644 --- a/pytorrent/logging_config.py +++ b/pytorrent/logging_config.py @@ -1,13 +1,10 @@ from __future__ import annotations - import logging import time from logging.handlers import TimedRotatingFileHandler from pathlib import Path from typing import Any - from flask import Flask, g, request - from .config import LOG_DIR, LOG_ENABLE, LOG_RETENTION_HOURS _CONFIGURED = False diff --git a/pytorrent/migrations.py b/pytorrent/migrations.py index 0675d2a..d1bb2c9 100644 --- a/pytorrent/migrations.py +++ b/pytorrent/migrations.py @@ -1,10 +1,8 @@ from __future__ import annotations - import sqlite3 from collections.abc import Callable from datetime import datetime, timezone - Migration = Callable[[sqlite3.Connection], bool] diff --git a/pytorrent/routes/__init__.py b/pytorrent/routes/__init__.py new file mode 100644 index 0000000..27e52b0 --- /dev/null +++ b/pytorrent/routes/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from importlib import import_module + +API_ROUTE_MODULES = ( + "torrents", + "profiles", + "rss", + "automations", + "smart_queue", + "system", + "backup", + "operation_logs", + "planner", +) + + +def load_api_route_modules() -> None: + """Import API route modules so their shared blueprint decorators are registered.""" + for module_name in API_ROUTE_MODULES: + import_module(f"{__name__}.{module_name}") diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py index 8bc9c79..48acf07 100644 --- a/pytorrent/routes/_shared.py +++ b/pytorrent/routes/_shared.py @@ -1,5 +1,4 @@ from __future__ import annotations - import base64 import os import platform @@ -19,7 +18,6 @@ import threading from pathlib import Path from urllib.parse import quote from flask import Blueprint, jsonify, request, abort, send_file, redirect, Response, stream_with_context, url_for -# Note: url_for is exported through this shared module for API routes that build temporary in-app links. 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 @@ -34,23 +32,33 @@ bp = Blueprint("api", __name__, url_prefix="/api") MOVE_BULK_MAX_HASHES = 100 - 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.""" + """Return the optional rTorrent 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 "" + + profile_id = ( + request.args.get("profile_id") + or request.form.get("profile_id") + or payload.get("rtorrent_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("rtorrent_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): @@ -123,13 +131,9 @@ def ok(payload=None): return jsonify(data) - from ..services.port_check import port_check_status - - - def _safe_len(callable_obj) -> int | None: try: return len(callable_obj()) @@ -261,13 +265,11 @@ def enrich_bulk_payload(profile: dict, action_name: str, data: dict) -> dict: def _chunk_hashes(hashes: list[str], size: int = MOVE_BULK_MAX_HASHES) -> list[list[str]]: - # Note: Splits very large torrent selections into predictable chunks so each queued job stays small and recoverable. safe_size = max(1, int(size or MOVE_BULK_MAX_HASHES)) return [hashes[index:index + safe_size] for index in range(0, len(hashes), safe_size)] def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict]: - # Note: One shared helper splits large move/remove operations into small ordered parts without changing other actions. base_payload = enrich_bulk_payload(profile, action_name, data) hashes = base_payload.get("hashes") or [] chunks = _chunk_hashes(hashes) @@ -297,17 +299,14 @@ def enqueue_bulk_parts(profile: dict, action_name: str, data: dict) -> list[dict def enqueue_move_bulk_parts(profile: dict, data: dict) -> list[dict]: - # Note: Keep the old public move helper while using the same partitioning logic. return enqueue_bulk_parts(profile, "move", data) def enqueue_remove_bulk_parts(profile: dict, data: dict) -> list[dict]: - # Note: Remove/rm uses the same partitioning as move, which lowers rTorrent load. return enqueue_bulk_parts(profile, "remove", data) def _user_disk_status(profile: dict) -> dict: - # Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller. prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None) try: paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else [] @@ -321,6 +320,4 @@ def _user_disk_status(profile: dict) -> dict: ) - -# Note: Route modules import shared helpers with wildcard imports; include private helper names intentionally. __all__ = [name for name in globals() if not name.startswith('__')] diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index c9d5ac0..6909b3d 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -1,15 +1,8 @@ from __future__ import annotations from ._shared import bp +from . import load_api_route_modules -# Note: Route modules are imported for their decorators; this keeps the public API unchanged. -from . import torrents as _torrents_routes -from . import profiles as _profiles_routes -from . import rss as _rss_routes -from . import automations as _automations_routes -from . import smart_queue as _smart_queue_routes -from . import system as _system_routes -from . import backup as _backup_routes -from . import operation_logs as _operation_logs_routes +load_api_route_modules() __all__ = ["bp"] diff --git a/pytorrent/routes/auth_api.py b/pytorrent/routes/auth_api.py index dc30a44..d25ac3e 100644 --- a/pytorrent/routes/auth_api.py +++ b/pytorrent/routes/auth_api.py @@ -1,7 +1,5 @@ from __future__ import annotations - from flask import abort, jsonify, request - from ..services.auth import current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, provider as auth_provider, uses_external_provider, external_auth_summary, list_api_tokens, create_api_token, revoke_api_token diff --git a/pytorrent/routes/automations.py b/pytorrent/routes/automations.py index aeb517a..d14636d 100644 --- a/pytorrent/routes/automations.py +++ b/pytorrent/routes/automations.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * diff --git a/pytorrent/routes/backup.py b/pytorrent/routes/backup.py index 71a27e4..85c98f8 100644 --- a/pytorrent/routes/backup.py +++ b/pytorrent/routes/backup.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services import auth @@ -53,7 +52,6 @@ def backup_create_app(): @bp.post("/backup") def backup_create(): - # Note: Legacy endpoint now creates a profile backup so non-admin users cannot capture other users' settings. return backup_create_profile() diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index 467e216..5d16794 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -12,8 +12,6 @@ from ..services.preferences import get_preferences, list_profiles, active_profil from ..services import auth, pdf_preview_links, rtorrent from ..config import PYTORRENT_TMP_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..services.frontend_assets import asset_path - -# for favicon from flask import current_app, send_from_directory bp = Blueprint("main", __name__) @@ -24,8 +22,6 @@ def _asset_url(key: str) -> str: return path if path.startswith("http") else url_for("static", filename=path) - - def _attachment_headers(download_name: str, content_type: str = "application/octet-stream", disposition: str = "attachment") -> dict: safe = Path(download_name or "download.bin").name or "download.bin" safe_disposition = "inline" if disposition == "inline" else "attachment" diff --git a/pytorrent/routes/operation_logs.py b/pytorrent/routes/operation_logs.py index 1fb9739..eba68ef 100644 --- a/pytorrent/routes/operation_logs.py +++ b/pytorrent/routes/operation_logs.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services import operation_logs diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index c964ce5..e4835c3 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -1,14 +1,11 @@ from __future__ import annotations -from flask import Blueprint, jsonify, request +from flask import jsonify, request -from ._shared import request_profile -from ..services import preferences, download_planner, poller_control +from ._shared import bp, request_profile +from ..services import download_planner, poller_control from ..services.auth import current_user_id -bp = Blueprint("planner_api", __name__, url_prefix="/api") - - def ok(payload=None): data = {"ok": True} if payload: @@ -33,7 +30,7 @@ def download_planner_get(): @bp.post("/download-planner") def download_planner_save(): - # Note: Planner settings are saved through one canonical endpoint to avoid hidden frontend/backend fallbacks. + # Note: Planner settings are saved through one canonical endpoint to keep the frontend/backend contract explicit. profile, error = _profile_or_error() if error: return error diff --git a/pytorrent/routes/profiles.py b/pytorrent/routes/profiles.py index 9accebd..021351b 100644 --- a/pytorrent/routes/profiles.py +++ b/pytorrent/routes/profiles.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services.rtorrent.diagnostics import profile_diagnostics from ..services import auth @@ -26,7 +25,6 @@ def profiles_create(): return jsonify({"ok": False, "error": str(exc)}), 400 - @bp.put("/profiles/") def profiles_update(profile_id: int): try: diff --git a/pytorrent/routes/rss.py b/pytorrent/routes/rss.py index d96da64..f5ed7a0 100644 --- a/pytorrent/routes/rss.py +++ b/pytorrent/routes/rss.py @@ -1,8 +1,6 @@ from __future__ import annotations - from ._shared import * - def _active_profile_or_400(): profile = request_profile() if not profile: diff --git a/pytorrent/routes/smart_queue.py b/pytorrent/routes/smart_queue.py index a1646bd..2a8a5f5 100644 --- a/pytorrent/routes/smart_queue.py +++ b/pytorrent/routes/smart_queue.py @@ -1,7 +1,7 @@ from __future__ import annotations - from ._shared import * + @bp.get('/smart-queue') def smart_queue_get(): from ..services import smart_queue @@ -19,7 +19,6 @@ def smart_queue_get(): return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []}) - @bp.post('/smart-queue') def smart_queue_save(): from ..services import smart_queue diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index aa1694a..387eeaf 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * import posixpath from ..services import operation_logs @@ -27,7 +26,6 @@ def system_status(): status["disk"] = _user_disk_status(profile) if bool(profile.get("is_remote")): try: - # Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats. usage = rtorrent.remote_system_usage(profile) status.update(usage) status["usage_available"] = True @@ -40,7 +38,6 @@ def system_status(): status["ram"] = psutil.virtual_memory().percent status["usage_source"] = "local" status["usage_available"] = True - # Note: REST status returns the latest records without waiting for the next Socket.IO message. status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0)) return ok({"status": status}) except Exception as exc: diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 146e094..2ab9cc6 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ._shared import * from ..services import profile_speed_limits from ..services import pdf_preview_links, torrent_creator @@ -20,7 +19,6 @@ def torrents(): - @bp.get("/trackers/summary") def trackers_summary(): profile = request_profile() diff --git a/pytorrent/services/auth.py b/pytorrent/services/auth.py index 535eb57..3d07480 100644 --- a/pytorrent/services/auth.py +++ b/pytorrent/services/auth.py @@ -1,11 +1,8 @@ from __future__ import annotations - from functools import wraps from typing import Any import secrets - from urllib.parse import urlparse - from flask import abort, g, has_request_context, jsonify, redirect, request, session, url_for from werkzeug.security import check_password_hash, generate_password_hash @@ -39,8 +36,6 @@ RTORRENT_WRITE_PREFIXES = ( ) RTORRENT_CONFIG_PREFIXES = ("/api/rtorrent-config",) ADMIN_PREFIXES = ("/api/auth/users", "/api/profiles") -# Note: API reads that expose rTorrent/profile data must also respect profile permissions. -# Note: Planner, poller and operation-log endpoints are profile-scoped and must follow the active profile context. PROFILE_READ_PREFIXES = ( "/api/torrents", "/api/torrent-stats", @@ -101,7 +96,6 @@ def _host_matches_bypass(host: str) -> bool: def auth_bypassed_request() -> bool: - # Note: Allows trusted direct-IP access to keep auth enabled for reverse-proxy traffic. if not enabled() or not AUTH_BYPASS_HOSTS or not has_request_context(): return False return _host_matches_bypass(request.host) @@ -115,7 +109,6 @@ def bypass_user_id() -> int: row = conn.execute("SELECT id FROM users WHERE username=? AND is_active=1", (username,)).fetchone() if row: return int(row["id"]) - # Note: Keep direct-IP access usable after old installs, but never choose an inactive fallback. row = conn.execute("SELECT id FROM users WHERE username='admin' AND is_active=1").fetchone() if row: return int(row["id"]) @@ -126,7 +119,6 @@ def current_user_id() -> int: if not enabled(): return default_user_id() if not has_request_context(): - # Note: Background jobs and schedulers do not have Flask request/session state. return 0 if auth_bypassed_request(): return bypass_user_id() diff --git a/pytorrent/services/automation_rules.py b/pytorrent/services/automation_rules.py index ed042ee..4e86848 100644 --- a/pytorrent/services/automation_rules.py +++ b/pytorrent/services/automation_rules.py @@ -23,8 +23,6 @@ def _check_lock(profile_id: int, rule_id: int | None = None) -> threading.Lock: return _CHECK_LOCKS[key] - - def _resolve_user_id(profile: dict[str, Any] | None = None, user_id: int | None = None) -> int: """Return a safe user id for rule ownership or background execution.""" if user_id: diff --git a/pytorrent/services/background_automations.py b/pytorrent/services/background_automations.py index 03f8abc..0df85f5 100644 --- a/pytorrent/services/background_automations.py +++ b/pytorrent/services/background_automations.py @@ -4,7 +4,6 @@ import os import threading import time from typing import Any - from ..db import connect, default_user_id from . import automation_rules, operation_logs, poller_control, rtorrent from .websocket import emit_profile_event diff --git a/pytorrent/services/background_cache_warmup.py b/pytorrent/services/background_cache_warmup.py index 1b002bf..bb27621 100644 --- a/pytorrent/services/background_cache_warmup.py +++ b/pytorrent/services/background_cache_warmup.py @@ -4,7 +4,6 @@ import os import threading import time from typing import Any - from ..db import connect, default_user_id from . import port_check, preferences, rtorrent, tracker_cache from .torrent_cache import torrent_cache diff --git a/pytorrent/services/backup.py b/pytorrent/services/backup.py index 2742a6b..debca40 100644 --- a/pytorrent/services/backup.py +++ b/pytorrent/services/backup.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import threading import time diff --git a/pytorrent/services/database_maintenance.py b/pytorrent/services/database_maintenance.py index 95b108c..cc6c228 100644 --- a/pytorrent/services/database_maintenance.py +++ b/pytorrent/services/database_maintenance.py @@ -1,11 +1,9 @@ from __future__ import annotations - import shutil import sqlite3 import threading import time from typing import Any - from ..config import DB_PATH _VACUUM_LOCK = threading.Lock() diff --git a/pytorrent/services/disk_guard.py b/pytorrent/services/disk_guard.py index b997307..42e8073 100644 --- a/pytorrent/services/disk_guard.py +++ b/pytorrent/services/disk_guard.py @@ -1,7 +1,5 @@ from __future__ import annotations - from typing import Any - from . import download_planner diff --git a/pytorrent/services/download_planner.py b/pytorrent/services/download_planner.py index 39e4f26..176064b 100644 --- a/pytorrent/services/download_planner.py +++ b/pytorrent/services/download_planner.py @@ -1,12 +1,9 @@ from __future__ import annotations - import json import time +import psutil from datetime import datetime, timezone from typing import Any - -import psutil - from ..db import connect, default_user_id, utcnow from . import auth, operation_logs, rtorrent diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py index 5c3f99f..e1bf15c 100644 --- a/pytorrent/services/frontend_assets.py +++ b/pytorrent/services/frontend_assets.py @@ -1,7 +1,5 @@ from __future__ import annotations - from pathlib import Path - from ..config import BASE_DIR, USE_OFFLINE_LIBS LIBS_STATIC_DIR = "libs" diff --git a/pytorrent/services/geoip.py b/pytorrent/services/geoip.py index 6cd9342..29bef32 100644 --- a/pytorrent/services/geoip.py +++ b/pytorrent/services/geoip.py @@ -1,12 +1,11 @@ from __future__ import annotations - from functools import lru_cache from pathlib import Path from ..config import GEOIP_DB try: import geoip2.database -except Exception: # pragma: no cover +except Exception: geoip2 = None _reader = None diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index 15faa5f..8118653 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json from datetime import datetime, timedelta, timezone from typing import Any diff --git a/pytorrent/services/pdf_preview_links.py b/pytorrent/services/pdf_preview_links.py index c856d33..b3d5fc9 100644 --- a/pytorrent/services/pdf_preview_links.py +++ b/pytorrent/services/pdf_preview_links.py @@ -1,5 +1,4 @@ from __future__ import annotations - import secrets import threading import time @@ -18,7 +17,6 @@ def _cleanup_expired(now: float | None = None) -> None: def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: dict) -> dict: """Create a short-lived in-app link target used by preview and download routes.""" - # Note: API routes validate the request first, then return an app URL token instead of exposing stable download URLs in the UI. now = time.time() token = secrets.token_urlsafe(24) with _TEMPORARY_LINK_LOCK: @@ -35,7 +33,6 @@ def _create_temporary_link(kind: str, profile_id: int, user_id: int, payload: di def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict: """Create a short-lived in-app PDF preview link without exposing the API download URL.""" - # Note: The public link is temporary and points to an app route, while streaming still reuses the existing file reader. return _create_temporary_link( "pdf_preview", profile_id, @@ -46,7 +43,6 @@ def create_pdf_preview_link(torrent_hash: str, file_index: int, profile_id: int, def create_file_download_link(torrent_hash: str, file_index: int, profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for one torrent file.""" - # Note: File downloads use /download/ in the UI, but the backend keeps the same rTorrent streaming logic. return _create_temporary_link( "file_download", profile_id, @@ -57,7 +53,6 @@ def create_file_download_link(torrent_hash: str, file_index: int, profile_id: in def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for a ZIP of torrent files.""" - # Note: Selected indexes are stored with the token so the final /download route does not need an API body. clean_indexes = None if indexes is None else [int(index) for index in indexes] return _create_temporary_link( "file_zip_download", @@ -69,7 +64,6 @@ def create_file_zip_download_link(torrent_hash: str, indexes: list[int] | None, def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for an exported .torrent file.""" - # Note: The token hides the stable export API URL from browser-visible download actions. return _create_temporary_link( "torrent_file_download", profile_id, @@ -80,7 +74,6 @@ def create_torrent_file_download_link(torrent_hash: str, profile_id: int, user_i def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, user_id: int) -> dict: """Create a temporary in-app download link for a ZIP of exported .torrent files.""" - # Note: Hashes are copied into the token target after the API validates that the request is non-empty. return _create_temporary_link( "torrent_files_zip_download", profile_id, @@ -91,7 +84,6 @@ def create_torrent_files_zip_download_link(hashes: list[str], profile_id: int, u def get_temporary_link(token: str) -> dict | None: """Return a temporary target if the link is still valid.""" - # Note: Expired links are removed on read so stale browser tabs stop resolving automatically. clean = str(token or "").strip() if not clean: return None diff --git a/pytorrent/services/poller_control.py b/pytorrent/services/poller_control.py index 5a0067e..d7ca21a 100644 --- a/pytorrent/services/poller_control.py +++ b/pytorrent/services/poller_control.py @@ -1,10 +1,8 @@ from __future__ import annotations - import json import time from dataclasses import dataclass, field from typing import Any - from ..db import connect, utcnow from ..config import POLL_INTERVAL, MIN_POLL_INTERVAL_SECONDS @@ -81,7 +79,6 @@ def normalize_settings(data: dict | None) -> dict: "recovery_after_errors": int(_coerce_float(raw.get("recovery_after_errors"), 3, 1, 20)), } if settings["safe_fallback_enabled"]: - # Note: Safe fallback keeps existing functionality, but prevents very aggressive polling from overloading rTorrent or the browser. for key, minimum in SAFE_FALLBACK_MINIMUMS.items(): settings[key] = max(float(settings.get(key) or DEFAULTS[key]), float(minimum)) return settings @@ -91,7 +88,6 @@ def get_settings(profile_id: int) -> dict: with connect() as conn: row = conn.execute("SELECT settings_json FROM poller_settings WHERE profile_id=?", (int(profile_id),)).fetchone() if not row: - # Note: Existing installs stored profile poller settings in app_settings; migrate lazily on first read. legacy = conn.execute("SELECT value FROM app_settings WHERE key=?", (_key(profile_id),)).fetchone() if legacy: try: @@ -240,7 +236,6 @@ def should_heartbeat(now: float, settings: dict, state: ProfilePollState, change def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", updated_count: int = 0, requires_full_refresh: bool = False) -> None: now = time.monotonic() - # Note: Live poller diagnostics track only lightweight speed/status refreshes, not the full torrent snapshot loop. state.live_poll_count += 1 state.last_live_duration_ms = round((now - started_at) * 1000.0, 2) state.last_live_updated_count = int(updated_count or 0) @@ -254,7 +249,6 @@ def mark_live_poll(state: ProfilePollState, started_at: float, ok: bool, error: def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: str = "", added_count: int = 0, updated_count: int = 0, removed_count: int = 0) -> None: now = time.monotonic() - # Note: List poller diagnostics are separate because this slower loop runs full torrent snapshot reconciliation. state.list_poll_count += 1 state.last_list_duration_ms = round((now - started_at) * 1000.0, 2) state.last_list_added_count = int(added_count or 0) @@ -269,7 +263,6 @@ def mark_list_poll(state: ProfilePollState, started_at: float, ok: bool, error: def reset_runtime_stats(profile_id: int) -> dict: state = state_for(profile_id) - # Note: Cleanup resets diagnostic counters only; poller timers and saved settings keep running unchanged. state.tick_count = 0 state.last_tick_ms = 0.0 state.last_tick_gap_ms = 0.0 @@ -390,7 +383,6 @@ def snapshot(profile_id: int, settings: dict | None = None) -> dict: effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id) data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count}) runtime_ready = bool(state.stats) or state.tick_count > 0 - # Note: Snapshot includes saved intervals even before the first runtime tick so diagnostics never render as an empty zero-only panel. data.setdefault("runtime_ready", runtime_ready) data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))) data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting")) @@ -399,7 +391,6 @@ def snapshot(profile_id: int, settings: dict | None = None) -> dict: data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS) if not runtime_ready: data["last_ok"] = None - # Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats. data.update({ "live_poll_count": state.live_poll_count, "list_poll_count": state.list_poll_count, diff --git a/pytorrent/services/port_check.py b/pytorrent/services/port_check.py index 9b334c1..3c432d4 100644 --- a/pytorrent/services/port_check.py +++ b/pytorrent/services/port_check.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import re import socket @@ -8,7 +7,6 @@ import urllib.parse import urllib.request from datetime import datetime, timezone from typing import Any - from ..db import connect from . import preferences, rtorrent @@ -44,7 +42,6 @@ def _public_ip(profile: dict | None = None, force: bool = False) -> str: def _parse_port_candidates(value: str, limit: int = MAX_PORT_CHECK_CANDIDATES) -> tuple[list[int], bool]: """Return valid incoming port candidates from rTorrent network.port_range.""" - # Note: rTorrent can keep a range/list and pick a random port on start, so the checker tests all safe candidates. ports: list[int] = [] seen: set[int] = set() truncated = False @@ -136,7 +133,6 @@ def _check_ports(public_ip: str, ports: list[int], checker) -> dict: def port_check_status(profile: dict | None = None, force: bool = False, user_id: int | None = None) -> dict: """Return cached or freshly checked incoming-port status for one rTorrent profile.""" - # Note: This service is shared by UI routes and the background worker, so browser startup is not required. profile = profile or preferences.active_profile(user_id) prefs = preferences.get_preferences(user_id, int(profile.get("id"))) if profile else preferences.get_preferences(user_id) enabled = bool((prefs or {}).get("port_check_enabled")) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index 3b4dc97..3328942 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -1,7 +1,5 @@ from __future__ import annotations - import json - from ..db import connect, utcnow, default_user_id from . import auth from .frontend_assets import BOOTSTRAP_THEME_LABELS @@ -28,7 +26,6 @@ FONT_FAMILIES = { "adwaita-mono": "Adwaita Mono", } -# Note: Backend owns the recommended torrent table layout so frontend builds do not duplicate presets. RECOMMENDED_TABLE_COLUMNS = { "hidden": ["hash", "priority", "hashing", "active", "message", "complete", "state", "ratio_group"], "shown": ["down_total", "to_download", "up_total", "created"], diff --git a/pytorrent/services/profile_speed_limits.py b/pytorrent/services/profile_speed_limits.py index b7883ca..77fdeab 100644 --- a/pytorrent/services/profile_speed_limits.py +++ b/pytorrent/services/profile_speed_limits.py @@ -1,5 +1,4 @@ from __future__ import annotations - from ..db import connect, utcnow diff --git a/pytorrent/services/ratio_rules.py b/pytorrent/services/ratio_rules.py index 321b371..18fa48f 100644 --- a/pytorrent/services/ratio_rules.py +++ b/pytorrent/services/ratio_rules.py @@ -1,9 +1,7 @@ from __future__ import annotations - import json import time from datetime import datetime, timezone - from ..db import connect, utcnow, default_user_id from . import auth, rtorrent from .workers import enqueue diff --git a/pytorrent/services/retention.py b/pytorrent/services/retention.py index db9c707..9999e84 100644 --- a/pytorrent/services/retention.py +++ b/pytorrent/services/retention.py @@ -1,7 +1,5 @@ from __future__ import annotations - from datetime import datetime, timedelta, timezone - from ..config import JOBS_RETENTION_DAYS, LOG_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect diff --git a/pytorrent/services/reverse_dns.py b/pytorrent/services/reverse_dns.py index 1b90a13..f44957b 100644 --- a/pytorrent/services/reverse_dns.py +++ b/pytorrent/services/reverse_dns.py @@ -1,5 +1,4 @@ from __future__ import annotations - import ipaddress import socket import time diff --git a/pytorrent/services/rss.py b/pytorrent/services/rss.py index 97e8450..b6297ab 100644 --- a/pytorrent/services/rss.py +++ b/pytorrent/services/rss.py @@ -1,5 +1,4 @@ from __future__ import annotations - import re import time import urllib.request diff --git a/pytorrent/services/rtorrent/README.md b/pytorrent/services/rtorrent/README.md deleted file mode 100644 index c276e1f..0000000 --- a/pytorrent/services/rtorrent/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# rTorrent service modules - -The old `pytorrent/services/rtorrent.py` monolith is end-of-life. -Do not recreate it and do not add new rTorrent logic outside this directory. - -Use focused modules in `pytorrent/services/rtorrent/` instead: -- `client.py` for SCGI/XMLRPC transport and shared caches. -- `system.py` for status, footer metrics, disk and remote host usage. -- `torrents.py` for torrent list and torrent operations. -- `files.py`, `config.py`, `diagnostics.py` for their dedicated areas. diff --git a/pytorrent/services/rtorrent/__init__.py b/pytorrent/services/rtorrent/__init__.py index 143fe1d..e2c33ee 100644 --- a/pytorrent/services/rtorrent/__init__.py +++ b/pytorrent/services/rtorrent/__init__.py @@ -1,14 +1,9 @@ from __future__ import annotations -# EOL note: do not recreate or edit the old pytorrent/services/rtorrent.py monolith. -# All rTorrent code belongs in this package directory. - -# Note: Public functions are re-exported here so existing imports from services.rtorrent remain transparent. -# Compatibility note: module __all__ definitions include selected private helpers used by existing routes. from .client import * from .system import * from .diagnostics import * from .files import * from .config import * from .torrents import * -from .chunks import * +from .chunks import * \ No newline at end of file diff --git a/pytorrent/services/rtorrent/chunks.py b/pytorrent/services/rtorrent/chunks.py index da8c8a3..c1c0497 100644 --- a/pytorrent/services/rtorrent/chunks.py +++ b/pytorrent/services/rtorrent/chunks.py @@ -1,5 +1,4 @@ from __future__ import annotations - import math import re from .client import * @@ -11,13 +10,11 @@ _HEX_RE = re.compile(r"[0-9a-fA-F]") def _clean_hex_bitfield(value) -> str: """Return only hexadecimal bitfield characters from rTorrent output.""" - # Note: rTorrent may return spacing or non-hex separators; keep only the actual bitfield payload. return "".join(_HEX_RE.findall(str(value or ""))).lower() def _hex_to_bits(value: str, limit: int | None = None) -> list[int]: """Decode an rTorrent hex bitfield into one bit per torrent piece.""" - # Note: d.bitfield is a packed bitset, not a per-nibble completion percentage; decoding fixes false partial cells near 100% torrents. bits: list[int] = [] for char in _clean_hex_bitfield(value): nibble = int(char, 16) @@ -47,7 +44,6 @@ def _chunk_status(completed: int, total: int, seen: bool = False) -> str: def _group_cells(cells: list[dict], max_cells: int) -> list[dict]: """Reduce very large torrents to a browser-friendly number of visual cells.""" - # Note: Grouping now happens on real piece states, so the aggregated percentage matches the actual torrent progress. if max_cells <= 0 or len(cells) <= max_cells: return cells grouped: list[dict] = [] @@ -79,7 +75,6 @@ def _group_cells(cells: list[dict], max_cells: int) -> list[dict]: def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[int]) -> list[dict]: """Create one raw cell per real torrent piece.""" - # Note: The UI still groups these cells later when needed, but the source data remains exact per piece. cells: list[dict] = [] for idx in range(max(0, int(total_chunks or 0))): completed = 1 if idx < len(have_bits) and have_bits[idx] else 0 @@ -101,7 +96,6 @@ def _build_piece_cells(total_chunks: int, have_bits: list[int], seen_bits: list[ def torrent_chunks(profile: dict, torrent_hash: str, max_cells: int = 2048) -> dict: """Return ruTorrent-like visual chunk data for one torrent.""" - # Note: Uses documented rTorrent XML-RPC fields: d.bitfield, d.chunks_seen, d.chunk_size and d.size_chunks. c = client_for(profile) values = { "bitfield": _clean_hex_bitfield(c.call("d.bitfield", torrent_hash)), @@ -177,7 +171,6 @@ def _files_touching_chunks(c: ScgiRtorrentClient, torrent_hash: str, first_chunk def torrent_chunk_action(profile: dict, torrent_hash: str, action: str, payload: dict | None = None) -> dict: """Run safe actions related to visual chunk selection.""" - # Note: rTorrent does not expose a supported XML-RPC method to redownload one arbitrary chunk; recheck is torrent-wide. payload = payload or {} action = str(action or "").strip().lower() c = client_for(profile) diff --git a/pytorrent/services/rtorrent/client.py b/pytorrent/services/rtorrent/client.py index a047e59..44d3fa2 100644 --- a/pytorrent/services/rtorrent/client.py +++ b/pytorrent/services/rtorrent/client.py @@ -1,5 +1,4 @@ from __future__ import annotations - import errno import os import posixpath diff --git a/pytorrent/services/rtorrent/config.py b/pytorrent/services/rtorrent/config.py index f919844..1be905f 100644 --- a/pytorrent/services/rtorrent/config.py +++ b/pytorrent/services/rtorrent/config.py @@ -1,5 +1,4 @@ from __future__ import annotations - from .client import * RTORRENT_CONFIG_FIELDS = [ diff --git a/pytorrent/services/rtorrent/diagnostics.py b/pytorrent/services/rtorrent/diagnostics.py index e934d58..9f2e6af 100644 --- a/pytorrent/services/rtorrent/diagnostics.py +++ b/pytorrent/services/rtorrent/diagnostics.py @@ -1,5 +1,4 @@ from __future__ import annotations - from .client import * from .. import poller_control diff --git a/pytorrent/services/rtorrent/files.py b/pytorrent/services/rtorrent/files.py index 93cd6aa..97272b2 100644 --- a/pytorrent/services/rtorrent/files.py +++ b/pytorrent/services/rtorrent/files.py @@ -1,5 +1,4 @@ from __future__ import annotations - from .client import * from ...config import BASE_DIR @@ -25,7 +24,6 @@ def torrent_files(profile: dict, torrent_hash: str) -> list[dict]: def torrent_file_tree(profile: dict, torrent_hash: str) -> dict: - # Note: The tree is built from rTorrent file paths without changing the existing flat file API. root = {"name": "", "path": "", "type": "directory", "size": 0, "children": {}} for item in torrent_files(profile, torrent_hash): parts = [part for part in str(item.get("path") or "").split("/") if part] diff --git a/pytorrent/services/rtorrent/shared.py b/pytorrent/services/rtorrent/shared.py index 4a7729e..0eec6a8 100644 --- a/pytorrent/services/rtorrent/shared.py +++ b/pytorrent/services/rtorrent/shared.py @@ -1,4 +1,2 @@ from __future__ import annotations - -# Note: Backward-compatible internal alias for modules created during refactor. from .client import * diff --git a/pytorrent/services/rtorrent/system.py b/pytorrent/services/rtorrent/system.py index 457c674..07c95dd 100644 --- a/pytorrent/services/rtorrent/system.py +++ b/pytorrent/services/rtorrent/system.py @@ -1,8 +1,6 @@ from __future__ import annotations - from typing import Any from threading import RLock - from .client import * from .config import default_download_path from ...utils import human_size @@ -10,7 +8,6 @@ from ...utils import human_size def browse_path(profile: dict, path: str | None = None) -> dict: """List directories through rTorrent execute.capture to avoid pyTorrent FS permissions.""" - # Note: Directory browsing stays remote-side, matching the original monolithic service behavior. c = client_for(profile) base = _remote_clean_path(path or default_download_path(profile)) script = ( @@ -44,7 +41,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict: name, full_path = parts[0], parts[1] is_empty = len(parts) > 2 and parts[2] == "1" if name not in {".", ".."}: - # Note: Empty status is returned with every directory so the path picker can enable safe inline rename. dirs.append({"name": name, "path": full_path, "empty": is_empty}) elif marker == "M" and "\t" in rest: first, second = rest.split("\t", 1) @@ -67,7 +63,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict: parent = posixpath.dirname(base.rstrip("/")) or "/" if parent == base: parent = base - # Note: Path picker metadata is best-effort and remote-side, so it works for move targets on remote rTorrent hosts. return { "path": base, "parent": parent, diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py index fe982ae..7f4e6e4 100644 --- a/pytorrent/services/rtorrent/torrents.py +++ b/pytorrent/services/rtorrent/torrents.py @@ -1,18 +1,14 @@ from __future__ import annotations - import time - from .client import * from .files import set_file_priorities from .system import disk_usage_for_default_path - XMLRPC_DEFAULT_SIZE_LIMIT_BYTES = 512 * 1024 def _parse_xmlrpc_size_limit(value) -> int: """Parse rTorrent XML-RPC size values such as 524288, 16M or 8K.""" - # Note: rTorrent accepts human suffixes in config files; UI validation normalizes them to bytes. text = str(value or '').strip().lower() if not text: return XMLRPC_DEFAULT_SIZE_LIMIT_BYTES @@ -29,7 +25,6 @@ def _parse_xmlrpc_size_limit(value) -> int: def xmlrpc_size_limit(profile: dict) -> dict: """Return the current rTorrent XML-RPC request size limit.""" - # Note: This value controls .torrent uploads because load.raw sends the torrent through XML-RPC. try: raw = client_for(profile).call('network.xmlrpc.size_limit') limit = _parse_xmlrpc_size_limit(raw) @@ -40,7 +35,6 @@ def xmlrpc_size_limit(profile: dict) -> dict: def estimate_torrent_upload_request_size(data: bytes, start: bool = True, directory: str = '', label: str = '', file_priorities: list[dict] | None = None) -> int: """Estimate the XML-RPC body size produced by rTorrent load.raw* for a .torrent file.""" - # Note: XML-RPC uses base64 for Binary payloads, so the request is larger than the raw .torrent file. commands = [] if directory: commands.append(f'd.directory.set={directory}') @@ -93,7 +87,6 @@ def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool: if age > _POST_CHECK_WATCH_TTL_SECONDS: _clear_post_check_watch(profile_id, torrent_hash) return False - # Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet. return age >= _POST_CHECK_WATCH_MIN_SECONDS @@ -124,7 +117,6 @@ def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, cu labels = _label_names(str(label_source or "")) if POST_CHECK_DOWNLOAD_LABEL not in labels: return False - # Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue. c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL])) return True @@ -151,11 +143,9 @@ def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool if POST_CHECK_DOWNLOAD_LABEL not in labels: return False status = str(row.get("status") or "").lower() - # Note: rTorrent may report state=1 after a recheck even when the download is not really active yet. started_after_wait = bool(int(row.get("state") or 0)) and bool(int(row.get("active") or 0)) and status != "checking" if not (_row_progress_complete(row) or status == "seeding" or started_after_wait): return False - # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding. clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or "")) row["label"] = _without_post_check_download_label(str(row.get("label") or "")) return True @@ -183,7 +173,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict complete = _row_progress_complete(row) try: if complete: - # Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately. start_result = start_or_resume_hash(c, h) clear_post_check_download_label(c, h, str(row.get("label") or "")) row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))}) @@ -193,7 +182,6 @@ def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict if POST_CHECK_DOWNLOAD_LABEL not in labels: labels.append(POST_CHECK_DOWNLOAD_LABEL) label_value = _label_value(labels) - # Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit. c.call("d.stop", h) try: c.call("d.close", h) @@ -229,7 +217,6 @@ LIVE_TORRENT_FIELDS = [ def human_duration(seconds: int) -> str: - # Note: Download ETA is derived locally from remaining bytes and current download speed. seconds = max(0, int(seconds or 0)) if seconds <= 0: return '-' @@ -256,12 +243,8 @@ def normalize_row(row: list) -> dict: base_path = str(row[15] or "") state = int(row[2] or 0) complete = int(row[3] or 0) - # Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads. is_multi_file = int(row[24] or 0) if len(row) > 24 else 0 - # Show the selected download location only. Hide the torrent root - # directory for multi-file torrents and the filename for single-file - # torrents. Data deletion still uses the full d.base_path elsewhere. if base_path and base_path != "/": display_parent = posixpath.dirname(base_path.rstrip("/")) or "/" display_path = display_parent.rstrip("/") + "/" if display_parent != "/" else display_parent @@ -280,20 +263,15 @@ def normalize_row(row: list) -> dict: is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state) last_activity = int(row[25] or 0) if len(row) > 25 else 0 if not last_activity and (down_rate > 0 or up_rate > 0): - # Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp. last_activity = int(time.time()) completed_at = int(row[26] or 0) if len(row) > 26 else 0 - # Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever. is_checking = bool(hashing) or _message_indicates_active_check(msg_l) post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active) - # Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused. - # The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate. is_paused = manual_pause and not is_checking and not post_check is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check - # Note: Post-check and Queued are application-level UI statuses; rTorrent itself mainly exposes flags. status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped" to_download_bytes = remaining_bytes if not complete else 0 - # Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value. + return { "hash": str(row[0] or ""), "name": str(row[1] or ""), @@ -338,7 +316,6 @@ def normalize_row(row: list) -> dict: def normalize_live_row(row: list) -> dict: """Normalize the small row used by the fast live stats poller.""" - # Note: The live poller intentionally reads only volatile fields so the main list poller can run less often. size = int(row[3] or 0) completed = int(row[4] or 0) complete = int(row[2] or 0) @@ -406,13 +383,10 @@ def list_torrents(profile: dict) -> list[dict]: try: rows = c.d.multicall2("", "main", *(TORRENT_FIELDS + TORRENT_OPTIONAL_FIELDS)) except Exception: - # Keep compatibility with older rTorrent builds that do not expose optional timestamp fields. rows = c.d.multicall2("", "main", *TORRENT_FIELDS) return [normalize_row(list(row)) for row in rows] - - def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: fields = [ "p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", @@ -444,8 +418,6 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: return peers - - def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict: errors = [] for method, args in candidates: @@ -457,7 +429,6 @@ def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> d raise RuntimeError("; ".join(errors)) - def _tracker_domain(url: str) -> str: raw = str(url or '').strip() if not raw: @@ -471,7 +442,6 @@ def _tracker_domain(url: str) -> str: def tracker_summary(profile: dict, torrent_hashes: list[str] | None = None, limit: int = 1000) -> dict: """Return tracker domains grouped by torrent for the sidebar filter.""" - # Note: Tracker summary is read-only and isolated from the normal torrent snapshot, so slow tracker RPC calls cannot break the main list. hashes = [str(h or '').strip() for h in (torrent_hashes or []) if str(h or '').strip()] if not hashes: hashes = [t.get('hash') for t in list_torrents(profile) if t.get('hash')] diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 8ce21bb..05e7ac7 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -1,12 +1,10 @@ from __future__ import annotations - from collections import Counter from datetime import datetime, timezone from typing import Any import json import os import time - from ..config import BASE_DIR, SMART_QUEUE_LABEL, SMART_QUEUE_STALLED_LABEL from ..db import connect, default_user_id, utcnow from . import rtorrent diff --git a/pytorrent/services/speed_peaks.py b/pytorrent/services/speed_peaks.py index a8b8f58..b4f8eb7 100644 --- a/pytorrent/services/speed_peaks.py +++ b/pytorrent/services/speed_peaks.py @@ -1,8 +1,6 @@ from __future__ import annotations - import threading from typing import Any - from ..db import connect, utcnow from .rtorrent import human_rate diff --git a/pytorrent/services/startup_config.py b/pytorrent/services/startup_config.py index 42afc7f..3153ea9 100644 --- a/pytorrent/services/startup_config.py +++ b/pytorrent/services/startup_config.py @@ -1,8 +1,6 @@ from __future__ import annotations - import threading from time import monotonic - from ..db import connect from . import operation_logs, rtorrent diff --git a/pytorrent/services/torrent_cache.py b/pytorrent/services/torrent_cache.py index d658285..5dab484 100644 --- a/pytorrent/services/torrent_cache.py +++ b/pytorrent/services/torrent_cache.py @@ -1,11 +1,9 @@ from __future__ import annotations - from threading import RLock from time import time from . import rtorrent, operation_logs _LIVE_KEYS = {"state", "active", "paused", "complete", "completed_bytes", "progress", "ratio", "up_rate", "up_rate_h", "down_rate", "down_rate_h", "eta_seconds", "eta_h", "up_total", "up_total_h", "down_total", "down_total_h", "to_download", "to_download_h", "peers", "seeds", "message", "status", "post_check", "hashing"} - _VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"} diff --git a/pytorrent/services/torrent_creator.py b/pytorrent/services/torrent_creator.py index 6dbf909..a7579a7 100644 --- a/pytorrent/services/torrent_creator.py +++ b/pytorrent/services/torrent_creator.py @@ -1,5 +1,4 @@ from __future__ import annotations - import hashlib import os import time diff --git a/pytorrent/services/torrent_meta.py b/pytorrent/services/torrent_meta.py index 3f53aaa..4e1680d 100644 --- a/pytorrent/services/torrent_meta.py +++ b/pytorrent/services/torrent_meta.py @@ -1,5 +1,4 @@ from __future__ import annotations - import hashlib from pathlib import PurePosixPath from typing import Any diff --git a/pytorrent/services/torrent_stats.py b/pytorrent/services/torrent_stats.py index 6745b05..d21d521 100644 --- a/pytorrent/services/torrent_stats.py +++ b/pytorrent/services/torrent_stats.py @@ -1,10 +1,8 @@ from __future__ import annotations - import json import threading import time from typing import Any - from ..db import connect, utcnow from . import rtorrent from .torrent_cache import torrent_cache diff --git a/pytorrent/services/torrent_summary.py b/pytorrent/services/torrent_summary.py index 70d43f4..726442f 100644 --- a/pytorrent/services/torrent_summary.py +++ b/pytorrent/services/torrent_summary.py @@ -1,5 +1,4 @@ from __future__ import annotations - from copy import deepcopy from threading import RLock from time import time diff --git a/pytorrent/services/tracker_cache.py b/pytorrent/services/tracker_cache.py index 8746756..573b5ea 100644 --- a/pytorrent/services/tracker_cache.py +++ b/pytorrent/services/tracker_cache.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import mimetypes import re @@ -11,7 +10,6 @@ import urllib.parse import urllib.request from html.parser import HTMLParser from pathlib import Path - from ..config import BASE_DIR from ..db import connect, utcnow diff --git a/pytorrent/services/traffic_history.py b/pytorrent/services/traffic_history.py index cdb459f..88be994 100644 --- a/pytorrent/services/traffic_history.py +++ b/pytorrent/services/traffic_history.py @@ -1,8 +1,6 @@ from __future__ import annotations - from datetime import datetime, timedelta, timezone from typing import Any - from ..config import TRAFFIC_HISTORY_RETENTION_DAYS from ..db import connect, utcnow from . import retention diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index 60b9992..b08e81a 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -1,5 +1,4 @@ from __future__ import annotations - import threading import time import json @@ -17,7 +16,6 @@ def _profile_room(profile_id: int) -> str: def _poller_profiles() -> list[dict]: - # Background polling has no browser session, so auth-enabled mode refreshes all profiles and emits only to per-profile rooms. if not auth.enabled(): profile = active_profile() return [profile] if profile else [] @@ -27,7 +25,6 @@ def _poller_profiles() -> list[dict]: def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None: - # Note: Profile-scoped events always go to the selected profile room, even when authentication is disabled. scoped_payload = {**(payload or {}), "profile_id": int(profile_id)} socketio.emit(event, scoped_payload, to=_profile_room(profile_id)) @@ -36,19 +33,15 @@ def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None: emit_profile_event(socketio, event, payload, profile_id) - - def _apply_configured_speed_limits(profile: dict) -> None: limits = profile_speed_limits.get_limits(int(profile.get("id") or 0)) if not limits.get("configured"): return - # Note: Profile-level speed limits are re-applied when the profile is opened so they are not tied to a specific user session. rtorrent.set_limits(profile, limits.get("down"), limits.get("up")) def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: state = poller_control.state_for(profile_id) - # Note: Background checks keep the profile owner so bypass/admin profiles do not enqueue jobs as the fallback user. profile_user_id = int(profile.get("user_id") or default_user_id()) try: try: @@ -67,7 +60,6 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None: except Exception as exc: _emit_profile(socketio, "smart_queue_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id) try: - # Note: Automations are profile-scoped; each queued job still runs as the rule owner. auto_result = automation_rules.check(profile, force=False) if auto_result.get("applied") or auto_result.get("batches"): _emit_profile(socketio, "automation_update", auto_result, profile_id) @@ -94,7 +86,6 @@ def _is_active_rows(rows: list[dict]) -> bool: def _speed_status_from_rows(profile_id: int, rows: list[dict]) -> dict: - # Note: Fast-poller speed status keeps browser-title speed and peaks independent from slower system_stats. down_rate = sum(int(row.get("down_rate") or 0) for row in rows or []) up_rate = sum(int(row.get("up_rate") or 0) for row in rows or []) return { @@ -184,7 +175,6 @@ def register_socketio_handlers(socketio): else: skipped_emissions += 1 if live.get("requires_full_refresh"): - # Note: Missing or unknown hashes mean the next slow list tick must reconcile rows. state.last_list_at = 0.0 run_list = True else: @@ -218,7 +208,6 @@ def register_socketio_handlers(socketio): rtorrent_call_count += 1 if bool(profile.get("is_remote")): try: - # Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats. usage = rtorrent.remote_system_usage(profile) status.update(usage) status["usage_available"] = True @@ -272,7 +261,6 @@ def register_socketio_handlers(socketio): global _started with _start_lock: if not _started: - # The poller starts with the app, so Smart Queue, planner and automations work without an open UI. socketio.start_background_task(poller) _started = True diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index 56c8dcc..e91eec9 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -1,5 +1,4 @@ from __future__ import annotations - import json import threading import time @@ -43,7 +42,6 @@ def _emit(name: str, payload: dict): return profile_id = payload.get("profile_id") if profile_id: - # Note: Job/socket events are profile-room scoped so modals and toasts do not leak between rTorrent profiles. _socketio.emit(name, payload, to=f"profile:{int(profile_id)}") else: _socketio.emit(name, payload) @@ -102,7 +100,6 @@ def _job_payload(row) -> dict: def _is_ordered_job(row) -> bool: payload = _job_payload(row) action = str((row or {}).get("action") or "") - # Note: Only long/destructive tasks are ordered; lightweight start/stop/label jobs may run beside other work. return action in {"move", "remove", "add_magnet", "add_torrent_raw"} or bool(payload.get("requires_order")) @@ -195,7 +192,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non job_id = uuid.uuid4().hex if force: payload = dict(payload or {}) - # Note: Forced pending jobs bypass ordered waits and run in a separate worker slot after explicit user confirmation. payload['force_job'] = True payload['priority_job'] = True now = utcnow() @@ -205,7 +201,6 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non "INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now), ) - # Note: Queued jobs are now written to operation logs so work is visible before a worker starts it. operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id) _emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"}) _submit_job(job_id, action_name) @@ -217,7 +212,6 @@ def _job_event_meta(payload: dict) -> dict: source = str(ctx.get("source") or payload.get("source") or "user") meta = {"source": source} if source == "automation": - # Note: Socket operation toasts use this flag so automation notifications respect user preferences. meta["automation"] = True meta["source_label"] = str(ctx.get("rule_name") or "automation") if ctx.get("rule_id") is not None: @@ -226,7 +220,6 @@ def _job_event_meta(payload: dict) -> dict: - def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | None = None) -> bool: # Note: Disk usage refreshes only when a remove job actually requested data deletion. if str(action_name or "") != "remove": @@ -239,7 +232,6 @@ def _remove_job_deletes_data(action_name: str, payload: dict, result: dict | Non def _clear_disk_refresh_cache(profile_id: int) -> None: try: - # Note: Remove-with-data jobs invalidate disk cache before notifying browsers, otherwise /api/system/disk may return stale values. rtorrent.clear_profile_runtime_caches(int(profile_id)) except Exception: pass @@ -247,7 +239,6 @@ def _clear_disk_refresh_cache(profile_id: int) -> None: def _emit_profile_disk_refresh(profile_id: int, reason: str, hash_count: int = 0, delay_seconds: int = 0) -> None: _clear_disk_refresh_cache(profile_id) - # Note: The browser performs the fresh /api/system/disk read so profile-scoped disk monitor preferences stay respected. _emit("disk_refresh_requested", { "profile_id": int(profile_id), "hash_count": int(hash_count or 0), @@ -282,7 +273,6 @@ def _schedule_profile_disk_refresh(profile_id: int, hash_count: int = 0) -> None old_timer = _disk_refresh_timers.get(key) if old_timer: old_timer.cancel() - # Note: Repeated delete jobs share one delayed refresh per profile and delay, preventing timer storms during bulk cleanup. timer = threading.Timer(float(delay_seconds), _run_delayed_disk_refresh, args=(profile_id, int(delay_seconds))) timer.daemon = True _disk_refresh_timers[key] = timer @@ -301,7 +291,6 @@ def _emit_disk_refresh_requested(profile_id: int, action_name: str, payload: dic def _execute(profile: dict, action_name: str, payload: dict, user_id: int | None = None): if action_name == "smart_queue_check": from . import smart_queue - # Note: Worker execution uses the job owner instead of Flask session state. return smart_queue.check(profile, user_id=user_id or default_user_id(), force=True) if action_name == "add_magnet": if bool(payload.get("start", True)): @@ -363,7 +352,6 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None: else: _emit("rtorrent_error", {**diff, "profile_id": profile_id}) except Exception as exc: - # Note: A failed live refresh must not change the already completed job result. _emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)}) @@ -372,7 +360,6 @@ def _schedule_delayed_torrent_refresh(profile: dict, action_name: str) -> None: return def delayed_refresh(): - # Note: rTorrent may expose state changes one poll later than the XML-RPC action result. sleep_fn = getattr(_socketio, "sleep", time.sleep) for delay in (0.75, 1.75): sleep_fn(delay) @@ -395,7 +382,6 @@ def _run(job_id: str): profile = get_profile(int(job["profile_id"]), int(job["user_id"])) if not profile: _set_job(job_id, "failed", "rTorrent profile does not exist", finished=True) - # Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too. operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found") _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"}) return @@ -422,16 +408,13 @@ def _run(job_id: str): _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts}) result = _execute(profile, job["action"], payload, user_id=int(job.get("user_id") or 0)) fresh = _job_row(job_id) - # Note: Emergency cancel and watchdog timeout keep late work from overwriting a terminal state. if fresh and fresh["status"] != "running": return _set_job(job_id, "done", result=result, finished=True) operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0)) _emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta}) - # Note: Remove-with-data jobs ask connected browsers to refresh disk usage immediately after filesystem deletion finishes. action_name = str(job["action"] or "") _emit_disk_refresh_requested(int(profile["id"]), action_name, payload, result or {}) - # Note: Completed jobs must publish a fresh torrent snapshot/patch so removed or moved torrents disappear without a page reload. _emit_torrent_refresh(profile, action_name) _schedule_delayed_torrent_refresh(profile, action_name) _emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result}) @@ -495,7 +478,6 @@ def _timeout_running_jobs() -> None: continue message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds" _set_job(row["id"], "failed", message, finished=True) - # Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised. operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message) _emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"}) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message}) @@ -514,8 +496,7 @@ def _resubmit_interrupted_running_jobs() -> None: if not profile: continue last_seen_ts = _parse_ts(row.get("heartbeat_at") or row.get("updated_at")) - # Note: After process restart there is no in-memory runner for this job. - # A short grace avoids stealing work from another still-alive Gunicorn worker. + if last_seen_ts is not None and now_ts - last_seen_ts < 90: continue with connect() as conn: @@ -524,7 +505,6 @@ def _resubmit_interrupted_running_jobs() -> None: ("Resuming interrupted job from last checkpoint", utcnow(), row["id"]), ) if int(cur.rowcount or 0): - # Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable. operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True}) _submit_job(row["id"], row.get("action")) @@ -547,7 +527,6 @@ def _resubmit_stale_pending_jobs() -> None: continue with connect() as conn: conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"])) - # Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery. operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True}) _submit_job(row["id"], row.get("action")) @@ -586,7 +565,6 @@ def _job_summary(row: dict, payload: dict, result: dict) -> str: count = int(ctx.get("hash_count") or len(payload.get("hashes") or []) or result.get("count") or 0) parts = [] if ctx.get("bulk_label"): - # Note: Shows which generated bulk part is being displayed in the job queue. parts.append(f"{ctx.get('bulk_label')} of {ctx.get('bulk_parts')}") if count: parts.append(("bulk " if count > 1 else "single ") + f"{count} torrent(s)") @@ -652,7 +630,6 @@ def cancel_job(job_id: str) -> bool: row = _job_row(job_id) if not row or row["status"] not in {"pending", "running"}: return False - # Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup. _set_job(job_id, "cancelled", finished=True) payload = _job_payload(row) operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0)) @@ -670,7 +647,6 @@ def clear_jobs() -> int: def emergency_clear_jobs() -> int: - # Note: Emergency cleanup first marks active jobs as cancelled, then clears the whole job log list. now = utcnow() where, params = _job_scope_sql(writable=True) status_clause = "status IN ('pending', 'running')" diff --git a/pytorrent/static/js/plannerActions.js b/pytorrent/static/js/plannerActions.js index f74fc22..56073a5 100644 --- a/pytorrent/static/js/plannerActions.js +++ b/pytorrent/static/js/plannerActions.js @@ -1 +1 @@ -export const plannerActionsSource = " function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' · dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} · next change ${esc(plannerDateText(preview.next_change_at))} · DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' · pauses downloads':''}${preview.manual_override_until?' · override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const payload=plannerPayload(); const j=await post('/api/download-planner',payload); fillPlanner(j.settings||payload); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerActionsSource = " const PLANNER_API_BASE = '/api/download-planner';\n\n async function plannerApiJson(url, options={}){\n const response = await fetch(url, {cache:'no-store', ...options});\n const json = await response.json().catch(() => ({}));\n if(!response.ok || json.ok === false){\n throw new Error(json.error || `Planner API failed (${response.status})`);\n }\n return json;\n }\n\n function renderPlannerPreview(preview={}){\n updatePlannerCurrentSummary(preview);\n const box=$('plannerPreview');\n if(!box) return;\n const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0);\n box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`;\n updatePlannerFooter(!!$('plannerEnabled')?.checked,preview);\n const ov=$('plannerOverrideStatus');\n if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.';\n }\n\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory');\n if(!box) return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n\n function fillPlanner(st){\n if(!st) return;\n $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled);\n $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode');\n $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run);\n updatePlannerFooter(!!st.enabled,st);\n $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled);\n const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[];\n for(let hour=0;hour<24;hour++){\n const item=hourly.find(x=>Number(x.hour)===hour)||{};\n const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`);\n if(d) d.value=Number(item.down||0);\n if(u) u.value=Number(item.up||0);\n updatePlannerHourSummary(hour);\n }\n $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled);\n $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00');\n $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00');\n $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled);\n $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00');\n $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00');\n $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0);\n $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0);\n $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0);\n $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0);\n updatePlannerSpeedControls('plannerWeekday');\n updatePlannerSpeedControls('plannerWeekend');\n $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled);\n $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90);\n $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled);\n $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95);\n $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled);\n $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0);\n $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0);\n $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled);\n $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95);\n $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false);\n $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0);\n if(st.manual_override_until) renderPlannerPreview(st);\n updatePlannerCurrentSummary(st);\n }\n\n function applyPlannerPreset(){\n const name=$('plannerProfileName')?.value||'';\n if(name==='night mode'){\n $('plannerNightOnly').checked=true;\n $('plannerQuietEnabled').checked=false;\n setPlannerSpeed('plannerWeekday',100);\n setPlannerSpeed('plannerWeekend',250);\n }\n if(name==='weekend mode'){\n $('plannerNightOnly').checked=false;\n setPlannerSpeed('plannerWeekday',50);\n setPlannerSpeed('plannerWeekend',0);\n }\n if(name==='low power mode'){\n $('plannerLoadEnabled').checked=true;\n $('plannerCpuEnabled').checked=true;\n $('plannerCpuPercent').value=70;\n setPlannerSpeed('plannerWeekday',50);\n setPlannerSpeed('plannerWeekend',50);\n }\n if(name==='unlimited mode'){\n $('plannerNightOnly').checked=false;\n $('plannerQuietEnabled').checked=false;\n setPlannerSpeed('plannerWeekday',0);\n setPlannerSpeed('plannerWeekend',0);\n }\n updatePlannerCurrentSummary();\n }\n\n async function loadPlannerPreview(){\n try{\n const limit=plannerHistoryExpanded?100:10;\n const j=await plannerApiJson(`${PLANNER_API_BASE}/preview?history_limit=${limit}`);\n renderPlannerPreview(j.preview||{});\n renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));\n }catch(e){\n const box=$('plannerPreview');\n if(box) box.innerHTML=`${esc(e.message||'Planner preview failed')}`;\n }\n }\n\n async function loadDownloadPlanner(){\n ensurePlannerToolsUI();\n try{\n const j=await plannerApiJson(PLANNER_API_BASE);\n fillPlanner(j.settings||{});\n await loadPlannerPreview();\n }catch(e){\n const box=$('plannerPreview');\n if(box) box.innerHTML=`${esc(e.message||'Planner settings failed')}`;\n }\n }\n\n async function saveDownloadPlanner(){\n setBusy(true);\n try{\n // Note: Save uses the canonical Planner endpoint registered on the shared API blueprint.\n const payload=plannerPayload();\n const j=await post(PLANNER_API_BASE,payload);\n fillPlanner(j.settings||payload);\n await loadPlannerPreview();\n toast('Download planner saved','success');\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n\n async function applyDownloadPlannerNow(dryRun=false){\n setBusy(true);\n try{\n const j=await post(`${PLANNER_API_BASE}/check`,{dry_run:!!dryRun});\n const r=j.result||{};\n if(r.settings) fillPlanner(r.settings);\n renderPlannerPreview(r.preview||r);\n if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length);\n else await loadPlannerPreview();\n toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n\n async function setPlannerOverride(){\n setBusy(true);\n try{\n const seconds=Number($('plannerOverrideSeconds')?.value||0);\n await post(`${PLANNER_API_BASE}/override`,{seconds});\n toast(seconds?'Planner override set':'Planner override cleared','success');\n await loadDownloadPlanner();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 9f36fd9..def9d5b 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -3418,6 +3418,39 @@ body.mobile-mode .mobile-filter-bar { margin-bottom: 0.7rem; } +/* Note: Planner Current Settings inherits the original compact card chrome from .smart-setting-row. */ +.planner-current-summary { + align-items: flex-start; +} + +/* Note: Keep Planner Current Settings entries on one visual line, with the same separator spacing as before. */ +.planner-diagnostic-line { + align-items: center; + color: var(--bs-secondary-color); + display: flex; + flex-wrap: wrap; + gap: 0.3rem 0.55rem; + line-height: 1.45; + margin-top: 0.2rem; +} + +.planner-diagnostic-item { + align-items: baseline; + display: inline-flex; + gap: 0.25rem; + white-space: nowrap; +} + +.planner-diagnostic-item b { + color: var(--bs-body-color); + display: inline; + font-weight: 700; +} + +.planner-diagnostic-line .diagnostic-separator { + margin: 0 0.18rem; +} + .planner-current-summary ul { display: flex; flex-wrap: wrap; @@ -5746,39 +5779,6 @@ body.compact-torrent-list .mobile-progress .torrent-progress { width: 1rem; } -/* Note: Planner Current Settings inherits the original compact card chrome from .smart-setting-row. */ -.planner-current-summary { - align-items: flex-start; -} - -/* Note: Keep Planner Current Settings entries on one visual line, with the same separator spacing as before. */ -.planner-diagnostic-line { - align-items: center; - color: var(--bs-secondary-color); - display: flex; - flex-wrap: wrap; - gap: 0.3rem 0.55rem; - line-height: 1.45; - margin-top: 0.2rem; -} - -.planner-diagnostic-item { - align-items: baseline; - display: inline-flex; - gap: 0.25rem; - white-space: nowrap; -} - -.planner-diagnostic-item b { - color: var(--bs-body-color); - display: inline; - font-weight: 700; -} - -.planner-diagnostic-line .diagnostic-separator { - margin: 0 0.18rem; -} - .diagnostic-separator, .modal-meta-separator { color: var(--bs-secondary-color); diff --git a/pytorrent/utils.py b/pytorrent/utils.py index 7a2639a..602cba3 100644 --- a/pytorrent/utils.py +++ b/pytorrent/utils.py @@ -1,5 +1,4 @@ from __future__ import annotations - import hashlib from pathlib import Path