diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index 93e344a..67eca99 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -1951,6 +1951,9 @@ "$ref": "#/components/schemas/TorrentPeer" }, "type": "array" + }, + "peer_activity": { + "$ref": "#/components/schemas/TorrentPeerActivity" } }, "required": [ @@ -2155,6 +2158,75 @@ "url", "expires_in" ] + }, + "MediaInfoResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "type": "object", + "properties": { + "media_info": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "media_info" + ] + } + ] + }, + "BackupCreateResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiOk" + }, + { + "type": "object", + "properties": { + "backup": { + "$ref": "#/components/schemas/Backup" + }, + "profile_backups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Backup" + } + }, + "app_backups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Backup" + } + }, + "backups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Backup" + } + } + } + } + ] + }, + "TorrentPeerActivity": { + "type": "object", + "properties": { + "peers_connected": { + "type": "integer" + }, + "up_rate": { + "type": "integer" + }, + "down_rate": { + "type": "integer" + }, + "active": { + "type": "boolean" + } + } } }, "securitySchemes": { @@ -3200,6 +3272,186 @@ "summary": "Create backup" } }, + "/api/backup/app": { + "post": { + "summary": "Create application backup", + "description": "Creates an application-wide backup. Admin permissions are required.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupCreateInput" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupCreateResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, + "/api/backup/profile": { + "post": { + "summary": "Create profile backup", + "description": "Creates a backup for the active profile only.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupCreateInput" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupCreateResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, + "/api/backup/profile/settings": { + "get": { + "summary": "Get profile automatic backup settings", + "description": "Returns automatic backup settings for the active profile.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoBackupSettingsResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + }, + "post": { + "summary": "Save profile automatic backup settings", + "description": "Saves automatic backup settings for the active profile.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoBackupSettingsRequest" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AutoBackupSettingsResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, "/api/backup/settings": { "get": { "responses": { @@ -3539,6 +3791,64 @@ "summary": "Clear active profile cache" } }, + "/api/cleanup/database/vacuum": { + "post": { + "summary": "Vacuum application database", + "description": "Runs SQLite VACUUM for database maintenance. Admin permissions are required.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + } + } + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, "/api/cleanup/jobs": { "post": { "responses": { @@ -3617,6 +3927,39 @@ "summary": "Clear Planner action history" } }, + "/api/cleanup/poller-diagnostics": { + "post": { + "summary": "Clear poller runtime diagnostics", + "description": "Clears in-memory poller runtime counters for the active profile without changing poller settings or torrent state.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupResponse" + } + } + }, + "description": "Cleanup result" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, "/api/cleanup/smart-queue": { "post": { "responses": { @@ -5948,6 +6291,40 @@ "summary": "Queue global speed limit change" } }, + "/api/static_hash": { + "get": { + "tags": [ + "System" + ], + "summary": "Get current frontend JS/CSS hash", + "description": "Returns the startup-computed hash for app JavaScript and CSS assets. The value is kept in memory and returned without scanning static files per request.", + "responses": { + "200": { + "description": "Static asset hash", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "static_hash": { + "type": "string", + "description": "Short SHA-256-based hash of frontend JavaScript and CSS files computed once at app startup." + }, + "version": { + "type": "string", + "description": "Alias of static_hash for simple client version checks." + } + } + } + } + } + } + } + } + }, "/api/system/disk": { "get": { "responses": { @@ -6317,9 +6694,58 @@ "summary": "Export selected torrent files as ZIP" } }, + "/api/torrents/torrent-files.zip/link": { + "post": { + "summary": "Create temporary .torrent files ZIP download link", + "description": "Validates selected torrents and returns a short-lived /download URL for a ZIP of exported .torrent files.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hashes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "hashes" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryLinkResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/torrents/{action_name}": { "post": { - "description": "For move, path is the target directory; move_data=true physically moves data on the rTorrent host using a detached shell move with status polling. Large move/remove selections are split into ordered bulk parts of up to 100 hashes.", + "description": "For move, path is the target directory; move_data=true physically moves data on the rTorrent host using a detached shell move with status polling. Large move/remove selections are split into ordered bulk parts of up to 100 hashes. The set_label action accepts a label string and queues a label update for the selected hashes.", "parameters": [ { "in": "path", @@ -6568,6 +6994,62 @@ "summary": "Torrent files" } }, + "/api/torrents/{torrent_hash}/files/download-link": { + "post": { + "summary": "Create temporary torrent file download link from body", + "description": "Body-based alias that validates a selected torrent file and returns a short-lived /download URL.", + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file_index": { + "type": "integer" + } + }, + "required": [ + "file_index" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryLinkResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/torrents/{torrent_hash}/files/download.zip": { "post": { "parameters": [ @@ -6605,6 +7087,63 @@ "summary": "Download selected files as ZIP" } }, + "/api/torrents/{torrent_hash}/files/download.zip/link": { + "post": { + "summary": "Create temporary torrent files ZIP download link", + "description": "Validates selected torrent files and returns a short-lived /download URL for a ZIP archive. If indexes is omitted or null, all files are included.", + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "indexes": { + "type": "array", + "items": { + "type": "integer" + }, + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryLinkResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/torrents/{torrent_hash}/files/folder-priority": { "post": { "parameters": [ @@ -6766,6 +7305,103 @@ "summary": "Download torrent file" } }, + "/api/torrents/{torrent_hash}/files/{file_index}/download-link": { + "post": { + "summary": "Create temporary torrent file download link", + "description": "Validates the selected torrent file through the API and returns a short-lived /download URL for the UI.", + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "file_index", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryLinkResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/torrents/{torrent_hash}/files/{file_index}/mediainfo": { + "get": { + "summary": "Read torrent file media info", + "description": "Returns a lightweight media/text/image/PDF preview metadata payload for one torrent file.", + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "file_index", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MediaInfoResponse" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "sessionCookie": [] + } + ] + } + }, "/api/torrents/{torrent_hash}/peers": { "get": { "parameters": [ @@ -6835,6 +7471,44 @@ "summary": "Export torrent file" } }, + "/api/torrents/{torrent_hash}/torrent-file/link": { + "get": { + "summary": "Create temporary .torrent export download link", + "description": "Validates .torrent export availability and returns a short-lived /download URL for the UI.", + "parameters": [ + { + "in": "path", + "name": "torrent_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemporaryLinkResponse" + } + } + } + }, + "400": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/torrents/{torrent_hash}/trackers": { "get": { "parameters": [ @@ -7146,55 +7820,6 @@ "summary": "Traffic history" } }, - "/preview/pdf/{token}": { - "get": { - "summary": "Open temporary PDF preview", - "description": "Streams a PDF through an in-app temporary preview URL created by the API. The browser-visible URL does not expose the stable /api download route.", - "parameters": [ - { - "in": "path", - "name": "token", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "PDF stream", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "403": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - }, - "404": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - } - }, "/download/{token}": { "get": { "summary": "Open temporary download link", @@ -7256,173 +7881,14 @@ } } }, - "/api/torrents/{torrent_hash}/files/{file_index}/download-link": { - "post": { - "summary": "Create temporary torrent file download link", - "description": "Validates the selected torrent file through the API and returns a short-lived /download URL for the UI.", - "parameters": [ - { - "in": "path", - "name": "torrent_hash", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "file_index", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TemporaryLinkResponse" - } - } - } - }, - "400": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - } - }, - "/api/torrents/{torrent_hash}/files/download-link": { - "post": { - "summary": "Create temporary torrent file download link from body", - "description": "Body-based alias that validates a selected torrent file and returns a short-lived /download URL.", - "parameters": [ - { - "in": "path", - "name": "torrent_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "file_index": { - "type": "integer" - } - }, - "required": [ - "file_index" - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TemporaryLinkResponse" - } - } - } - }, - "400": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - } - }, - "/api/torrents/{torrent_hash}/files/download.zip/link": { - "post": { - "summary": "Create temporary torrent files ZIP download link", - "description": "Validates selected torrent files and returns a short-lived /download URL for a ZIP archive. If indexes is omitted or null, all files are included.", - "parameters": [ - { - "in": "path", - "name": "torrent_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "indexes": { - "type": "array", - "items": { - "type": "integer" - }, - "nullable": true - } - } - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TemporaryLinkResponse" - } - } - } - }, - "400": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - } - }, - "/api/torrents/{torrent_hash}/torrent-file/link": { + "/preview/pdf/{token}": { "get": { - "summary": "Create temporary .torrent export download link", - "description": "Validates .torrent export availability and returns a short-lived /download URL for the UI.", + "summary": "Open temporary PDF preview", + "description": "Streams a PDF through an in-app temporary preview URL created by the API. The browser-visible URL does not expose the stable /api download route.", "parameters": [ { "in": "path", - "name": "torrent_hash", + "name": "token", "required": true, "schema": { "type": "string" @@ -7431,16 +7897,17 @@ ], "responses": { "200": { - "description": "OK", + "description": "PDF stream", "content": { - "application/json": { + "application/pdf": { "schema": { - "$ref": "#/components/schemas/TemporaryLinkResponse" + "type": "string", + "format": "binary" } } } }, - "400": { + "403": { "description": "Error", "content": { "application/json": { @@ -7449,47 +7916,8 @@ } } } - } - } - } - }, - "/api/torrents/torrent-files.zip/link": { - "post": { - "summary": "Create temporary .torrent files ZIP download link", - "description": "Validates selected torrents and returns a short-lived /download URL for a ZIP of exported .torrent files.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "hashes": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "hashes" - ] - } - } - } - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TemporaryLinkResponse" - } - } - } }, - "400": { + "404": { "description": "Error", "content": { "application/json": { @@ -7501,40 +7929,6 @@ } } } - }, - "/api/static_hash": { - "get": { - "tags": [ - "System" - ], - "summary": "Get current frontend JS/CSS hash", - "description": "Returns the startup-computed hash for app JavaScript and CSS assets. The value is kept in memory and returned without scanning static files per request.", - "responses": { - "200": { - "description": "Static asset hash", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ok": { - "type": "boolean" - }, - "static_hash": { - "type": "string", - "description": "Short SHA-256-based hash of frontend JavaScript and CSS files computed once at app startup." - }, - "version": { - "type": "string", - "description": "Alias of static_hash for simple client version checks." - } - } - } - } - } - } - } - } } } } diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 02eecbd..c71575c 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -483,13 +483,15 @@ def torrent_peers(torrent_hash: str): if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 peers = rtorrent.torrent_peers(profile, torrent_hash) + activity = rtorrent.torrent_peer_activity(profile, torrent_hash) for peer in peers: peer.update(lookup_ip(peer.get("ip", ""))) prefs = preferences.get_preferences(profile_id=profile.get("id")) if int(prefs.get("reverse_dns_enabled") or 0): # Note: PTR hostnames are attached only when the user enables the lightweight cached resolver. attach_reverse_dns(peers) - return ok({"peers": peers}) + # Note: peer_activity lets the UI silently retry when rTorrent reports traffic before peer rows are visible. + return ok({"peers": peers, "peer_activity": activity}) diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py index 1c8d5f9..077e10f 100644 --- a/pytorrent/services/rtorrent/torrents.py +++ b/pytorrent/services/rtorrent/torrents.py @@ -400,17 +400,42 @@ def list_torrents(profile: dict) -> list[dict]: -def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: - fields = [ - "p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", - "p.up_rate=", "p.port=", "p.is_encrypted=", "p.is_incoming=", - "p.is_snubbed=", "p.is_banned=", - ] +PEER_DETAIL_FIELDS = [ + "p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", + "p.up_rate=", "p.port=", "p.is_encrypted=", "p.is_incoming=", + "p.is_snubbed=", "p.is_banned=", +] +PEER_DETAIL_FALLBACK_FIELDS = [ + "p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", + "p.up_rate=", "p.port=", "p.is_encrypted=", +] +PEER_ACTIVITY_FIELDS = ["d.peers_connected=", "d.up.rate=", "d.down.rate=", "d.is_active="] + + +def _peer_rows(c: ScgiRtorrentClient, torrent_hash: str) -> list: try: - rows = client_for(profile).p.multicall(torrent_hash, "", *fields) + return c.p.multicall(torrent_hash, "", *PEER_DETAIL_FIELDS) except Exception: - fields = ["p.address=", "p.client_version=", "p.completed_percent=", "p.down_rate=", "p.up_rate=", "p.port=", "p.is_encrypted="] - rows = client_for(profile).p.multicall(torrent_hash, "", *fields) + return c.p.multicall(torrent_hash, "", *PEER_DETAIL_FALLBACK_FIELDS) + + +def _peer_activity(c: ScgiRtorrentClient, torrent_hash: str) -> dict: + try: + values = c.d.multicall2("", "main", "d.hash=", *PEER_ACTIVITY_FIELDS) + for row in values: + if str(row[0] or "").lower() == str(torrent_hash or "").lower(): + return { + "peers_connected": int(row[1] or 0), + "up_rate": int(row[2] or 0), + "down_rate": int(row[3] or 0), + "active": bool(row[4]), + } + except Exception: + pass + return {"peers_connected": 0, "up_rate": 0, "down_rate": 0, "active": False} + + +def _normalize_peer_rows(rows: list) -> list[dict]: peers = [] for idx, r in enumerate(rows): peers.append({ @@ -431,6 +456,30 @@ def torrent_peers(profile: dict, torrent_hash: str) -> list[dict]: return peers +def torrent_peer_activity(profile: dict, torrent_hash: str) -> dict: + """Return lightweight live counters used to decide whether an empty peer list may be stale.""" + # Note: This is additive and does not change torrent list polling or cached torrent rows. + return _peer_activity(client_for(profile), torrent_hash) + + +def torrent_peers(profile: dict, torrent_hash: str, retry_when_active: bool = True) -> list[dict]: + c = client_for(profile) + rows = _peer_rows(c, torrent_hash) + if rows or not retry_when_active: + return _normalize_peer_rows(rows) + activity = _peer_activity(c, torrent_hash) + should_retry = bool(activity.get("peers_connected") or activity.get("up_rate") or activity.get("down_rate")) + if not should_retry: + return [] + # Note: rTorrent can expose transfer counters before p.multicall catches up; short retries avoid a misleading empty peer table. + for _attempt in range(3): + time.sleep(0.2) + rows = _peer_rows(c, torrent_hash) + if rows: + return _normalize_peer_rows(rows) + return [] + + def _call_first(c: ScgiRtorrentClient, candidates: list[tuple[str, tuple]]) -> dict: diff --git a/pytorrent/static/js/peerRefresh.js b/pytorrent/static/js/peerRefresh.js index 8f86986..4c3ad0d 100644 --- a/pytorrent/static/js/peerRefresh.js +++ b/pytorrent/static/js/peerRefresh.js @@ -1 +1 @@ -export const peerRefreshSource = " function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }\n function refreshPeersOnceForReverseDns(){\n // Note: Enabling reverse DNS immediately refreshes peers; pending hostnames then use their own follow-up loop.\n if(activeTab()==='peers' && selectedHash) loadDetails('peers');\n const modal=$('mobileDetailsModal');\n if(modal?.classList.contains('show') && selectedHash) openMobileDetails(selectedHash);\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n"; +export const peerRefreshSource = " function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers',{silent:true}); }, peersRefreshSeconds*1000); } }\n function clearPeerEmptyRetry(){\n clearTimeout(peerEmptyRetryTimer);\n peerEmptyRetryTimer=null;\n peerEmptyRetryHash=null;\n peerEmptyRetryAttempts=0;\n }\n function peerActivityLooksActive(activity){\n return !!(activity && (Number(activity.peers_connected||0)>0 || Number(activity.up_rate||0)>0 || Number(activity.down_rate||0)>0));\n }\n function schedulePeerEmptyRetry(peers, activity){\n if((peers||[]).length || !peerActivityLooksActive(activity)){ clearPeerEmptyRetry(); return; }\n if(activeTab()!=='peers' || !selectedHash) return;\n const hash=selectedHash;\n if(peerEmptyRetryHash!==hash){ peerEmptyRetryHash=hash; peerEmptyRetryAttempts=0; }\n if(peerEmptyRetryTimer || peerEmptyRetryAttempts>=PEER_EMPTY_RETRY_MAX_ATTEMPTS) return;\n peerEmptyRetryAttempts+=1;\n peerEmptyRetryTimer=setTimeout(async()=>{\n peerEmptyRetryTimer=null;\n if(activeTab()!=='peers' || selectedHash!==hash) return;\n await loadDetails('peers',{silent:true});\n }, PEER_EMPTY_RETRY_SECONDS*1000);\n }\n function refreshPeersOnceForReverseDns(){\n // Note: Enabling reverse DNS immediately refreshes peers; pending hostnames then use their own follow-up loop.\n if(activeTab()==='peers' && selectedHash) loadDetails('peers');\n const modal=$('mobileDetailsModal');\n if(modal?.classList.contains('show') && selectedHash) openMobileDetails(selectedHash);\n }\n function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia(\"(max-width: 900px)\").matches; document.body.classList.toggle(\"mobile-mode\", auto || document.body.classList.contains(\"mobile-mode-manual\")); scheduleRender(true); }\n\n\n let automationRulesCache=[];\n let automationConditions=[];\n let automationEffects=[];\n"; diff --git a/pytorrent/static/js/runtimeState.js b/pytorrent/static/js/runtimeState.js index c6715f2..7611c3a 100644 --- a/pytorrent/static/js/runtimeState.js +++ b/pytorrent/static/js/runtimeState.js @@ -1 +1 @@ -export const runtimeStateSource = " let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 0;\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let diskMonitorOwnerLabel = String(window.PYTORRENT?.diskMonitorOwnerLabel || \"\").trim();\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n"; +export const runtimeStateSource = " let knownLabels = [];\n let jobsPage = 0, jobsLimit = 25, jobsTotal = 0, smartHistoryExpanded = false, plannerHistoryExpanded = false;\n let automationSmartQueueStats = null;\n let peersRefreshTimer = null;\n let peersRefreshSeconds = Number(window.PYTORRENT?.peersRefreshSeconds || 0);\n // Note: Empty active peer snapshots get a short background retry loop because rTorrent can expose traffic before peer rows.\n const PEER_EMPTY_RETRY_SECONDS = 2;\n const PEER_EMPTY_RETRY_MAX_ATTEMPTS = 6;\n let peerEmptyRetryTimer = null;\n let peerEmptyRetryHash = null;\n let peerEmptyRetryAttempts = 0;\n // Note: Reverse DNS follow-up refreshes are independent from the user-selected peers auto-refresh interval.\n const REVERSE_DNS_REFRESH_SECONDS = 2;\n const REVERSE_DNS_REFRESH_MAX_ATTEMPTS = 8;\n let reverseDnsRefreshTimer = null;\n let reverseDnsRefreshInFlight = false;\n let reverseDnsRefreshAttempts = 0;\n let reverseDnsRefreshHash = null;\n let mobileReverseDnsRefreshTimer = null;\n let mobileReverseDnsRefreshAttempts = 0;\n // Note: Files tab auto-refresh is independent from the peers refresh setting and stops when files are complete.\n const FILES_AUTO_REFRESH_SECONDS = 5;\n let filesRefreshTimer = null;\n let filesRefreshInFlight = false;\n let filesAutoRefreshHash = null;\n let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);\n let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || \"default\";\n let fontFamily = window.PYTORRENT?.fontFamily || \"default\";\n let interfaceScale = Number(window.PYTORRENT?.interfaceScale || 100);\n let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);\n let trackerFaviconsEnabled = !!Number(window.PYTORRENT?.trackerFaviconsEnabled || 0);\n // Note: Reverse DNS is opt-in because PTR lookups can be slower than normal peer refreshes.\n let reverseDnsEnabled = !!Number(window.PYTORRENT?.reverseDnsEnabled || 0);\n let automationToastsEnabled = window.PYTORRENT?.automationToastsEnabled !== false && Number(window.PYTORRENT?.automationToastsEnabled ?? 1) !== 0;\n let smartQueueToastsEnabled = window.PYTORRENT?.smartQueueToastsEnabled !== false && Number(window.PYTORRENT?.smartQueueToastsEnabled ?? 1) !== 0;\n let easterEggEnabled = Number(window.PYTORRENT?.easterEggEnabled || 0) !== 0;\n let easterEggLoadingImageUrl = String(window.PYTORRENT?.easterEggLoadingImageUrl || \"\").trim();\n let easterEggClickImageUrl = String(window.PYTORRENT?.easterEggClickImageUrl || \"\").trim();\n let diskMonitorPaths = Array.isArray(window.PYTORRENT?.diskMonitorPaths) ? [...window.PYTORRENT.diskMonitorPaths] : [];\n let diskMonitorMode = window.PYTORRENT?.diskMonitorMode || \"default\";\n let diskMonitorSelectedPath = window.PYTORRENT?.diskMonitorSelectedPath || \"\";\n let diskMonitorOwnerLabel = String(window.PYTORRENT?.diskMonitorOwnerLabel || \"\").trim();\n let lastUserDiskFetchAt = 0;\n let userDiskFetchInFlight = false;\n let userDiskFetchSeq = 0;\n let activeProfileId = window.PYTORRENT?.activeProfile || null;\n let trackerSummary = {hashes:{}, trackers:[], scanned:0, errors:[]};\n let trackerSummaryStatus = 'idle';\n let trackerSummarySignature = \"\";\n let trackerSummaryTimer = null;\n let lastLabelFiltersSignature = \"\";\n let lastTrackerFiltersSignature = \"\";\n let lastMobileFiltersSignature = \"\";\n const BASE_TITLE = document.title || \"pyTorrent\";\n const lastBrowserSpeed = {down: \"0 B/s\", up: \"0 B/s\"};\n const FOOTER_STATUS_STORAGE_PREFIX = \"pytorrent.footerStatus.v2\";\n function isActiveProfilePayload(payload={}){\n const payloadProfile = Number(payload?.profile_id || 0);\n const currentProfile = Number(activeProfileId || window.PYTORRENT?.activeProfile || 0);\n return !payloadProfile || !currentProfile || payloadProfile === currentProfile;\n }\n function currentProfileStorageId(){\n return String(activeProfileId || window.PYTORRENT?.activeProfile || \"none\");\n }\n function footerStatusStorageKey(profileId=currentProfileStorageId()){\n return `${FOOTER_STATUS_STORAGE_PREFIX}.${profileId || \"none\"}`;\n }\n function clearProfileScopedFooterState(){\n // Note: Profile changes clear footer-only values immediately so old rTorrent data is never mixed with the new profile.\n [\"statSockets\", \"statRtDownloads\", \"statRtUploads\", \"statRtHttp\", \"statRtFiles\", \"statRtPort\", \"statDl\", \"statUl\", \"mobileSpeedDl\", \"mobileSpeedUl\", \"statPeakSession\", \"statPeakAllTime\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n [\"statCpu\", \"statRam\", \"statVersion\", \"statTotalDl\", \"statTotalUl\"].forEach(id => { const el = $(id); if(el) el.textContent = \"-\"; });\n lastBrowserSpeed.down = \"0 B/s\";\n lastBrowserSpeed.up = \"0 B/s\";\n updateBrowserSpeedTitle(\"0 B/s\", \"0 B/s\", 0, 0);\n }\n const FOOTER_RT_METRIC_KEYS = new Set([\"sockets\", \"rt_downloads\", \"rt_uploads\", \"rt_http\", \"rt_files\", \"rt_port\"]);\n const FOOTER_ITEM_DEFS = [\n [\"cpu\", \"CPU\"], [\"ram\", \"RAM\"], [\"usage_chart\", \"CPU/RAM chart\"], [\"disk\", \"Disk\"],\n [\"version\", \"rTorrent version\"], [\"speed_down\", \"Download speed\"], [\"speed_up\", \"Upload speed\"],\n [\"speed_peaks\", \"Peak speeds\"], [\"limits\", \"Speed limits\"], [\"totals\", \"Total transfer\"], [\"port_check\", \"Port check\"],\n [\"clock\", \"Clock\"], [\"sockets\", \"Open sockets\"], [\"rt_downloads\", \"Downloads (D)\"], [\"rt_uploads\", \"Uploads (U)\"], [\"rt_http\", \"HTTP (H)\"], [\"rt_files\", \"Files (F)\"], [\"rt_port\", \"Incoming port\"], [\"shown\", \"Shown torrents\"], [\"selected\", \"Selected torrents\"], [\"docs\", \"API docs\"]\n ];\n const DEFAULT_FOOTER_ITEMS = Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, !FOOTER_RT_METRIC_KEYS.has(key)]));\n let footerItems = {...DEFAULT_FOOTER_ITEMS, ...(window.PYTORRENT?.footerItems || {})};\n let modalLabels = new Set(), defaultDownloadPath = null;\n let hasTorrentSnapshot = false, initialLoaderDone = false, rtConfigOriginal = new Map(), rtConfigFieldTypes = new Map(), rtConfigOriginalApplyOnStart = false;\n let rtorrentStartingMessage = '';\n let rtorrentStartingTimer = null, rtorrentStartingSince = 0;\n const RTORRENT_STALE_GRACE_MS = 30000;\n let torrentSummary = null;\n let profileCache = new Map();\n let hasActiveProfile = !!window.PYTORRENT?.activeProfile;\n let firstRunSetupShown = false;\n const activeOperations = new Map();\n // Note: Keeps live filter tooltips stable while the pointer is over a filter button.\n const filterTooltipState = new WeakMap();\n\n const toastGroups = new Map();\n const preferenceSaveTimers = new Map();\n"; diff --git a/pytorrent/static/js/torrentDetailsLoader.js b/pytorrent/static/js/torrentDetailsLoader.js index 1106fc5..f90ed59 100644 --- a/pytorrent/static/js/torrentDetailsLoader.js +++ b/pytorrent/static/js/torrentDetailsLoader.js @@ -1 +1 @@ -export const torrentDetailsLoaderSource = " async function loadDetails(tab, options={}){\n const t=torrents.get(selectedHash);\n const silent = !!options.silent;\n if(tab !== 'files') clearFilesAutoRefresh();\n if(tab !== 'peers') clearReverseDnsPeerRefresh();\n if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers');\n setupPeersRefresh(tab);\n if(!t) return;\n if(tab==='general') return renderGeneral();\n if(tab==='log'){\n $('detailPane').innerHTML=`
`;\n return;\n }\n const pane=$('detailPane');\n if(!silent) pane.innerHTML=`