From b772c97d504424915e4197d060a7b0ec348d2175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 21 May 2026 22:05:08 +0200 Subject: [PATCH] temporary_link feature --- pytorrent/openapi/openapi.json | 378 ++++++++++++++++++++++++ pytorrent/routes/main.py | 261 +++++++++++++++- pytorrent/routes/torrents.py | 96 +++++- pytorrent/services/pdf_preview_links.py | 109 +++++++ pytorrent/services/rtorrent/files.py | 33 +-- pytorrent/static/js/api.js | 2 +- pytorrent/static/js/poller.js | 2 +- pytorrent/static/js/torrentDetails.js | 2 +- pytorrent/static/styles.css | 1 + requirements.txt | 1 - 10 files changed, 844 insertions(+), 41 deletions(-) create mode 100644 pytorrent/services/pdf_preview_links.py diff --git a/pytorrent/openapi/openapi.json b/pytorrent/openapi/openapi.json index 6c407fe..32e810c 100644 --- a/pytorrent/openapi/openapi.json +++ b/pytorrent/openapi/openapi.json @@ -2111,6 +2111,28 @@ "type": "object" } ] + }, + "TemporaryLinkResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": true + }, + "url": { + "type": "string", + "example": "/download/r4nd0mTemporaryToken" + }, + "expires_in": { + "type": "integer", + "example": 600 + } + }, + "required": [ + "ok", + "url", + "expires_in" + ] } }, "securitySchemes": { @@ -7101,6 +7123,362 @@ }, "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", + "description": "Resolves a short-lived in-app download token created by an API endpoint and streams the requested file or ZIP.", + "parameters": [ + { + "in": "path", + "name": "token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File stream", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/x-bittorrent": { + "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" + } + } + } + } + } + } + }, + "/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": { + "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-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" + } + } + } + } + } + } } } } diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index aeb6398..79d2fe5 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -1,10 +1,16 @@ from __future__ import annotations from pathlib import Path +from urllib.parse import quote +import queue +import tempfile +import threading +import zipfile -from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file -from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES -from ..services import auth +from flask import Blueprint, render_template, Response, request, redirect, url_for, abort, send_file, stream_with_context +from ..services.preferences import get_preferences, list_profiles, active_profile, get_profile, BOOTSTRAP_THEMES, FONT_FAMILIES +from ..services import auth, pdf_preview_links, rtorrent +from ..config import PYTORRENT_TMP_DIR from ..services.frontend_assets import asset_path # for favicon @@ -18,6 +24,141 @@ 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" + return { + "Content-Type": content_type, + "Content-Disposition": f"{safe_disposition}; filename*=UTF-8''{quote(safe)}", + "X-Content-Type-Options": "nosniff", + } + + +def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None: + if local: + try: + Path(path).unlink() + except Exception: + pass + return + rtorrent._remote_remove_staged(profile, path) + try: + tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-" + if str(path).startswith(tmp_prefix) and Path(path).exists(): + Path(path).unlink() + except Exception: + pass + + +def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes: + if local: + return Path(path).read_bytes() + return b"".join(bytes(chunk) for chunk in rtorrent.iter_remote_file_chunks(profile, path) if chunk) + + +def _safe_zip_name(name: str, fallback: str) -> str: + value = str(name or fallback).replace("\\", "/").lstrip("/") + parts = [part for part in value.split("/") if part not in ("", ".", "..")] + return "/".join(parts) or fallback + + +class _ZipStream: + def __init__(self): + self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16) + self.closed = False + + def write(self, data): + if not data: + return 0 + payload = bytes(data) + self.queue.put(payload) + return len(payload) + + def flush(self): + return None + + def close(self): + if not self.closed: + self.closed = True + self.queue.put(None) + + def writable(self): + return True + + +def _stream_torrent_files_zip(profile: dict, items: list[dict]): + writer = _ZipStream() + errors: list[BaseException] = [] + + def produce(): + try: + with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive: + used = set() + for item in items: + arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}") + base = arcname + counter = 2 + while arcname in used: + stem = Path(base).stem or "file" + suffix = Path(base).suffix + parent = str(Path(base).parent).replace(".", "", 1).strip("/") + candidate = f"{stem}-{counter}{suffix}" + arcname = f"{parent}/{candidate}" if parent else candidate + counter += 1 + used.add(arcname) + info = zipfile.ZipInfo(arcname) + info.compress_type = zipfile.ZIP_STORED + info.file_size = int(item.get("size") or 0) + with archive.open(info, "w", force_zip64=True) as dest: + for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None): + dest.write(chunk) + except BaseException as exc: + errors.append(exc) + finally: + writer.close() + + threading.Thread(target=produce, name="pytorrent-token-zip-stream", daemon=True).start() + while True: + chunk = writer.queue.get() + if chunk is None: + break + yield chunk + if errors: + raise errors[0] + + +def _send_staged_torrent_file(profile: dict, path: str, download_name: str, local: bool = False): + headers = _attachment_headers(download_name, "application/x-bittorrent") + if local: + data = Path(path).read_bytes() + _cleanup_staged_file(profile, path, local=True) + headers["Content-Length"] = str(len(data)) + return Response(data, headers=headers) + + def generate(): + try: + yield from rtorrent.iter_remote_file_chunks(profile, path) + finally: + _cleanup_staged_file(profile, path, local=False) + + return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True) + + +def _profile_for_temporary_target(target: dict): + profile_id = int(target.get("profile_id") or 0) + owner_user_id = int(target.get("user_id") or 0) + if auth.enabled() and owner_user_id != auth.current_user_id(): + abort(403) + if not auth.can_access_profile(profile_id): + abort(403) + profile = active_profile() if not profile_id else get_profile(profile_id) + if not profile: + abort(404) + return profile + + @bp.get("/favicon.ico") def favicon_ico(): response = send_from_directory( @@ -65,6 +206,120 @@ def index(): ) + + +@bp.get("/preview/pdf/") +def pdf_preview(token: str): + # Note: This route keeps browser-visible PDF links inside the app and delegates streaming to the existing rTorrent file reader. + target = pdf_preview_links.get_pdf_preview_link(token) + if not target: + abort(404) + profile_id = int(target.get("profile_id") or 0) + owner_user_id = int(target.get("user_id") or 0) + if auth.enabled() and owner_user_id != auth.current_user_id(): + abort(403) + if not auth.can_access_profile(profile_id): + abort(403) + profile = active_profile() if not profile_id else get_profile(profile_id) + if not profile: + abort(404) + item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"])) + filename = Path(item.get("download_name") or "preview.pdf").name or "preview.pdf" + if Path(filename).suffix.lower() != ".pdf": + abort(404) + size = int(item.get("size") or 0) + headers = { + "Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}", + "Content-Type": "application/pdf", + "X-Content-Type-Options": "nosniff", + } + if size > 0: + headers["Content-Length"] = str(size) + + def generate(): + yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None) + + return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True) + + +@bp.get("/download/") +def temporary_download(token: str): + # Note: UI download actions resolve API-created temporary tokens here, keeping browser-visible URLs outside /api/. + target = pdf_preview_links.get_temporary_link(token) + if not target: + abort(404) + profile = _profile_for_temporary_target(target) + kind = str(target.get("kind") or "") + + if kind == "file_download": + item = rtorrent.torrent_download_file_info(profile, target["torrent_hash"], int(target["file_index"])) + size = int(item.get("size") or 0) + headers = _attachment_headers(item.get("download_name") or "file.bin") + if size > 0: + headers["Content-Length"] = str(size) + + def generate_file(): + yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None) + + return Response(stream_with_context(generate_file()), headers=headers, direct_passthrough=True) + + if kind == "file_zip_download": + items = rtorrent.torrent_download_zip_items(profile, target["torrent_hash"], target.get("indexes")) + headers = _attachment_headers(f"{str(target['torrent_hash'])[:12]}-files.zip", "application/zip") + headers["X-PyTorrent-Download-Mode"] = "temporary-token" + return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True) + + if kind == "torrent_file_download": + item = rtorrent.export_torrent_file(profile, target["torrent_hash"]) + return _send_staged_torrent_file(profile, item["path"], item["download_name"], bool(item.get("local"))) + + if kind == "torrent_files_zip_download": + hashes = [str(item) for item in (target.get("hashes") or []) if str(item).strip()] + if not hashes: + abort(404) + staged_paths = [] + PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True) + tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR)) + tmp.close() + try: + with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + used_names = set() + for torrent_hash in hashes: + item = rtorrent.export_torrent_file(profile, torrent_hash) + staged_paths.append((item["path"], bool(item.get("local")))) + name = Path(item["download_name"]).name or f"{torrent_hash}.torrent" + base_name = name + counter = 2 + while name in used_names: + stem = Path(base_name).stem + name = f"{stem}-{counter}.torrent" + counter += 1 + used_names.add(name) + archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local")))) + response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip") + + def cleanup(): + for path, is_local in staged_paths: + _cleanup_staged_file(profile, path, is_local) + try: + Path(tmp.name).unlink() + except Exception: + pass + + response.call_on_close(cleanup) + return response + except Exception: + for path, is_local in staged_paths: + _cleanup_staged_file(profile, path, is_local) + try: + Path(tmp.name).unlink() + except Exception: + pass + raise + + abort(404) + + @bp.get("/docs") def docs(): html = f"""pyTorrent API Docs
""" diff --git a/pytorrent/routes/torrents.py b/pytorrent/routes/torrents.py index 705e147..234df0e 100644 --- a/pytorrent/routes/torrents.py +++ b/pytorrent/routes/torrents.py @@ -1,7 +1,7 @@ from __future__ import annotations from ._shared import * -from ..services import torrent_creator +from ..services import pdf_preview_links, torrent_creator from ..services.reverse_dns import attach_reverse_dns @bp.get("/torrents") @@ -105,7 +105,18 @@ def torrent_file_media_info(torrent_hash: str, file_index: int): return jsonify({"ok": False, "error": "No profile"}), 400 try: # Note: The route is additive and keeps all existing file endpoints unchanged. - return ok({"media_info": rtorrent.torrent_file_media_info(profile, torrent_hash, file_index)}) + media_info = rtorrent.torrent_file_media_info(profile, torrent_hash, file_index) + if media_info.get("kind") == "pdf": + link = pdf_preview_links.create_pdf_preview_link( + torrent_hash, + file_index, + int(profile.get("id") or 0), + int(default_user_id() or 0), + ) + # Note: The frontend receives an in-app temporary URL instead of exposing the API download endpoint in the new-tab action. + media_info["preview_url"] = url_for("main.pdf_preview", token=link["token"]) + media_info["preview_expires_in"] = link["expires_in"] + return ok({"media_info": media_info}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}), 400 @@ -199,6 +210,87 @@ 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() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + try: + # Note: The API validates the file selection before returning a short-lived in-app /download URL to the UI. + rtorrent.torrent_download_file_info(profile, torrent_hash, file_index) + link = pdf_preview_links.create_file_download_link(torrent_hash, file_index, int(profile.get("id") or 0), int(default_user_id() or 0)) + return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@bp.post("/torrents//files/download-link") +def torrent_file_download_link_from_body(torrent_hash: str): + data = request.get_json(silent=True) or {} + try: + file_index = int(data.get("file_index")) + except Exception: + return jsonify({"ok": False, "error": "file_index is required"}), 400 + return torrent_file_download_link(torrent_hash, file_index) + + +@bp.post("/torrents//files/download.zip/link") +def torrent_files_download_zip_link(torrent_hash: str): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + data = request.get_json(silent=True) or {} + try: + indexes = data.get("indexes") or None + # Note: ZIP link creation validates the requested files through the same service used by the direct download endpoint. + rtorrent.torrent_download_zip_items(profile, torrent_hash, indexes) + link = pdf_preview_links.create_file_zip_download_link(torrent_hash, indexes, int(profile.get("id") or 0), int(default_user_id() or 0)) + return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@bp.get("/torrents//torrent-file/link") +def torrent_file_export_link(torrent_hash: str): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + try: + # Note: Export availability is checked before the UI receives a temporary /download URL. + item = rtorrent.export_torrent_file(profile, torrent_hash) + _cleanup_staged_file(profile, item["path"], bool(item.get("local"))) + link = pdf_preview_links.create_torrent_file_download_link(torrent_hash, int(profile.get("id") or 0), int(default_user_id() or 0)) + return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + +@bp.post("/torrents/torrent-files.zip/link") +def torrent_files_export_zip_link(): + profile = preferences.active_profile() + if not profile: + return jsonify({"ok": False, "error": "No profile"}), 400 + data = request.get_json(silent=True) or {} + hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()] + if not hashes: + return jsonify({"ok": False, "error": "No torrents selected"}), 400 + try: + # Note: Each hash is checked before the temporary ZIP export link is returned to the UI. + staged_paths = [] + try: + for h in hashes: + item = rtorrent.export_torrent_file(profile, h) + staged_paths.append((item["path"], bool(item.get("local")))) + finally: + for path, is_local in staged_paths: + _cleanup_staged_file(profile, path, is_local) + link = pdf_preview_links.create_torrent_files_zip_download_link(hashes, int(profile.get("id") or 0), int(default_user_id() or 0)) + return ok({"url": url_for("main.temporary_download", token=link["token"]), "expires_in": link["expires_in"]}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}), 400 + + @bp.get("/torrents//files//download") def torrent_file_download(torrent_hash: str, file_index: int): profile = preferences.active_profile() diff --git a/pytorrent/services/pdf_preview_links.py b/pytorrent/services/pdf_preview_links.py new file mode 100644 index 0000000..c856d33 --- /dev/null +++ b/pytorrent/services/pdf_preview_links.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import secrets +import threading +import time + +_LINK_TTL_SECONDS = 10 * 60 +_TEMPORARY_LINKS: dict[str, dict] = {} +_TEMPORARY_LINK_LOCK = threading.Lock() + + +def _cleanup_expired(now: float | None = None) -> None: + now = time.time() if now is None else float(now) + expired = [token for token, item in _TEMPORARY_LINKS.items() if float(item.get("expires_at") or 0) <= now] + for token in expired: + _TEMPORARY_LINKS.pop(token, 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: + _cleanup_expired(now) + _TEMPORARY_LINKS[token] = { + "kind": str(kind), + "profile_id": int(profile_id), + "user_id": int(user_id), + "expires_at": now + _LINK_TTL_SECONDS, + **payload, + } + return {"token": token, "expires_in": _LINK_TTL_SECONDS} + + +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, + user_id, + {"torrent_hash": str(torrent_hash), "file_index": int(file_index)}, + ) + + +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, + user_id, + {"torrent_hash": str(torrent_hash), "file_index": int(file_index)}, + ) + + +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", + profile_id, + user_id, + {"torrent_hash": str(torrent_hash), "indexes": clean_indexes}, + ) + + +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, + user_id, + {"torrent_hash": str(torrent_hash)}, + ) + + +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, + user_id, + {"hashes": [str(item) for item in hashes]}, + ) + + +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 + with _TEMPORARY_LINK_LOCK: + _cleanup_expired() + item = _TEMPORARY_LINKS.get(clean) + return dict(item) if item else None + + +def get_pdf_preview_link(token: str) -> dict | None: + """Return a temporary PDF preview target if the link is still valid.""" + item = get_temporary_link(token) + if not item or item.get("kind") != "pdf_preview": + return None + return item diff --git a/pytorrent/services/rtorrent/files.py b/pytorrent/services/rtorrent/files.py index 90dc88d..f9ac10f 100644 --- a/pytorrent/services/rtorrent/files.py +++ b/pytorrent/services/rtorrent/files.py @@ -141,8 +141,6 @@ _MEDIA_INFO_SAMPLE_BYTES = 32 * 1024 * 1024 _MEDIA_INFO_CHUNK_BYTES = 1024 * 1024 _TEXT_PREVIEW_BYTES = 512 * 1024 _IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024 -_PDF_TEXT_BYTES = 16 * 1024 * 1024 -_PDF_TEXT_PAGES = 10 _MEDIA_INFO_TMP_DIR = BASE_DIR / "data" / "media-info-samples" @@ -296,41 +294,12 @@ def _image_file_preview(profile: dict, selected: dict, remote_path: str, max_byt return result -def _pdf_imports(): - # Note: pypdf is imported lazily so non-PDF previews do not depend on it at request time. - import sys - - try: - from pypdf import PdfReader - return PdfReader - except ModuleNotFoundError as exc: - missing = str(getattr(exc, "name", "") or "pypdf") - if missing.split(".", 1)[0] == "pypdf": - raise RuntimeError( - "Python package 'pypdf' is not importable in the application runtime. " - "Install it inside the pyTorrent virtualenv and restart the service: " - "/opt/pyTorrent/venv/bin/pip install -r /opt/pyTorrent/requirements.txt && systemctl restart pytorrent. " - f"Runtime: {sys.executable}." - ) from exc - raise RuntimeError( - f"pypdf is installed, but one of its Python dependencies is missing: {missing}. " - f"Runtime: {sys.executable}." - ) from exc - except Exception as exc: - raise RuntimeError( - "pypdf was found, but failed during import. " - f"Runtime: {sys.executable}. Details: {exc}" - ) from exc - - def _pdf_file_preview( profile: dict, selected: dict, remote_path: str, - max_bytes: int = _PDF_TEXT_BYTES, - max_pages: int = _PDF_TEXT_PAGES, ) -> dict: - # Note: The modal keeps a metadata payload here, while the frontend streams the real PDF through the existing file download route in inline mode. + # Note: pypdf is no longer required because PDFs are not parsed; the browser renders the original file stream. size = int(selected.get("size") or 0) return { **selected, diff --git a/pytorrent/static/js/api.js b/pytorrent/static/js/api.js index 2de544e..ff15cfb 100644 --- a/pytorrent/static/js/api.js +++ b/pytorrent/static/js/api.js @@ -1 +1 @@ -export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1) return downloadResponse(`/api/torrents/${encodeURIComponent(list[0])}/torrent-file`,{},`${list[0]}.torrent`,'Preparing .torrent...').catch(e=>toast(e.message,'danger'));\n return downloadResponse('/api/torrents/torrent-files.zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({hashes:list})},'pytorrent-torrents.zip','Preparing torrent ZIP...').catch(e=>toast(e.message,'danger'));\n }\n"; +export const apiSource = " async function post(url,data,method='POST'){\n const res=await fetch(url,{method,headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify(data||{})});\n const text=await res.text();\n let json;\n try{ json=JSON.parse(text); }\n catch(e){\n const clean=(text||'').replace(/<[^>]+>/g,' ').replace(/\\s+/g,' ').trim().slice(0,180);\n throw new Error(clean?`Invalid server response (${res.status}): ${clean}`:`Invalid server response (${res.status})`);\n }\n if(!res.ok || !json.ok) throw new Error(json.error||`Operation failed (${res.status})`);\n return json;\n }\n\n async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toastMessage('toast.noTorrentsSelected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toastMessage('toast.actionQueued','success',{action,parts}); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }\n function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }\n function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; }\n function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; }\n function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }\n function filenameFromResponse(res, fallback){ const cd=res.headers.get('Content-Disposition')||''; const m=cd.match(/filename\\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i); try{ return decodeURIComponent(m?.[1]||m?.[2]||fallback); }catch(e){ return m?.[1]||m?.[2]||fallback; } }\n async function openTemporaryDownload(url, data=null, method='POST'){\n const options = {method, headers:{'Accept':'application/json'}};\n if(data !== null){\n options.headers['Content-Type']='application/json';\n options.body=JSON.stringify(data || {});\n }\n const res = await fetch(url, options);\n const json = await res.json().catch(()=>({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `Download link failed (${res.status})`);\n if(!json.url) throw new Error('Download link response did not include a URL');\n // Note: API creates the temporary link, while the browser-visible download target stays under /download/.\n window.location.href = json.url;\n toastMessage('toast.downloadStarted','success');\n return json;\n }\n async function downloadResponse(url, options={}, fallback='download.bin', label='Preparing download...'){\n setBusy(true,label);\n try{\n const res=await fetch(url,options);\n if(!res.ok){ const j=await res.json().catch(()=>({})); throw new Error(j.error||`Download failed: HTTP ${res.status}`); }\n const total=Number(res.headers.get('Content-Length')||0);\n const name=filenameFromResponse(res,fallback);\n let blob;\n if(res.body){\n const reader=res.body.getReader();\n const chunks=[]; let received=0;\n while(true){\n const {done,value}=await reader.read();\n if(done) break;\n chunks.push(value); received += value.length;\n const loader=$('globalLoader');\n const span=loader?.querySelector('span:last-child');\n if(span){\n if(total){\n const pct=Math.max(0,Math.min(100,Math.round((received/total)*100)));\n span.textContent=`Downloading ${pct}%`;\n } else {\n span.textContent=`Downloading ${(received/1024/1024).toFixed(1)} MB`;\n }\n }\n }\n blob=new Blob(chunks);\n } else {\n blob=await res.blob();\n }\n const obj=URL.createObjectURL(blob);\n const a=document.createElement('a'); a.href=obj; a.download=name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(obj),1000);\n toastMessage('toast.downloadStarted','success');\n } finally { setBusy(false); }\n }\n async function downloadTorrentFiles(hashes=null){\n const list=hashes||selectedHashes();\n if(!list.length) return toastMessage('toast.noTorrentsSelected','warning');\n if(list.length===1) return openTemporaryDownload(`/api/torrents/${encodeURIComponent(list[0])}/torrent-file/link`, null, 'GET').catch(e=>toast(e.message,'danger'));\n return openTemporaryDownload('/api/torrents/torrent-files.zip/link',{hashes:list}).catch(e=>toast(e.message,'danger'));\n }\n"; diff --git a/pytorrent/static/js/poller.js b/pytorrent/static/js/poller.js index 7aa0262..213f7de 100644 --- a/pytorrent/static/js/poller.js +++ b/pytorrent/static/js/poller.js @@ -1 +1 @@ -export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),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 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); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('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}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms \u00b7 gap ${esc(rt.last_tick_gap_ms||0)} ms \u00b7 effective ${esc(rt.effective_interval_seconds||0)}s \u00b7 min ${esc(rt.configured_min_interval_seconds||0)}s \u00b7 payload ${esc(fmtBytes(rt.emitted_payload_size||0))} \u00b7 rTorrent calls ${esc(rt.rtorrent_call_count||0)} \u00b7 skipped ${esc(rt.skipped_emissions||0)} \u00b7 mode ${esc(mode)} \u00b7 adaptive ${adaptive?'on':'off'} \u00b7 ok ${rt.last_ok?'yes':'no'} \u00b7 ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ 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(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('backupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup',{name:$('backupName')?.value||'Manual backup'}); toast('Backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Backup schedule saved','success'); loadBackup();}); $('backupManager')?.addEventListener('click',async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ if(!confirm('Restore this backup and replace current app settings?')) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download`,{},'file.bin','Preparing file...').catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; +export const pollerSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||0.5),idle_interval_seconds:Number($('pollerIdle')?.value||3),error_interval_seconds:Number($('pollerError')?.value||2),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||0.5),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 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); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??0.5); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??3); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??2); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??0.5); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??1); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??30); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||30); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??5); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??5); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??10000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??1); $('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}); }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; return `duration ${esc(rt.duration_ms||rt.last_tick_ms||0)} ms \u00b7 gap ${esc(rt.last_tick_gap_ms||0)} ms \u00b7 effective ${esc(rt.effective_interval_seconds||0)}s \u00b7 min ${esc(rt.configured_min_interval_seconds||0)}s \u00b7 payload ${esc(fmtBytes(rt.emitted_payload_size||0))} \u00b7 rTorrent calls ${esc(rt.rtorrent_call_count||0)} \u00b7 skipped ${esc(rt.skipped_emissions||0)} \u00b7 mode ${esc(mode)} \u00b7 adaptive ${adaptive?'on':'off'} \u00b7 ok ${rt.last_ok?'yes':'no'} \u00b7 ticks ${esc(rt.tick_count||0)}`; }\n async function loadPollerSettings(){ 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(); function switchAppStatusPane(pane){ document.querySelectorAll('#appStatusTabs [data-appstatus-pane], #appStatusManager [data-appstatus-pane]').forEach(x=>x.classList.toggle('active',x.dataset.appstatusPane===pane)); $('appStatusManager')?.querySelectorAll('[data-appstatus-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.appstatusPanel!==pane)); } $('appStatusTabs')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('appStatusManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-appstatus-pane]'); if(tab) switchAppStatusPane(tab.dataset.appstatusPane); }); $('healthDashboardManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab && typeof setHealthPane==='function') setHealthPane(tab.dataset.healthPane); }); $('torrentStatsManager')?.addEventListener('click',e=>{ const tab=e.target.closest('[data-torrentstats-pane]'); if(tab && typeof setTorrentStatsPane==='function') setTorrentStatsPane(tab.dataset.torrentstatsPane); }); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const token=e.target.closest('.auth-token:not(.auth-token-list)'); const tokenList=e.target.closest('.auth-token-list'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(token){ await generateAuthToken(token.dataset.id); return; } if(tokenList){ await showAuthTokens(tokenList.dataset.id); return; } if(del && confirm('Delete user?')){ try{ const j=await post(`/api/auth/users/${del.dataset.id}`,{},'DELETE'); if(!j.ok) throw new Error(j.error||'Delete failed'); toast('User deleted','success'); await loadAuthUsers(); }catch(e){ toast(e.message,'danger'); } } }); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{id:$('rssFeedId')?.value||null,name:$('rssName').value,url:$('rssUrl').value,interval_minutes:$('rssInterval')?.value||30,enabled:true}); if($('rssFeedId')) $('rssFeedId').value=''; loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{id:$('rssRuleId')?.value||null,name:$('rssRuleName').value,pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null,save_path:$('rssPath').value,label:$('rssLabel').value}); if($('rssRuleId')) $('rssRuleId').value=''; loadRss();}); $('rssTestBtn')?.addEventListener('click',async()=>{try{const j=await post('/api/rss/rules/test',{feed_url:$('rssUrl').value,rule:{pattern:$('rssPattern').value,exclude_pattern:$('rssExclude')?.value||'',min_size_mb:$('rssMinSize')?.value||0,max_size_mb:$('rssMaxSize')?.value||0,category:$('rssCategory')?.value||'',quality:$('rssQuality')?.value||'',season:$('rssSeason')?.value||null,episode:$('rssEpisode')?.value||null}}); $('rssTestResult').innerHTML=table(['Title','Reason'],(j.result?.matches||[]).map(x=>[esc(x.title),esc(x.reason)]));}catch(e){toast(e.message,'danger');}}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toastMessage('toast.rssQueued','success',{queued:j.queued}); loadRss();}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('rssManager')?.addEventListener('click',async e=>{const ef=e.target.closest('.rss-edit-feed'); const er=e.target.closest('.rss-edit-rule'); const df=e.target.closest('.rss-delete-feed'); const dr=e.target.closest('.rss-delete-rule'); if(ef){const f=JSON.parse(ef.dataset.feed||'{}'); $('rssFeedId').value=f.id||''; $('rssName').value=f.name||''; $('rssUrl').value=f.url||''; $('rssInterval').value=f.interval_minutes||30;} if(er){const r=JSON.parse(er.dataset.rule||'{}'); $('rssRuleId').value=r.id||''; $('rssRuleName').value=r.name||''; $('rssPattern').value=r.pattern||''; $('rssExclude').value=r.exclude_pattern||''; $('rssMinSize').value=r.min_size_mb||''; $('rssMaxSize').value=r.max_size_mb||''; $('rssCategory').value=r.category||''; $('rssQuality').value=r.quality||''; $('rssSeason').value=r.season||''; $('rssEpisode').value=r.episode||''; $('rssPath').value=r.save_path||''; $('rssLabel').value=r.label||'';} if(df&&confirm('Delete RSS feed?')){await fetch(`/api/rss/feeds/${df.dataset.id}`,{method:'DELETE'}); loadRss();} if(dr&&confirm('Delete RSS rule?')){await fetch(`/api/rss/rules/${dr.dataset.id}`,{method:'DELETE'}); loadRss();}}); $('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('backupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup',{name:$('backupName')?.value||'Manual backup'}); toast('Backup created','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Backup schedule saved','success'); loadBackup();}); $('backupManager')?.addEventListener('click',async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ if(!confirm('Restore this backup and replace current app settings?')) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); $('statusPlannerOpen')?.addEventListener('click',()=>{ ensurePlannerToolsUI(); activateToolTab('planner'); new bootstrap.Modal($('toolsModal')).show(); }); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});\n $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toastMessage('toast.automationsApplied','success',{count:torrents,batches}); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const run=e.target.closest('.automation-run'); if(run){ setBusy(true); try{ const j=await post(`/api/automations/${run.dataset.id}/run`,{}); toastMessage('toast.automationForceRunDone','success',{count:j.result?.applied?.length}); await loadAutomations(); }catch(err){ toast(err.message,'danger'); } finally{ setBusy(false); } return; } const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); });\n document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });\n $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});\n $('smartExcludeSelectedBtn')?.addEventListener('click',openSmartQueueExclusionModal);\n $('smartExclusionSearch')?.addEventListener('input',filterSmartQueueExclusionChoices);\n $('smartExclusionSaveBtn')?.addEventListener('click',saveSmartQueueExclusionChoices);\n $('smartHistory')?.addEventListener('click',async e=>{\n const clear=e.target.closest('#smartHistoryClear');\n if(clear){\n // Note: Clear history removes only Smart Queue audit rows for the active profile.\n if(!confirm('Clear Smart Queue history?')) return;\n try{ await post('/api/smart-queue/history',{},'DELETE'); smartHistoryExpanded=false; toast('Smart Queue history cleared','success'); await loadSmartQueue(); }catch(err){ toast(err.message,'danger'); }\n return;\n }\n const btn=e.target.closest('#smartHistoryToggle'); if(!btn) return; smartHistoryExpanded=!smartHistoryExpanded; loadSmartQueue();\n });\n\n // Note: Mobile filter changes are handled by setMobileFilterValue in bootstrap.js to avoid duplicate preference writes.\n function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }\n function openRemoveModalForCurrentSelection(){\n // Note: Mobile remove uses the same Bootstrap modal as desktop, including the Remove with data switch.\n const modal=$('removeModal');\n if(!modal) return toast('Remove dialog is unavailable','danger');\n new bootstrap.Modal(modal).show();\n }\n document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ const key=mobileFilter.dataset.filter||'all'; if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); activeFilter='all'; mobileActiveFilterKey=key; } else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; } syncFilterButtons(); saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSort=e.target.closest('#mobileSortCycle'); if(mobileSort){ cycleMobileSort(); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileTorrentDownload=e.target.closest('#mobileBulkTorrentDownload'); if(mobileTorrentDownload){ downloadTorrentFiles(); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; if(mobileAct.dataset.action==='remove') openRemoveModalForCurrentSelection(); else awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const mobileModal=e.target.closest('.mobile-card [data-mobile-modal]'); if(mobileModal){ const card0=mobileModal.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; lastSelectedHash=selectedHash; scheduleRender(true); if(mobileModal.dataset.mobileModal==='label') new bootstrap.Modal($('labelModal')).show(); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=selected.size?h:null; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } updateBulkBar(); scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const torrentExport=e.target.closest('[data-download-torrent]'); if(torrentExport){ downloadTorrentFiles(); return; } const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });\n document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });\n setupDetailResizer();\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; saveTorrentSortPreference(); scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); updateBulkBar(); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeTrackerFilter=''; activeFilter=b.dataset.filter; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const tree=e.target.closest('.file-tree-refresh'); if(tree){ loadFileTree(); return; } const mediaInfo=e.target.closest('.file-media-info'); if(mediaInfo){ openMediaInfo(mediaInfo.dataset.index); return; } const oneDownload=e.target.closest('.file-download-one'); if(oneDownload){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${oneDownload.dataset.index}/download-link`).catch(err=>toast(err.message,'danger')); return; } const selectedDownload=e.target.closest('.file-download-selected'); if(selectedDownload){ downloadSelectedFiles(); return; } const allZip=e.target.closest('.file-download-zip'); if(allZip){ downloadZip(null); return; } const folder=e.target.closest('.folder-priority'); if(folder){ post(`/api/torrents/${encodeURIComponent(selectedHash)}/files/folder-priority`,{path:folder.dataset.path||'',priority:Number(folder.dataset.priority||0)}).then(()=>{toast('Folder priority updated','success');loadDetails('files');}).catch(err=>toast(err.message,'danger')); } }); document.addEventListener('click',e=>{ const cell=e.target.closest('.chunk-cell'); if(cell){ cell.classList.toggle('is-selected'); if(typeof updateChunkSelectionInfo==='function') updateChunkSelectionInfo(); return; } const refresh=e.target.closest('.chunk-refresh'); if(refresh){ loadDetails('chunks'); return; } const recheck=e.target.closest('.chunk-action-recheck'); if(recheck){ runChunkAction('recheck',{}); return; } const prio=e.target.closest('.chunk-action-prioritize'); if(prio){ const range=selectedChunkRange(); if(!range) return toast('No chunks selected','warning'); runChunkAction('prioritize_files',{...range,priority:2}); } }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('interfaceScaleRange')?.addEventListener('input',e=>applyInterfaceScale(e.target.value)); $('interfaceScaleRange')?.addEventListener('change',saveAppearancePreferences); $('resetViewPreferencesBtn')?.addEventListener('click',resetViewPreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('trackerFaviconsEnabled')?.addEventListener('change',saveTrackerFaviconsPreference); $('reverseDnsEnabled')?.addEventListener('change',saveReverseDnsPreference); $('automationToastsEnabled')?.addEventListener('change',saveNotificationPrefs); $('smartQueueToastsEnabled')?.addEventListener('change',saveNotificationPrefs); document.querySelectorAll('.disk-monitor-mode').forEach(input=>input.addEventListener('change',async e=>{ diskMonitorMode=e.target.value||'default'; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath && diskMonitorPaths.length) diskMonitorSelectedPath=diskMonitorPaths[0]; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); })); $('diskMonitorSelectedPath')?.addEventListener('change',async e=>{ diskMonitorSelectedPath=e.target.value||''; if(diskMonitorSelectedPath) diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('addDiskPathBtn')?.addEventListener('click',async()=>{ const p=($('diskMonitorPathInput')?.value||'').trim(); if(!p) return; if(!diskMonitorPaths.includes(p)) diskMonitorPaths.push(p); if(!diskMonitorSelectedPath) diskMonitorSelectedPath=p; if(diskMonitorMode==='default') diskMonitorMode='selected'; if($('diskMonitorPathInput')) $('diskMonitorPathInput').value=''; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('diskMonitorPaths')?.addEventListener('click',async e=>{ const use=e.target.closest('.disk-path-select'); if(use){ diskMonitorSelectedPath=use.dataset.path||''; diskMonitorMode='selected'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); return; } const btn=e.target.closest('.disk-path-remove'); if(!btn) return; diskMonitorPaths=diskMonitorPaths.filter(p=>p!==btn.dataset.path); if(diskMonitorSelectedPath===btn.dataset.path) diskMonitorSelectedPath=diskMonitorPaths[0]||''; if(diskMonitorMode==='selected' && !diskMonitorSelectedPath) diskMonitorMode='default'; renderDiskMonitorPaths(); await saveDiskMonitorPrefs(); }); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);\n document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();downloadTorrentFiles();return;} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s' && !(e.ctrlKey||e.metaKey))runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });\n $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});\n $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));\n\n $('toolsModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(false));\n // Note: Torrent add modal and drag/drop upload handling moved to torrentAdd.js.\n const mbpsToKib=mbps=>mbps?Math.round((Number(mbps)*1000000/8)/1024):0;\n const kibToMbps=kib=>kib?Math.round((Number(kib)*1024*8)/1000000):0;\n function setLimitSliderMax(slider,mbps){ if(slider && mbps>Number(slider.max||0)) slider.max=String(mbps); }\n function setLimitValue(targetId,kib){ const input=$(targetId); if(input) input.value=Math.max(0,Math.round(Number(kib)||0)); }\n function updateLimitSlider(slider){ if(!slider) return; const input=$(slider.dataset.target); const out=$(slider.dataset.output); const mbps=kibToMbps(Number(input?.value||0)); setLimitSliderMax(slider,mbps); slider.value=String(mbps); if(out) out.textContent=mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function updateLimitSliders(){ document.querySelectorAll('.limit-slider').forEach(updateLimitSlider); }\n function syncLimitInputFromSlider(slider){ const mbps=Number(slider.value||0); setLimitValue(slider.dataset.target,mbpsToKib(mbps)); updateLimitSlider(slider); }\n document.querySelectorAll('.limit-preset').forEach(b=>b.addEventListener('click',()=>{const kib=mbpsToKib(Number(b.dataset.mbps||0));setLimitValue('limitDown',kib);setLimitValue('limitUp',kib);updateLimitSliders();}));\n document.querySelectorAll('.limit-slider').forEach(slider=>slider.addEventListener('input',()=>syncLimitInputFromSlider(slider)));\n ['limitDown','limitUp'].forEach(id=>$(id)?.addEventListener('input',updateLimitSliders));\n $('saveSpeedBtn')?.addEventListener('click',async()=>{const btn=$('saveSpeedBtn');buttonBusy(btn,true);setBusy(true);try{await post('/api/speed/limits',{down:Math.round(Number($('limitDown').value||0)*1024),up:Math.round(Number($('limitUp').value||0)*1024)});toast('Speed limits queued','success');bootstrap.Modal.getInstance($('speedModal'))?.hide();}catch(e){toast(e.message,'danger');}finally{buttonBusy(btn,false);setBusy(false);}}); $('speedModal')?.addEventListener('show.bs.modal',()=>{setLimitValue('limitDown',lastLimits.down?Math.round(lastLimits.down/1024):0);setLimitValue('limitUp',lastLimits.up?Math.round(lastLimits.up/1024):0);updateLimitSliders();});\n // Note: rTorrent profile management was moved to profiles.js so poller.js only keeps polling and tools wiring.\n $('themeToggle')?.addEventListener('click',async()=>{const cur=document.documentElement.dataset.bsTheme==='dark'?'light':'dark';document.documentElement.dataset.bsTheme=cur;await post('/api/preferences',{theme:cur}).catch(()=>{});}); $('mobileToggle')?.addEventListener('click',()=>{document.body.classList.toggle('mobile-mode-manual');syncMobileMode();}); window.addEventListener('resize',()=>syncMobileMode(),{passive:true}); syncMobileMode();\n"; diff --git a/pytorrent/static/js/torrentDetails.js b/pytorrent/static/js/torrentDetails.js index 97d2801..3350e09 100644 --- a/pytorrent/static/js/torrentDetails.js +++ b/pytorrent/static/js/torrentDetails.js @@ -1 +1 @@ -export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
${label}${value}
`).join('');\n $('detailPane').innerHTML=`\n
\n
\n
${esc(t.name||'-')}
${esc(t.status||'-')}
\n
Directory${esc(t.path||'-')}
\n
Full data path${esc(fullPath)}
\n
\n
Hash${esc(t.hash||'-')}
\n
\n
${cards}
\n
Labels${labels}
Ratio rule${ratioGroup}
Message${esc(t.message||'-')}
`;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download`,{},'file.bin','Preparing file...').catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await downloadResponse(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({indexes})},`${selectedHash.slice(0,12)}-files.zip`,'Preparing ZIP...');\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
${esc(label)}${mediaInfoValue(value)}
`).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
Detected metadata
${rows}
`;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
      ${raw || '
    • No metadata was returned for the sampled part of this file.
    • '}
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n if(!selectedHash || info.index === undefined || info.index === null) return '';\n const hash = encodeURIComponent(selectedHash);\n const index = encodeURIComponent(info.index);\n return `/api/torrents/${hash}/files/${index}/download?disposition=inline`;\n }\n function renderPdfPreview(info){\n // Note: PDF preview now uses the browser PDF renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing torrent hash or file index for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `
    PDF preview${esc(size)} · rendered by your browser
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=`
      ${fileTreeNode(j.tree||{})}
    `; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `; return; } const pane=$('detailPane'); pane.innerHTML=`
    Loading ${esc(tab)}...
    `; try{ const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`; const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='chunks') renderChunks(json.chunks||{}); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`
    ${esc(e.message)}
    `;} }\n"; +export const torrentDetailsSource = " function formatDateTime(seconds){ const n=Number(seconds||0); if(!n) return '-'; try{ return new Date(n*1000).toLocaleString(); }catch(e){ return '-'; } }\n function joinRemotePath(base,name){\n const b=String(base||'').trim();\n const n=String(name||'').trim();\n if(!b && !n) return '-';\n if(!n) return b || '-';\n if(!b) return n;\n return `${b.replace(/\\/+$/,'')}/${n.replace(/^\\/+/,'')}`;\n }\n function renderGeneral(){\n const t=torrents.get(selectedHash);\n if(!t){ $('detailPane').innerHTML='Select a torrent.'; return; }\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ') || '-';\n const ratioGroup=t.ratio_group ? `${esc(t.ratio_group)}` : 'Not assigned';\n const statusClass=t.status==='Seeding'?'success':t.status==='Downloading'?'primary':t.status==='Checking'?'warning':t.status==='Paused'?'secondary':t.status==='Stopped'?'dark':'secondary';\n const fullPath=joinRemotePath(t.path,t.name);\n const cards=[\n ['Size', esc(t.size_h||'-')],\n ['Downloaded', esc(t.down_total_h||'-')],\n ['Uploaded', esc(t.up_total_h||'-')],\n ['Ratio', esc(t.ratio??'-')],\n ['Download speed', esc(t.down_rate_h||'-')],\n ['Upload speed', esc(t.up_rate_h||'-')],\n ['Seeds / Peers', `${esc(t.seeds??0)} / ${esc(t.peers??0)}`],\n ['ETA', esc(t.eta_h||'-')],\n ['Added', esc(formatDateTime(t.created))],\n ['Priority', esc(t.priority??'-')],\n ].map(([label,value])=>`
    ${label}${value}
    `).join('');\n $('detailPane').innerHTML=`\n
    \n
    \n
    ${esc(t.name||'-')}
    ${esc(t.status||'-')}
    \n
    Directory${esc(t.path||'-')}
    \n
    Full data path${esc(fullPath)}
    \n
    \n
    Hash${esc(t.hash||'-')}
    \n
    \n
    ${cards}
    \n
    Labels${labels}
    Ratio rule${ratioGroup}
    Message${esc(t.message||'-')}
    `;\n }\n const FILE_PRIORITY_LABELS = {0: \"Skip\", 1: \"Normal\", 2: \"High\"};\n function priorityClass(priority){ priority=Number(priority||0); return priority===2?\"text-bg-success\":priority===0?\"text-bg-secondary\":\"text-bg-primary\"; }\n function renderFilePrioritySelect(f){ const p=Number(f.priority||0); return ``; }\n function selectedFileIndexes(){ return [...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>Number(cb.dataset.index)); }\n function downloadSelectedFiles(){\n if(!selectedHash) return;\n const indexes=selectedFileIndexes();\n if(!indexes.length) return toastMessage('toast.noFilesSelected','warning');\n if(indexes.length===1){ openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${indexes[0]}/download-link`).catch(e=>toast(e.message,'danger')); return; }\n downloadZip(indexes);\n }\n async function downloadZip(indexes=null){\n if(!selectedHash) return;\n try{\n await openTemporaryDownload(`/api/torrents/${encodeURIComponent(selectedHash)}/files/download.zip/link`, {indexes});\n }catch(e){ toast(e.message,'danger'); }\n }\n\n function mediaInfoValue(value){\n const text = value === null || value === undefined || value === '' ? '-' : String(value);\n return esc(text);\n }\n function mediaInfoSummaryCards(info){\n // Note: Summary cards show the most useful hachoir fields while keeping the full raw list below.\n const summary = info.summary || {};\n const cards = [\n ['Duration', summary.duration],\n ['Bit rate', summary.bit_rate],\n ['Resolution', summary.width && summary.height ? `${summary.width} × ${summary.height}` : null],\n ['Frame rate', summary.frame_rate],\n ['Audio', [summary.channels, summary.sample_rate].filter(Boolean).join(' · ')],\n ['Codec / compression', summary.compression],\n ['Producer', summary.producer],\n ['Created', summary.creation_date],\n ];\n return cards.map(([label,value]) => `
    ${esc(label)}${mediaInfoValue(value)}
    `).join('');\n }\n function mediaInfoFieldsTable(info){\n const rows = (info.fields || []).slice(0, 160).map(field => `${esc(field.key)}${esc(field.value)}`).join('');\n if(rows) return `
    Detected metadata
    ${rows}
    `;\n const raw = (info.raw || []).slice(0, 80).map(line => `
  • ${esc(line)}
  • `).join('');\n return `
    Raw parser output
      ${raw || '
    • No metadata was returned for the sampled part of this file.
    • '}
    `;\n }\n function ensureMediaInfoModal(){\n let modal = $('mediaInfoModal');\n if(modal) return modal;\n // Note: The modal is created lazily so existing templates and old modals stay untouched.\n modal = document.createElement('div');\n modal.id = 'mediaInfoModal';\n modal.className = 'modal fade media-info-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    File info
    Loading file info...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function mediaInfoSubtitle(info){\n if(info.kind === 'pdf'){\n const sizeText = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n return `${info.path || 'File'} · ${sizeText} · inline PDF preview`;\n }\n const sampleText = `${fmtBytes(info.sample_bytes || 0)} / ${fmtBytes(info.sample_limit || 0)} sample${info.partial ? ' · partial preview' : ''}`;\n return `${info.path || 'File'} · ${sampleText}`;\n }\n function renderTextPreview(info){\n const text = esc(info.text || '');\n const note = info.partial ? `
    Preview truncated to ${esc(fmtBytes(info.sample_limit || 0))}. Download the file to read the full content.
    ` : '';\n return `${note}
    ${text || 'No text content was returned.'}
    `;\n }\n function renderImagePreview(info){\n if(info.error){\n return `
    Image preview unavailable${esc(info.error)}
    `;\n }\n return `
    \"${esc(info.path
    ${esc(info.mime_type || 'image')} · ${esc(fmtBytes(info.sample_bytes || 0))}
    `;\n }\n function mediaInfoPdfUrl(info){\n // Note: PDF preview links are created by the backend as short-lived app URLs, so the new-tab button does not expose /api/.\n return String(info.preview_url || '');\n }\n function renderPdfPreview(info){\n // Note: PDF preview uses the browser renderer, preserving images and page layout instead of flattening books to extracted text.\n const src = mediaInfoPdfUrl(info);\n const downloadButton = ``;\n const openButton = src ? ` Open in new tab` : '';\n if(!src){\n return `
    PDF preview unavailableMissing temporary app link for inline preview.
    ${downloadButton}
    `;\n }\n const title = esc(info.path || 'PDF preview');\n const size = info.size_h || (info.size ? fmtBytes(info.size) : 'unknown size');\n const expires = info.preview_expires_in ? ` · temporary link: ${Math.round(Number(info.preview_expires_in) / 60)} min` : '';\n return `
    PDF preview${esc(size)} · rendered by your browser${esc(expires)}
    ${openButton}${downloadButton}
    Inline PDF preview is not availableYour browser blocked the embedded viewer. Open it in a new tab or download the file.
    ${openButton}${downloadButton}
    `;\n }\n function renderMediaInfoModal(info){\n const body = $('mediaInfoBody');\n const subtitle = $('mediaInfoSubtitle');\n if(!body) return;\n if(subtitle) subtitle.textContent = mediaInfoSubtitle(info);\n if(info.kind === 'text'){\n body.innerHTML = `
    ${mediaInfoSummaryCards({...info, summary:{duration:null, bit_rate:null, compression:info.encoding, producer:`${info.line_count || 0} line(s)`, creation_date:null}})}
    ${renderTextPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'image'){\n body.innerHTML = `${renderImagePreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.kind === 'pdf'){\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${renderPdfPreview(info)}${mediaInfoFieldsTable(info)}`;\n return;\n }\n if(info.error){\n body.innerHTML = `
    File info unavailable
    ${esc(info.error)}
    `;\n return;\n }\n body.innerHTML = `
    ${mediaInfoSummaryCards(info)}
    ${mediaInfoFieldsTable(info)}`;\n }\n async function openMediaInfo(index){\n if(!selectedHash) return;\n const modal = ensureMediaInfoModal();\n $('mediaInfoSubtitle').textContent = 'Reading a bounded file sample...';\n $('mediaInfoBody').innerHTML = '
    Loading file info...
    ';\n new bootstrap.Modal(modal).show();\n try{\n const res = await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/${encodeURIComponent(index)}/mediainfo`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n renderMediaInfoModal(json.media_info || {});\n }catch(e){\n $('mediaInfoBody').innerHTML = `
    File info failed
    ${esc(e.message)}
    `;\n }\n }\n\n function renderFiles(files){\n const pane=$('detailPane');\n const rows=(files||[]).map(f=>`${esc(f.path)}${esc(f.size_h)}${progressBar(f.progress ?? 0, 'file-progress')}${esc(FILE_PRIORITY_LABELS[Number(f.priority||0)]||f.priority)}${renderFilePrioritySelect(f)}
    `).join('');\n // Note: Files use the same responsive table wrapper as peers to keep wide paths usable on small screens.\n pane.innerHTML=`
    Priority
    Download
    Changes are applied immediately in rTorrent.
    ${rows || ''}
    PathSizeDonePrioritySet priorityActions
    No files.
    `;\n }\n function fileTreeNode(node){\n const children=(node.children||[]).map(fileTreeNode).join('');\n if(node.type==='file') return `
  • ${esc(node.name||node.path)} ${esc(node.size_h||'')}
  • `;\n return `
  • ${esc(node.name||'Files')} ${esc(node.size_h||'')}
      ${children}
  • `;\n }\n async function loadFileTree(){\n if(!selectedHash) return;\n const box=$('fileTreePanel');\n if(!box) return;\n box.classList.toggle('d-none');\n if(box.classList.contains('d-none')) return;\n box.innerHTML=' Loading tree...';\n try{ const j=await (await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/tree`)).json(); if(!j.ok) throw new Error(j.error||'Tree failed'); box.innerHTML=`
      ${fileTreeNode(j.tree||{})}
    `; }\n catch(e){ box.innerHTML=`
    ${esc(e.message)}
    `; }\n }\n async function setFilePriorities(items){\n if(!selectedHash || !items.length) return;\n setBusy(true);\n try{\n const res=await fetch(`/api/torrents/${encodeURIComponent(selectedHash)}/files/priority`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:items})});\n const j=await res.json();\n if(!j.ok || (j.errors&&j.errors.length)) throw new Error(j.errors?.[0]?.error || j.error || 'Priority update failed');\n toast(`Updated ${j.updated?.length||items.length} file priority item(s)`,'success');\n await loadDetails('files');\n }catch(e){ toast(e.message,'danger'); } finally{ setBusy(false); }\n }\n\n const CHUNK_DENSITY_OPTIONS = {\n compact: {label: 'Compact', maxCells: 2400},\n normal: {label: 'Normal', maxCells: 1400},\n detailed: {label: 'Detailed', maxCells: 700},\n };\n const CHUNK_FILTER_OPTIONS = [\n ['all', 'All'],\n ['problem', 'Missing + partial'],\n ['missing', 'Missing'],\n ['partial', 'Partial'],\n ['seen', 'Seen by peers'],\n ['complete', 'Complete'],\n ];\n let chunkFilterMode = localStorage.getItem('chunkFilterMode') || 'all';\n let chunkDensityMode = localStorage.getItem('chunkDensityMode') || 'normal';\n let lastChunkData = null;\n\n function chunkMaxCellsForDensity(){\n // Note: Density changes the API grouping level and the CSS cell size together.\n return CHUNK_DENSITY_OPTIONS[chunkDensityMode]?.maxCells || CHUNK_DENSITY_OPTIONS.normal.maxCells;\n }\n function chunkCellsForFilter(cells){\n const list = Array.isArray(cells) ? cells : [];\n if(chunkFilterMode === 'all') return list;\n if(chunkFilterMode === 'problem') return list.filter(cell => ['missing','partial'].includes(cell.status));\n return list.filter(cell => cell.status === chunkFilterMode);\n }\n function chunkStatusLabel(status){\n return ({complete:'Complete', partial:'Partial', missing:'Missing', seen:'Seen by peers'}[status] || 'Unknown');\n }\n function chunkCellTitle(cell){\n const first = cell.first_chunk ?? '-';\n const last = cell.last_chunk ?? first;\n const pct = Number(cell.percent||0).toFixed(1).replace(/\\.0$/,'');\n const completed = Number(cell.completed ?? 0);\n const total = Number(cell.total ?? cell.unit_count ?? 1);\n const grouped = cell.grouped ? `Grouped visual cell: ${cell.unit_count || 1} piece(s)` : 'Single piece';\n return [\n `Pieces: ${first}-${last}`,\n `Status: ${chunkStatusLabel(cell.status)}`,\n `Progress: ${pct}%`,\n `Complete pieces: ${completed}/${total}`,\n grouped,\n ].join(' | ');\n }\n function chunkCellMarkup(cell){\n const pct = Math.max(0, Math.min(100, Number(cell.percent || 0)));\n const cls = `chunk-cell chunk-${esc(cell.status || 'missing')}${cell.grouped ? ' is-grouped' : ''}`;\n return ``;\n }\n function renderChunkLegend(summary){\n const items=[['complete','Complete'],['partial','Partial'],['missing','Missing'],['seen','Seen by peers']];\n return items.map(([key,label])=>`${label} ${esc(summary?.[key]??0)}`).join('');\n }\n function renderChunkControls(){\n const filters = CHUNK_FILTER_OPTIONS.map(([value,label]) => ``).join('');\n const densities = Object.entries(CHUNK_DENSITY_OPTIONS).map(([value,cfg]) => ``).join('');\n return `
    `;\n }\n function selectedChunkRange(){\n const selected=[...document.querySelectorAll('#detailPane .chunk-cell.is-selected')].map(el=>({first:Number(el.dataset.firstChunk||0),last:Number(el.dataset.lastChunk||0)}));\n if(!selected.length) return null;\n return {first_chunk:Math.min(...selected.map(x=>x.first)),last_chunk:Math.max(...selected.map(x=>x.last)),count:selected.length};\n }\n function updateChunkSelectionInfo(){\n const info=$('chunkSelectionInfo');\n if(!info) return;\n const range=selectedChunkRange();\n const filteredCount=document.querySelectorAll('#detailPane .chunk-cell').length;\n const totalCount=lastChunkData?.cells?.length || 0;\n if(range){\n info.textContent=`Selected ${range.count} cell(s), pieces ${range.first_chunk}-${range.last_chunk}.`;\n return;\n }\n const filterText=chunkFilterMode === 'all' ? '' : ` Showing ${filteredCount}/${totalCount} cell(s).`;\n info.textContent=`Select one or more visual cells to prioritize files that overlap that range.${filterText}`;\n }\n function renderChunks(data){\n const pane=$('detailPane');\n const chunks=data||{};\n lastChunkData=chunks;\n const allCells=chunks.cells||[];\n const cells=chunkCellsForFilter(allCells);\n const grouped=chunks.grouped?'grouped for performance':'';\n const meta=[\n ['Piece size', chunks.chunk_size_h || '-'],\n ['Pieces', chunks.size_chunks ?? 0],\n ['Complete pieces', chunks.completed_chunks ?? 0],\n ['Hashed pieces', chunks.chunks_hashed ?? 0],\n ['Visual cells', chunks.visual_cells ?? allCells.length],\n ].map(([label,value])=>`
    ${esc(label)}${esc(value)}
    `).join('');\n pane.innerHTML=`\n
    \n
    \n
    Chunks ${grouped}
    \n
    \n \n \n \n
    \n
    \n
    ${meta}
    \n
    \n
    ${renderChunkLegend(chunks.summary||{})}
    \n ${renderChunkControls()}\n
    \n
    \n
    ${cells.map(chunkCellMarkup).join('') || '
    No chunk cells for this filter.
    '}
    \n
    `;\n updateChunkSelectionInfo();\n }\n async function runChunkAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/chunks/${action}`,payload);\n toast(j.message || appMessage('toast.chunkActionDone',{action}),'success');\n await loadDetails('chunks');\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n document.addEventListener('change', e=>{\n const filter=e.target.closest('#chunkFilterMode');\n if(filter){\n chunkFilterMode=filter.value || 'all';\n localStorage.setItem('chunkFilterMode', chunkFilterMode);\n if(lastChunkData && activeTab()==='chunks') renderChunks(lastChunkData);\n return;\n }\n const density=e.target.closest('#chunkDensityMode');\n if(density){\n chunkDensityMode=density.value || 'normal';\n localStorage.setItem('chunkDensityMode', chunkDensityMode);\n if(activeTab()==='chunks') loadDetails('chunks');\n }\n });\n function peerBadges(p){\n const badges=[];\n if(p.encrypted) badges.push('enc');\n if(p.incoming) badges.push('in');\n if(p.snubbed) badges.push('snub');\n if(p.banned) badges.push('ban');\n return badges.join(' ') || '-';\n }\n function peerHostCell(p){\n const host=String(p.host||'').trim();\n if(host) return `${esc(host)}`;\n if(p.host_pending) return 'resolving';\n return '-';\n }\n function renderPeers(peers){\n const headers=['Flag','IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Country','City','Client','%','DL','UL','Port','Flags');\n const rows=(peers||[]).map(p=>{\n const row=[flag(p.country_iso),`${esc(p.ip)}`];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress peer-progress-wide'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p));\n return row;\n });\n $('detailPane').innerHTML=responsiveTable(headers,rows,'peers-table');\n }\n function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n async function loadDetails(tab){ const t=torrents.get(selectedHash); if($('peersRefreshBox')) $('peersRefreshBox').classList.toggle('d-none', tab!=='peers'); setupPeersRefresh(tab); if(!t)return; if(tab==='general') return renderGeneral(); if(tab==='log'){ $('detailPane').innerHTML=`
    ${esc(t.message||'No logs')}
    `; return; } const pane=$('detailPane'); pane.innerHTML=`
    Loading ${esc(tab)}...
    `; try{ const detailUrl = tab==='chunks' ? `/api/torrents/${encodeURIComponent(selectedHash)}/chunks?max_cells=${chunkMaxCellsForDensity()}` : `/api/torrents/${encodeURIComponent(selectedHash)}/${tab}`; const res=await fetch(detailUrl,{headers:{'Accept':'application/json'}}); const text=await res.text(); let json; try{ json=JSON.parse(text); }catch(parseErr){ throw new Error(`Invalid API response for ${tab}. HTTP ${res.status}`); } if(!res.ok || !json.ok) throw new Error(json.error||`HTTP ${res.status}`); if(tab!==activeTab()) return; if(tab==='files') renderFiles(json.files||[]); if(tab==='chunks') renderChunks(json.chunks||{}); if(tab==='peers') renderPeers(json.peers||[]); if(tab==='trackers') renderTrackers(json.trackers||[]); }catch(e){pane.innerHTML=`
    ${esc(e.message)}
    `;} }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index f52a22f..b9dc43a 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -4601,6 +4601,7 @@ body, border: 1px solid var(--bs-border-color); border-radius: 0.9rem; height: min(72vh, 860px); + margin-bottom: 1rem; min-height: 520px; overflow: hidden; } diff --git a/requirements.txt b/requirements.txt index ea1ebce..7c3dcb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,3 @@ psutil>=5.9 simple-websocket>=1.0 gunicorn>=22.0 hachoir>=3.3 -pypdf>=4.3