diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index 7c52ce8..01002e5 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -4,21 +4,89 @@ import json from datetime import datetime, timedelta, timezone from typing import Any from ..db import connect, utcnow, default_user_id -from . import auth, rtorrent +from . import auth DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000} VALID_RETENTION_MODES = {"days", "lines", "both", "manual"} +MAX_DETAIL_TEXT = 4000 +MAX_DETAIL_ITEMS = 200 def _user_id(user_id: int | None = None) -> int: return int(user_id or auth.current_user_id() or default_user_id()) +def _json_safe(value: Any, depth: int = 0) -> Any: + """Convert operation details to JSON-safe data without dropping the whole payload on one bad value.""" + if depth > 8: + return str(value)[:MAX_DETAIL_TEXT] + if value is None or isinstance(value, (bool, int, float, str)): + if isinstance(value, str) and len(value) > MAX_DETAIL_TEXT: + return value[:MAX_DETAIL_TEXT] + "..." + return value + if isinstance(value, bytes): + return f"" + if isinstance(value, (list, tuple, set)): + data = list(value) + safe = [_json_safe(item, depth + 1) for item in data[:MAX_DETAIL_ITEMS]] + if len(data) > MAX_DETAIL_ITEMS: + safe.append({"truncated_items": len(data) - MAX_DETAIL_ITEMS}) + return safe + if isinstance(value, dict): + items = list(value.items()) + safe = {str(k): _json_safe(v, depth + 1) for k, v in items[:MAX_DETAIL_ITEMS]} + if len(items) > MAX_DETAIL_ITEMS: + safe["truncated_keys"] = len(items) - MAX_DETAIL_ITEMS + return safe + return str(value)[:MAX_DETAIL_TEXT] + + def _details(value: dict | None = None) -> str: + """Serialize details defensively so partial non-serializable values do not erase the log details.""" try: - return json.dumps(value or {}, ensure_ascii=False, sort_keys=True) - except Exception: - return "{}" + return json.dumps(_json_safe(value or {}), ensure_ascii=False, sort_keys=True) + except Exception as exc: + return json.dumps({"serialization_error": str(exc), "raw_type": type(value).__name__}, ensure_ascii=False) + + +def _compact_detail_value(value: Any) -> str: + """Build a readable one-line value for the Details column while keeping full JSON separately.""" + if value in (None, ""): + return "" + if isinstance(value, (list, tuple)): + if not value: + return "" + return f"{len(value)} item(s)" + if isinstance(value, dict): + if not value: + return "" + return f"{len(value)} field(s)" + text = str(value) + return text if len(text) <= 160 else text[:157] + "..." + + +def _details_summary(details: dict) -> str: + """Summarize important detail fields without hiding the full details_json payload.""" + priority = [ + "status", "job_id", "attempt", "attempts", "count", "hash_count", "action", + "source", "source_label", "directory", "label", "target_path", "remove_data", + "move_data", "keep_seeding", "error", "error_count", "result_count", + ] + parts: list[str] = [] + for key in priority: + if key in details: + value = _compact_detail_value(details.get(key)) + if value: + parts.append(f"{key}: {value}") + for key, raw in details.items(): + if key in priority: + continue + value = _compact_detail_value(raw) + if value: + parts.append(f"{key}: {value}") + if len(parts) >= 10: + break + return ", ".join(parts) def _row_to_public(row: dict) -> dict: @@ -27,7 +95,7 @@ def _row_to_public(row: dict) -> dict: item["details"] = json.loads(item.get("details_json") or "{}") except Exception: item["details"] = {} - item["details_h"] = ", ".join(f"{k}: {v}" for k, v in item["details"].items() if v not in (None, "")) + item["details_h"] = _details_summary(item["details"]) return item @@ -74,6 +142,7 @@ def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> di def record(profile_id: int | None, event_type: str, message: str, *, severity: str = "info", source: str = "system", torrent_hash: str | None = None, torrent_name: str | None = None, action: str | None = None, details: dict | None = None, user_id: int | None = None) -> int: + """Insert one operation log row and keep all details in JSON-safe form.""" now = utcnow() user_id = _user_id(user_id) with connect() as conn: @@ -87,48 +156,125 @@ def record(profile_id: int | None, event_type: str, message: str, *, severity: s return int(cur.lastrowid) +def _job_event_type(status: str) -> str: + """Map worker states to explicit operation log event types without changing old done/failed names.""" + return { + "queued": "job_queued", + "started": "job_started", + "done": "job_done", + "failed": "job_failed", + "retry": "job_retry", + "cancelled": "job_cancelled", + "timeout": "job_timeout", + "resubmitted": "job_resubmitted", + "forced": "job_forced", + }.get(str(status), "job_event") + + +def _job_severity(status: str) -> str: + """Use severity consistently for filtering and badge rendering.""" + if status in {"failed", "timeout"}: + return "danger" + if status in {"retry", "resubmitted", "cancelled", "forced"}: + return "warning" + return "info" + + +def _job_action_label(action: str) -> str: + """Return a stable human-readable action label for log messages.""" + labels = { + "add_magnet": "Magnet link", + "add_torrent_raw": "Torrent file", + "set_label": "Set label", + "set_ratio_group": "Set ratio group", + "set_limits": "Set speed limits", + "smart_queue_check": "Smart Queue check", + } + return labels.get(str(action or ""), str(action or "job")) + + +def _result_summary(result: dict) -> dict: + """Extract compact result counters while preserving full result in details.""" + result = result or {} + results = result.get("results") if isinstance(result.get("results"), list) else [] + errors = result.get("errors") if isinstance(result.get("errors"), list) else [] + ignored_errors = result.get("ignored_errors") if isinstance(result.get("ignored_errors"), list) else [] + return { + "result_count": len(results) if results is not None else result.get("count"), + "error_count": len(errors or []) + len(ignored_errors or []), + } + + def record_job_event(profile_id: int, action: str, status: str, payload: dict | None, result: dict | None = None, error: str = "", job_id: str | None = None, user_id: int | None = None) -> None: + """Record queued, running and terminal job states with per-torrent context when available.""" payload = payload or {} result = result or {} hashes = payload.get("hashes") or [] ctx = payload.get("job_context") or {} items = ctx.get("items") or [] by_hash = {str(item.get("hash")): item for item in items if item} - event_type = "job_done" if status == "done" else "job_failed" if status == "failed" else "job_started" - severity = "danger" if status == "failed" else "info" + event_type = _job_event_type(str(status)) + severity = _job_severity(str(status)) + context_source = str(ctx.get("source") or payload.get("source") or "user") + source_label = str(ctx.get("rule_name") or ctx.get("source") or context_source) + source = "job" + base_details = { + "job_id": job_id, + "status": status, + "source": context_source, + "source_label": source_label, + "directory": payload.get("directory"), + "label": payload.get("label"), + "target_path": ctx.get("target_path") or payload.get("path"), + "remove_data": ctx.get("remove_data") or payload.get("remove_data"), + "move_data": ctx.get("move_data") or payload.get("move_data"), + "keep_seeding": payload.get("keep_seeding"), + "hash_count": len(hashes), + "error": error, + "result": result, + **_result_summary(result), + } if action in {"add_magnet", "add_torrent_raw"}: name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300] - # Note: Keep the internal action name stable, but show a user-facing label instead of raw worker identifiers. - source_label = "Torrent file" if action == "add_torrent_raw" else "Magnet link" - status_label = {"started": "queued", "done": "added", "failed": "failed"}.get(str(status), str(status)) - msg = f"{source_label} {status_label}: {name}" - record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id) + status_label = {"queued": "queued", "started": "started", "done": "added", "failed": "failed", "retry": "retry scheduled", "cancelled": "cancelled"}.get(str(status), str(status)) + msg = f"{_job_action_label(action)} {status_label}: {name}" + record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source=source, action=action, details=base_details, user_id=user_id) return if not hashes: - record(profile_id, event_type, f"{action} {status}", severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "error": error, "result": result}, user_id=user_id) + record(profile_id, event_type, f"{_job_action_label(action)} {status}", severity=severity, source=source, action=action, details=base_details, user_id=user_id) return for h in hashes: item = by_hash.get(str(h)) or {} name = str(item.get("name") or h) - record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{action} {status}: {name}", severity=severity, source="job", torrent_hash=str(h), torrent_name=name, action=action, details={"job_id": job_id, "status": status, "error": error, "result": result, "target_path": ctx.get("target_path"), "remove_data": ctx.get("remove_data")}, user_id=user_id) + row_details = {**base_details, "item": item} + record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{_job_action_label(action)} {status}: {name}", severity=severity, source=source, torrent_hash=str(h), torrent_name=name, action=action, details=row_details, user_id=user_id) + + +def record_worker_event(profile_id: int, action: str, status: str, message: str, *, payload: dict | None = None, job_id: str | None = None, user_id: int | None = None, error: str = "", details: dict | None = None) -> None: + """Log worker-only lifecycle events that do not execute the normal job action path.""" + payload = payload or {} + merged = {"job_id": job_id, "status": status, "error": error, "payload": payload, **(details or {})} + record(profile_id, _job_event_type(status), message, severity=_job_severity(status), source="worker", action=action, details=merged, user_id=user_id) def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], updated: list[dict], old_rows: dict[str, dict]) -> None: + """Record torrent cache changes detected by the poller without depending on manual jobs.""" for row in added or []: - record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label")}) + record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label"), "tracker": row.get("tracker")}) for h in removed or []: old = old_rows.get(str(h)) or {} - record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label")}) + record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label"), "tracker": old.get("tracker")}) for patch in updated or []: h = str(patch.get("hash") or "") old = old_rows.get(h) or {} was_complete = bool(old.get("complete")) or float(old.get("progress") or 0) >= 100 is_complete = bool(patch.get("complete", old.get("complete"))) or float(patch.get("progress", old.get("progress") or 0) or 0) >= 100 if h and not was_complete and is_complete: - record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path")}) + record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path"), "label": old.get("label"), "tracker": old.get("tracker")}) def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "", hide_jobs: bool = False) -> dict: + """Return operation logs with searchable messages, torrents, actions and detail JSON.""" limit = max(1, min(int(limit or 200), 1000)) offset = max(0, int(offset or 0)) where = ["(profile_id=? OR profile_id IS NULL)"] @@ -137,12 +283,11 @@ def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: where.append("event_type=?") params.append(event_type) if hide_jobs: - # Note: Job-originated rows include torrent_added/torrent_removed events, so source is the reliable filter. - where.append("COALESCE(source, '') <> 'job'") + where.append("COALESCE(source, '') <> 'job' AND event_type NOT LIKE 'job_%'") if q: - where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)") + where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ? OR details_json LIKE ?)") like = f"%{q}%" - params.extend([like, like, like, like]) + params.extend([like, like, like, like, like]) sql_where = " WHERE " + " AND ".join(where) with connect() as conn: rows = conn.execute(f"SELECT * FROM operation_logs{sql_where} ORDER BY id DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() @@ -161,7 +306,6 @@ def stats(profile_id: int) -> dict: return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)} - def retention_label(settings: dict) -> str: mode = settings.get("retention_mode") or "days" if mode == "manual": @@ -172,6 +316,7 @@ def retention_label(settings: dict) -> str: return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines" return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days" + def clear(profile_id: int, *, event_type: str = "") -> int: where = ["(profile_id=? OR profile_id IS NULL)"] params: list[Any] = [int(profile_id or 0)] @@ -184,6 +329,7 @@ def clear(profile_id: int, *, event_type: str = "") -> int: def apply_retention(profile_id: int, user_id: int | None = None) -> dict: + """Apply operation-log retention without touching torrent data or other history tables.""" settings = get_settings(profile_id, user_id) mode = settings.get("retention_mode") or "manual" deleted_days = 0 diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index bcf028e..69dbbc1 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -200,6 +200,8 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non "INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now), ) + # Note: Queued jobs are now written to operation logs so work is visible before a worker starts it. + operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id) _emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"}) _submit_job(job_id, action_name) return job_id @@ -315,6 +317,8 @@ def _run(job_id: str): profile = get_profile(int(job["profile_id"]), int(job["user_id"])) if not profile: _set_job(job_id, "failed", "rTorrent profile does not exist", finished=True) + # Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too. + operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found") _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"}) return profile_id = int(profile["id"]) @@ -362,6 +366,9 @@ def _run(job_id: str): _set_job(job_id, status, str(exc), finished=(status == "failed")) if status == "failed": operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "failed", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0)) + else: + # Note: Retried attempts are logged explicitly so transient failures are not lost between final states. + operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "retry", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0)) _emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)}) _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts}) if status == "pending": @@ -408,6 +415,8 @@ def _timeout_running_jobs() -> None: continue message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds" _set_job(row["id"], "failed", message, finished=True) + # Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised. + operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message) _emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"}) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message}) @@ -435,6 +444,8 @@ def _resubmit_interrupted_running_jobs() -> None: ("Resuming interrupted job from last checkpoint", utcnow(), row["id"]), ) if int(cur.rowcount or 0): + # Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable. + operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True}) _submit_job(row["id"], row.get("action")) @@ -456,6 +467,8 @@ def _resubmit_stale_pending_jobs() -> None: continue with connect() as conn: conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"])) + # Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery. + operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True}) _submit_job(row["id"], row.get("action")) @@ -561,6 +574,8 @@ def cancel_job(job_id: str) -> bool: return False # Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup. _set_job(job_id, "cancelled", finished=True) + payload = _job_payload(row) + operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "cancelled"}) return True @@ -597,6 +612,7 @@ def force_job(job_id: str) -> bool: payload['priority_job'] = True with connect() as conn: conn.execute("UPDATE jobs SET payload_json=?, updated_at=? WHERE id=?", (json.dumps(payload), utcnow(), job_id)) + operation_logs.record_job_event(int(row.get('profile_id') or 0), row.get('action'), 'forced', payload, job_id=job_id, user_id=int(row.get('user_id') or 0)) _emit('job_update', {'id': job_id, 'profile_id': row.get('profile_id'), 'status': 'pending', 'forced': True}) _submit_job(job_id, row.get('action')) return True @@ -607,6 +623,8 @@ def retry_job(job_id: str) -> bool: return False with connect() as conn: conn.execute("UPDATE jobs SET status='pending', error='', finished_at=NULL, state_json=NULL, progress_current=0, heartbeat_at=NULL, updated_at=? WHERE id=?", (utcnow(), job_id)) + payload = _job_payload(row) + operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "retry", payload, job_id=job_id, user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "pending"}) _submit_job(job_id, row.get("action")) return True diff --git a/pytorrent/static/js/dashboard.js b/pytorrent/static/js/dashboard.js index f54fb7f..bdaef07 100644 --- a/pytorrent/static/js/dashboard.js +++ b/pytorrent/static/js/dashboard.js @@ -1 +1 @@ -export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count}`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`
${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)}${esc(x.message)}${esc(new Date(x.at).toLocaleString())}
`).join('')||'No notifications yet.'}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='
Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='
Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='
Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n"; +export const dashboardSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentErrorLog(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const errorLog=torrentErrorLog(t);\n if(view==='smart:needs_attention') return !!errorLog || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentErrorLog(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent error log.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\nfunction notificationItems(){\n try{ return JSON.parse(localStorage.getItem(NOTIFICATION_STORAGE_KEY)||'[]'); }catch(e){ return []; }\n}\nfunction saveNotificationItems(items){ localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(items.slice(0,120))); }\nfunction recordNotification(type, title, message){\n const item={at:new Date().toISOString(), type:String(type||'info'), title:String(title||type||'Notification'), message:String(message||'')};\n const items=[item,...notificationItems()].slice(0,120);\n saveNotificationItems(items);\n renderNotificationCenter();\n updateNotificationBadge();\n}\nfunction notificationIcon(type){\n if(type==='error') return 'fa-triangle-exclamation';\n if(type==='warning') return 'fa-circle-exclamation';\n if(type==='planner') return 'fa-calendar-days';\n if(type==='queue') return 'fa-shuffle';\n return 'fa-circle-info';\n}\nfunction updateNotificationBadge(){\n const btn=document.querySelector('.tool-tab[data-tool=\"notifications\"]');\n if(!btn) return;\n const count=notificationItems().length;\n btn.innerHTML=` Notifications${count?` ${count}`:''}`;\n}\nfunction renderNotificationCenter(){\n const box=$('notificationCenterManager');\n if(!box) return;\n const items=notificationItems();\n box.innerHTML=`
${esc(items.length)} saved event(s)
${items.map(x=>`
${esc(x.title)}${esc(x.message)}${esc(new Date(x.at).toLocaleString())}
`).join('')||'No notifications yet.'}
`;\n $('clearNotificationsBtn')?.addEventListener('click',()=>{ saveNotificationItems([]); renderNotificationCenter(); updateNotificationBadge(); });\n}\nfunction diagnosticsSection(title, cards){\n return `
${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`
Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)}
`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n}\nfunction ensureDashboardToolsUI(){\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n addToolTab('smartviews','fa-layer-group','Smart Views','torrentstats');\n addToolTab('notifications','fa-bell','Notifications','appstatus');\n const stats=$('toolTorrentStats');\n if(stats && !$('healthDashboardManager')){\n const section=document.createElement('div');\n section.className='surface-section mt-3';\n section.innerHTML='
Torrent health
Live health buckets calculated from the current torrent snapshot.
';\n stats.appendChild(section);\n section.addEventListener('click',e=>{ const tab=e.target.closest('[data-health-pane]'); if(tab){ const pane=tab.dataset.healthPane; section.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane)); section.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane)); return; } const row=e.target.closest('[data-hash]'); if(!row) return; selectedHash=row.dataset.hash; selected.clear(); selected.add(selectedHash); scheduleRender(true); });\n }\n if(!$('toolSmartviews')){\n const p=document.createElement('div');\n p.id='toolSmartviews';\n p.className='d-none';\n p.innerHTML='
Smart Views
One-click filters for common torrent maintenance tasks.
';\n host.appendChild(p);\n p.addEventListener('click',e=>{ const card=e.target.closest('.smart-view-card'); if(!card) return; activeTrackerFilter=''; activeFilter=card.dataset.filter||'all'; mobileActiveFilterKey=activeFilter; saveActiveFilterPreference(); syncFilterButtons(); scheduleRender(true); renderSmartViewsManager(); });\n }\n if(!$('toolNotifications')){\n const p=document.createElement('div');\n p.id='toolNotifications';\n p.className='d-none';\n p.innerHTML='
Notification center
Persistent local history for rTorrent, RSS, automation, disk, queue, planner and port events.
';\n host.appendChild(p);\n }\n renderHealthDashboard();\n renderSmartViewsManager();\n renderNotificationCenter();\n updateNotificationBadge();\n}\n"; diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js index 3ffdc02..f9abb4c 100644 --- a/pytorrent/static/js/operationLogs.js +++ b/pytorrent/static/js/operationLogs.js @@ -1 +1 @@ -export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; +export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_queued', 'job_started', 'job_done', 'job_failed', 'job_retry', 'job_cancelled', 'job_timeout', 'job_resubmitted', 'job_forced'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')}`;\n }\n\n function operationLogTypeLabel(type){\n const labels = {\n '': 'All types',\n torrent_added: 'Torrent added',\n torrent_removed: 'Torrent removed',\n torrent_completed: 'Torrent completed',\n job_queued: 'Job queued',\n job_started: 'Job started',\n job_done: 'Job done',\n job_failed: 'Job failed',\n job_retry: 'Job retry',\n job_cancelled: 'Job cancelled',\n job_timeout: 'Job timeout',\n job_resubmitted: 'Job resubmitted',\n job_forced: 'Job forced'\n };\n return labels[type] || type || 'All types';\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `
${esc(label)}${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `
${esc(x.bucket)}${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `
${esc(x.action)}${esc(x.n)}
`).join('');\n return `
${card('Total logs', stats.total || 0)}${types}
Daily count
${daily || 'No data.'}
Monthly count
${monthly || 'No data.'}
Top actions
${actions || 'No data.'}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function operationLogDetailsCell(row){\n const summary = row.details_h || '';\n const full = JSON.stringify(row.details || {}, null, 2);\n if(!summary && (!full || full === '{}')) return '';\n // Note: Details are expandable so long JSON results stay complete without widening the table.\n return `
${summary ? esc(summary) : 'Full details'}
${esc(full)}
`;\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => ``).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n operationLogDetailsCell(r),\n ]), 'operation-log-table');\n if(!rows.length) box.innerHTML = '
No logs.No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`, {cache: 'no-store'}).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `
${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n"; diff --git a/pytorrent/static/js/smartViews.js b/pytorrent/static/js/smartViews.js index 0c4175c..dc8ddf3 100644 --- a/pytorrent/static/js/smartViews.js +++ b/pytorrent/static/js/smartViews.js @@ -1 +1 @@ -export const smartViewsSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentWarning(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const warning=torrentWarning(t);\n if(view==='smart:needs_attention') return !!warning || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentWarning(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent warning state.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\n"; +export const smartViewsSource = "const NOTIFICATION_STORAGE_KEY = 'pytorrent.notifications.v1';\nconst HEALTH_PANE_STORAGE_KEY = 'pytorrent.healthPane.v1';\nconst SMART_VIEW_DEFS = [\n ['smart:needs_attention', 'Needs attention', 'Errors, dead torrents, inactive downloads or stalled seeding.'],\n ['smart:large_slow', 'Large and slow', 'Large active downloads below the slow speed threshold.'],\n ['smart:seeding_too_long', 'Seeding too long', 'Completed torrents seeding longer than 14 days or above ratio 2.0.'],\n ['smart:new_rss', 'New from RSS', 'RSS-labeled torrents added during the last 7 days.'],\n ['smart:no_label', 'No label', 'Torrents without any label.'],\n ['smart:private_trackers', 'Private trackers', 'Torrents matched by known private tracker domains.'],\n];\nfunction torrentTrackers(t){\n return trackerRowsForHash(t.hash).map(x=>String(x.domain||'')).filter(Boolean);\n}\nfunction torrentSearchText(t){\n return [\n t.name, t.hash, t.label, t.path, t.ratio_group, t.status, t.message,\n t.size_h, t.progress, torrentErrorLog(t), ...torrentTrackers(t),\n ].filter(v=>v!==undefined&&v!==null).join(' ').toLowerCase();\n}\nfunction torrentAgeSeconds(t){\n const created=Number(t.created||0);\n return created ? Math.max(0, Date.now()/1000-created) : 0;\n}\nfunction torrentCompletedAgeSeconds(t){\n const completedAt=Number(t.completed_at||t.finished_at||t.done_at||0);\n if(completedAt > 0) return Math.max(0, Date.now()/1000-completedAt);\n if(t.complete) return 0;\n return torrentAgeSeconds(t);\n}\nfunction torrentRatio(t){ return Number(t.ratio||0); }\nfunction torrentSize(t){ return Number(t.size||0); }\nfunction torrentDownRate(t){ return Number(t.down_rate||0); }\nfunction isIncompleteTorrent(t){ return !t.complete; }\nfunction isRunningTorrent(t){ return !!t.state && !t.paused; }\nfunction isSlowTorrent(t){ return torrentDownRate(t) > 0 && torrentDownRate(t) < 64*1024; }\nfunction isLargeTorrent(t){ return torrentSize(t) >= 20*1024*1024*1024; }\nfunction isDeadTorrent(t){ return isIncompleteTorrent(t) && Number(t.seeds||0) <= 0 && Number(t.peers||0) <= 0; }\nfunction isPostCheckTorrent(t){ return t.status === 'Post-check' || !!t.post_check; }\nfunction shouldBeActiveTorrent(t){ return isIncompleteTorrent(t) && !isChecking(t) && !isPostCheckTorrent(t) && !t.paused && !isRunningTorrent(t); }\nfunction isPrivateTrackerDomain(domain){\n return /(iptorrents|torrentleech|beyond-hd|passthepopcorn|btn|redacted|empornium|gazelle|private|hd-torrents|filelist|alpharatio|avistaz|cinemaz|animetorrents)/i.test(domain||'');\n}\nfunction smartViewVisible(t, view){\n const errorLog=torrentErrorLog(t);\n if(view==='smart:needs_attention') return !!errorLog || isDeadTorrent(t) || shouldBeActiveTorrent(t) || (t.complete && Number(t.seeds||0) <= 0);\n if(view==='smart:large_slow') return isIncompleteTorrent(t) && isRunningTorrent(t) && isLargeTorrent(t) && isSlowTorrent(t);\n if(view==='smart:seeding_too_long') return !!t.complete && (torrentRatio(t) >= 2 || torrentCompletedAgeSeconds(t) >= 14*86400);\n if(view==='smart:new_rss') return /rss/i.test(String(t.label||'') + ' ' + String(t.path||'')) && torrentAgeSeconds(t) <= 7*86400;\n if(view==='smart:no_label') return !labelNames(t.label).length;\n if(view==='smart:private_trackers') return torrentTrackers(t).some(isPrivateTrackerDomain);\n return true;\n}\nfunction duplicateTorrentRows(rows){\n const groups=new Map();\n rows.forEach(t=>{\n const name=String(t.name||'').trim().toLowerCase();\n if(!name) return;\n const key=`${name}|${torrentSize(t)||''}`;\n if(!groups.has(key)) groups.set(key,[]);\n groups.get(key).push(t);\n });\n return [...groups.values()].filter(g=>g.length>1).flat();\n}\nfunction healthRows(){\n const rows=trackerScopedRows();\n return {\n noSeeders: rows.filter(t=>isIncompleteTorrent(t) && Number(t.seeds||0)<=0),\n stoppedActive: rows.filter(shouldBeActiveTorrent),\n trackerErrors: rows.filter(t=>torrentErrorLog(t)),\n duplicates: duplicateTorrentRows(rows),\n slowest: rows.filter(t=>isIncompleteTorrent(t) && isRunningTorrent(t)).sort((a,b)=>torrentDownRate(a)-torrentDownRate(b)).slice(0,12),\n dead: rows.filter(isDeadTorrent),\n largest: rows.slice().sort((a,b)=>torrentSize(b)-torrentSize(a)).slice(0,12),\n belowRatio: rows.filter(t=>t.complete && torrentRatio(t)<1).sort((a,b)=>torrentRatio(a)-torrentRatio(b)).slice(0,12),\n };\n}\nfunction healthSection(title, rows, note){\n const sample=rows.slice(0,8).map(t=>``).join('');\n return `
${esc(title)}${esc(rows.length)}
${esc(note)}
${sample||'No items.'}
`;\n}\nfunction activeHealthPane(){\n const value=localStorage.getItem(HEALTH_PANE_STORAGE_KEY)||'availability';\n return ['availability','quality','size'].includes(value) ? value : 'availability';\n}\nfunction setHealthPane(pane){\n const box=$('healthDashboardManager');\n if(!box) return;\n localStorage.setItem(HEALTH_PANE_STORAGE_KEY, pane);\n box.querySelectorAll('[data-health-pane]').forEach(x=>x.classList.toggle('active',x.dataset.healthPane===pane));\n box.querySelectorAll('[data-health-panel]').forEach(x=>x.classList.toggle('d-none',x.dataset.healthPanel!==pane));\n}\nfunction renderHealthDashboard(){\n const box=$('healthDashboardManager');\n if(!box) return;\n const h=healthRows();\n const active=activeHealthPane();\n const panes=[\n ['availability','Availability', `${healthSection('Torrents without seeders',h.noSeeders,'Incomplete torrents with zero reported seeders.')}${healthSection('Stopped torrents that should be active',h.stoppedActive,'Incomplete torrents stopped outside explicit pause state.')}${healthSection('Dead torrents',h.dead,'No seeders and no peers.')}`],\n ['quality','Quality', `${healthSection('Tracker errors',h.trackerErrors,'Rows with tracker or torrent error log.')}${healthSection('Duplicate torrents',h.duplicates,'Same normalized name and size appear more than once.')}${healthSection('Slowest torrents',h.slowest,'Running incomplete torrents sorted by current download speed.')}`],\n ['size','Size / ratio', `${healthSection('Largest torrents',h.largest,'Largest torrents in the current profile.')}${healthSection('Below target ratio',h.belowRatio,'Completed torrents below the default ratio target 1.0.')}`]\n ];\n box.innerHTML=`
${panes.map(p=>`
${p[2]}
`).join('')}`;\n}\nfunction renderSmartViewsManager(){\n const box=$('smartViewsManager');\n if(!box) return;\n const rows=trackerScopedRows();\n // Note: The hint makes the card action explicit without changing existing filter behavior.\n box.innerHTML=`
Click any block to open the torrent list view filtered by that Smart View.
${SMART_VIEW_DEFS.map(([key,label,note])=>``).join('')}
`;\n}\n"; diff --git a/pytorrent/static/js/torrentFilterUi.js b/pytorrent/static/js/torrentFilterUi.js index dad1b86..ef7f273 100644 --- a/pytorrent/static/js/torrentFilterUi.js +++ b/pytorrent/static/js/torrentFilterUi.js @@ -1 +1 @@ -export const torrentFilterUiSource = " function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentWarning(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n"; +export const torrentFilterUiSource = " function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentErrorLog(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n"; diff --git a/pytorrent/static/js/torrentRowRenderer.js b/pytorrent/static/js/torrentRowRenderer.js index 6eba085..c4cf432 100644 --- a/pytorrent/static/js/torrentRowRenderer.js +++ b/pytorrent/static/js/torrentRowRenderer.js @@ -1 +1 @@ -export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return ``; }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(formatDateTime(t.last_activity))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n\n\n\n\n"; +export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${torrentNameStatusIcon(t)}${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(formatDateTime(t.last_activity))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n\n\n\n\n"; diff --git a/pytorrent/static/js/torrentTableRenderer.js b/pytorrent/static/js/torrentTableRenderer.js index a98712a..6516730 100644 --- a/pytorrent/static/js/torrentTableRenderer.js +++ b/pytorrent/static/js/torrentTableRenderer.js @@ -1 +1 @@ -export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `
${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; +export const torrentTableRendererSource = " function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `
${torrentNameStatusIcon(t)}${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; diff --git a/pytorrent/static/js/torrents.js b/pytorrent/static/js/torrents.js index 64e9edc..d57ee19 100644 --- a/pytorrent/static/js/torrents.js +++ b/pytorrent/static/js/torrents.js @@ -1 +1 @@ -export const torrentsSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentWarning(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n function trackerFavicon(tracker){\n const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');\n if(!trackerFaviconsEnabled || !domain) return '';\n // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly.\n const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`;\n const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;\n return `\"\"`;\n }\n function trackerFilterPlaceholder(){\n if(trackerSummaryStatus==='loading') return '
Loading cached trackers...
';\n if(trackerSummaryStatus==='error') return '
Tracker list unavailable
';\n if(Number(trackerSummary.pending||0)) return `
Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}
`;\n if(hasTorrentSnapshot && torrents.size) return '
No trackers found
';\n return '
Waiting for torrents...
';\n }\n function renderTrackerFilters(force=false){\n const box=$('trackerFilters');\n if(!box) return;\n const trackers=trackerSummary.trackers || [];\n // Note: Keep the selected tracker while the async summary is loading or temporarily incomplete; otherwise sorting can reset mobile scope to All trackers.\n if(activeTrackerFilter && trackerSummaryStatus==='ready' && trackers.length && !trackers.some(t=>t.domain===activeTrackerFilter)) activeTrackerFilter='';\n const sig=[\n trackerSummaryStatus,\n trackerFaviconsEnabled ? 1 : 0,\n trackerSummary.pending || 0,\n trackerSummary.cached || 0,\n trackerSummary.scanned || 0,\n trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|')\n ].join('::');\n if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; }\n lastTrackerFiltersSignature=sig;\n // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.\n const rows=trackers.length\n ? `` + trackers.map(t=>``).join('')\n : trackerFilterPlaceholder();\n box.innerHTML=`
Trackers
${rows}`;\n bindSidebarFilterClicks(box);\n }\n async function refreshTrackerSummary(force=false){\n const hashes=[...torrents.keys()].sort();\n const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;\n if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;\n trackerSummarySignature=sig;\n if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';\n renderTrackerFilters();\n try{\n // Note: Do not send 13k hashes in the URL; the backend uses a local snapshot and reads the cache in small chunks.\n const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();\n if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');\n trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';\n renderTrackerFilters();\n scheduleRender(true);\n if(Number(trackerSummary.pending||0)>0){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);\n }\n }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }\n }\n function scheduleTrackerSummary(force=false){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);\n }\n function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n function actionLabel(action){\n // Note: These labels are shown inside a torrent row, so they stay short and do not repeat the word torrent.\n const labels={start:'Start',pause:'Pause',stop:'Stop',resume:'Resume',recheck:'Check',reannounce:'Reannounce',remove:'Remove',move:'Move',set_label:'Set label',set_ratio_group:'Set ratio'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function actionCompletionPatch(action, torrent){\n // Note: rTorrent can acknowledge light state actions before the next list read exposes the new status.\n const complete=Number(torrent?.complete||0) !== 0;\n if(['start','resume','unpause'].includes(action)) return {state:1, active:1, paused:false, post_check:false, status:complete?'Seeding':'Downloading'};\n if(action==='pause') return {state:1, active:0, paused:true, status:'Paused'};\n if(action==='stop') return {state:0, active:0, paused:false, post_check:false, status:'Stopped'};\n return null;\n }\n function applyActionCompletionState(action, hashes=[]){\n // Note: This optimistic patch keeps completed light actions visible while delayed cache refreshes settle.\n const unique=[...new Set(hashes||[])].filter(Boolean);\n let changed=false;\n unique.forEach(hash=>{\n const current=torrents.get(hash);\n const patch=actionCompletionPatch(action,current);\n if(!current || !patch) return;\n torrents.set(hash,{...current,...patch});\n changed=true;\n });\n if(changed) scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; }\n function torrentNameIcon(t){ const m=statusMeta(t); return ``; }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(formatDateTime(t.last_activity))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n\n\n\n\n function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const warn=torrentWarning(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `
${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; +export const torrentsSource = " // Note: Displays status filter summaries calculated and cached by the backend API.\n const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', post_check:'countPostCheck', stopped:'countStopped', moving:'countMoving'};\n function formatFilterBytes(value){ return fmtBytes(value).replace(/\\.0 (?=GiB|TiB)/, ' '); }\n function filterMetaLine(bucket){\n if(!bucket || !Number(bucket.count||0)) return '';\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n return `Data ${formatFilterBytes(disk)}`;\n }\n function filterNeedsDownloadDetails(type, bucket){\n if(!bucket || !Number(bucket.count||0)) return false;\n if(type==='downloading' || type==='post_check') return true;\n if(type!=='paused' && type!=='stopped') return false;\n const size=Number(bucket.size||0);\n const completed=Number(bucket.completed_bytes ?? bucket.disk_bytes ?? 0);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n return size > 0 && remaining > 0 && progress < 100;\n }\n function filterTooltipLine(bucket, type){\n if(!bucket || !Number(bucket.count||0)) return '';\n const size=Number(bucket.size||0);\n const disk=Number(bucket.disk_bytes ?? bucket.completed_bytes ?? 0);\n const completed=Number(bucket.completed_bytes ?? disk);\n const remaining=Number(bucket.remaining_bytes ?? Math.max(0, size-completed));\n const progress=Number(bucket.progress_percent ?? (size ? (completed / size) * 100 : 0));\n const left=Number(bucket.remaining_percent ?? Math.max(0, 100-progress));\n const lines=[`Data: ${formatFilterBytes(disk)}`];\n if(filterNeedsDownloadDetails(type, bucket)){\n lines.push(`Total to download: ${formatFilterBytes(size)}`);\n lines.push(`Downloaded: ${formatFilterBytes(completed)} (${progress.toFixed(1)}%)`);\n lines.push(`Left: ${formatFilterBytes(remaining)} (${left.toFixed(1)}%)`);\n }\n return lines.join('\\n');\n }\n function applyFilterTooltip(button, tooltip, ariaLabel){\n if(tooltip){\n button.title = tooltip;\n button.setAttribute('aria-label', ariaLabel);\n } else {\n button.removeAttribute('title');\n button.removeAttribute('aria-label');\n }\n }\n function ensureStableFilterTooltip(button){\n if(filterTooltipState.has(button)) return filterTooltipState.get(button);\n const state = {hovering:false, pending:null};\n filterTooltipState.set(button, state);\n button.addEventListener('mouseenter', () => {\n state.hovering = true;\n state.pending = null;\n });\n button.addEventListener('mouseleave', () => {\n state.hovering = false;\n if(state.pending){\n applyFilterTooltip(button, state.pending.tooltip, state.pending.ariaLabel);\n state.pending = null;\n }\n });\n return state;\n }\n // Note: Freezes tooltip content during hover; the next hover receives the newest live summary.\n function setStableFilterTooltip(button, tooltip, ariaLabel){\n const state = ensureStableFilterTooltip(button);\n if(state.hovering){\n state.pending = {tooltip, ariaLabel};\n return;\n }\n applyFilterTooltip(button, tooltip, ariaLabel);\n }\n function movingOperationRows(){\n // Note: The Moving filter is based only on active move operations, not queued jobs.\n return [...torrents.values()].filter(t=>{\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n });\n }\n function movingFilterCount(){ return movingOperationRows().length; }\n function torrentMatchesFilterType(t, type){\n if(type==='all') return true;\n if(type==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused;\n if(type==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused;\n if(type==='paused') return !!t.paused || t.status==='Paused';\n if(type==='checking') return isChecking(t);\n if(type==='error') return torrentHasError(t);\n if(type==='post_check') return t.status==='Post-check' || !!t.post_check;\n if(type==='stopped') return !t.state && !isChecking(t) && t.status!=='Post-check' && !t.post_check;\n if(type==='moving'){\n const op=activeOperationFor(t);\n return op?.action==='move' && op?.state==='running';\n }\n return true;\n }\n function trackerScopedRows(){\n const rows=[...torrents.values()];\n return activeTrackerFilter ? rows.filter(t=>rowHasTracker(t, activeTrackerFilter)) : rows;\n }\n function summarizeFilterRows(rows, type){\n const matched=rows.filter(t=>torrentMatchesFilterType(t, type));\n const bucket={count:matched.length,size:0,disk_bytes:0,completed_bytes:0,remaining_bytes:0};\n matched.forEach(t=>{\n const size=Number(t.size||0);\n const progress=Number(t.progress||0);\n const completed=Number(t.completed_bytes ?? t.completed ?? t.down_total ?? (size && Number.isFinite(progress) ? size * Math.max(0, Math.min(100, progress)) / 100 : 0));\n bucket.size += size;\n bucket.completed_bytes += completed;\n bucket.disk_bytes += completed;\n bucket.remaining_bytes += Math.max(0, size-completed);\n });\n bucket.progress_percent = bucket.size ? (bucket.completed_bytes / bucket.size) * 100 : 0;\n bucket.remaining_percent = Math.max(0, 100-bucket.progress_percent);\n return bucket;\n }\n function filterSummaryBucket(type){\n if(type==='moving') return {count:movingFilterCount()};\n if(activeTrackerFilter) return summarizeFilterRows(trackerScopedRows(), type);\n return torrentSummary?.filters?.[type] || {count:0};\n }\n function setFilterSummary(type){\n const el=$(FILTER_COUNT_IDS[type]);\n if(!el) return;\n const bucket=filterSummaryBucket(type);\n const meta=type==='moving' ? '' : filterMetaLine(bucket, type);\n const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type);\n el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`;\n const button=el.closest('.filter');\n if(button){\n const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\\n/g, ', ')}` : '';\n button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0));\n setStableFilterTooltip(button, tooltip, ariaLabel);\n }\n }\n function labelNames(value){ return String(value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean).filter((x,i,a)=>a.indexOf(x)===i); }\n function labelValue(labels){ return [...new Set((labels||[]).map(x=>String(x||'').trim()).filter(Boolean))].join(', '); }\n function rowHasLabel(t,label){ return labelNames(t.label).includes(label); }\n function trackerRowsForHash(hash){ return trackerSummary.hashes?.[hash] || []; }\n function rowHasTracker(t, domain){ return trackerRowsForHash(t.hash).some(x=>x.domain===domain); }\n function torrentHasError(t){ return !!torrentErrorLog(t); }\n function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; }\n function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && !torrentSearchText(t).includes(q)) return false; if(activeTrackerFilter && !rowHasTracker(t, activeTrackerFilter)) return false; if(FILTER_COUNT_IDS[activeFilter]) return torrentMatchesFilterType(t, activeFilter); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); if(activeFilter.startsWith('smart:')) return smartViewVisible(t,activeFilter); return true; }\n function compareRows(a,b){\n const k=sortState.key;\n if(k==='eta'){\n // Note: ETA is displayed as text but sorted by eta_seconds; unavailable ETA stays last in both directions.\n const av=Number(a.eta_seconds||0), bv=Number(b.eta_seconds||0);\n const aMissing=!Number.isFinite(av)||av<=0, bMissing=!Number.isFinite(bv)||bv<=0;\n if(aMissing&&bMissing) return String(a.name||'').localeCompare(String(b.name||''));\n if(aMissing) return 1;\n if(bMissing) return -1;\n return (av>bv?1:avNumber(bv||0))?1:(Number(av||0)0?\" \":\" \"; }\n\n\n\n\n function updateSortHeaders(){\n document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{\n const title = th.querySelector('.column-title');\n const base = th.dataset.baseText || (title ? title.textContent.trim() : th.textContent.trim());\n th.dataset.baseText = base;\n if(title) title.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n else th.innerHTML = `${esc(base)}${sortIcon(th.dataset.sort)}`;\n th.classList.toggle('sorted', sortState.key === th.dataset.sort);\n });\n }\n // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation.\n function syncFilterButtons(){\n // Note: Tracker is a parent scope; regular filters stay active inside the selected tracker.\n document.querySelectorAll('.filter').forEach(x=>{\n const key=x.dataset.filter||'';\n if(key.startsWith('tracker:')) x.classList.toggle('active', activeTrackerFilter===key.slice(8));\n else if(x.dataset.trackerScope==='all') x.classList.toggle('active', !activeTrackerFilter);\n else x.classList.toggle('active', key===activeFilter);\n });\n }\n function renderCounts(){\n // Note: When the last move operation finishes, the hidden filter does not leave an empty list active.\n if(activeFilter==='moving' && !movingFilterCount()){ activeFilter='all'; mobileActiveFilterKey='all'; }\n syncFilterButtons();\n Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary);\n $('statSelected').textContent=selected.size;\n }\n function bindSidebarFilterClicks(root){\n root?.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{\n const key=b.dataset.filter||'all';\n if(key.startsWith('tracker:')){ activeTrackerFilter=key.slice(8); mobileActiveFilterKey=key; }\n else if(b.dataset.trackerScope==='all'){ activeTrackerFilter=''; mobileActiveFilterKey='tracker:'; }\n else { activeTrackerFilter=''; activeFilter=key; mobileActiveFilterKey=key; }\n syncFilterButtons();\n saveActiveFilterPreference();\n if($('tableWrap')) $('tableWrap').scrollTop=0;\n if($('mobileList')) $('mobileList').scrollTop=0;\n scheduleRender(true);\n }));\n }\n function renderLabelFilters(force=false){\n const box=$('labelFilters');\n if(!box) return;\n const counts=new Map();\n trackerScopedRows().forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1)));\n const labels=[...counts.keys()].filter(l=>counts.get(l)>0).sort((a,b)=>a.localeCompare(b));\n if(activeFilter.startsWith('label:') && !counts.has(activeFilter.slice(6))){ activeFilter='all'; mobileActiveFilterKey='all'; }\n const sig=labels.map(l=>`${l}:${counts.get(l)}`).join('|');\n if(!force && sig===lastLabelFiltersSignature){ syncFilterButtons(); return; }\n lastLabelFiltersSignature=sig;\n box.innerHTML=labels.length?`
Labels
${labels.map(l=>``).join('')}`:'';\n bindSidebarFilterClicks(box);\n }\n function trackerFavicon(tracker){\n const domain=typeof tracker==='string'?tracker:(tracker?.domain||'');\n if(!trackerFaviconsEnabled || !domain) return '';\n // Note: Normal rendering must use cached/static URLs only. Avoid refresh=1 here, otherwise scroll-triggered paints can re-warm icons repeatedly.\n const fallback=`/api/trackers/favicon/${encodeURIComponent(domain)}`;\n const src=(typeof tracker==='object' && tracker?.favicon_url) ? tracker.favicon_url : fallback;\n return `\"\"`;\n }\n function trackerFilterPlaceholder(){\n if(trackerSummaryStatus==='loading') return '
Loading cached trackers...
';\n if(trackerSummaryStatus==='error') return '
Tracker list unavailable
';\n if(Number(trackerSummary.pending||0)) return `
Tracker cache: ${esc(trackerSummary.cached||0)}/${esc(trackerSummary.scanned||0)}
`;\n if(hasTorrentSnapshot && torrents.size) return '
No trackers found
';\n return '
Waiting for torrents...
';\n }\n function renderTrackerFilters(force=false){\n const box=$('trackerFilters');\n if(!box) return;\n const trackers=trackerSummary.trackers || [];\n // Note: Keep the selected tracker while the async summary is loading or temporarily incomplete; otherwise sorting can reset mobile scope to All trackers.\n if(activeTrackerFilter && trackerSummaryStatus==='ready' && trackers.length && !trackers.some(t=>t.domain===activeTrackerFilter)) activeTrackerFilter='';\n const sig=[\n trackerSummaryStatus,\n trackerFaviconsEnabled ? 1 : 0,\n trackerSummary.pending || 0,\n trackerSummary.cached || 0,\n trackerSummary.scanned || 0,\n trackers.map(t=>`${t.domain}:${t.count||0}:${t.favicon_url||''}`).join('|')\n ].join('::');\n if(!force && sig===lastTrackerFiltersSignature){ syncFilterButtons(); return; }\n lastTrackerFiltersSignature=sig;\n // Note: Tracker filter section is always visible, so an empty or failed tracker scan does not look like a missing feature.\n const rows=trackers.length\n ? `` + trackers.map(t=>``).join('')\n : trackerFilterPlaceholder();\n box.innerHTML=`
Trackers
${rows}`;\n bindSidebarFilterClicks(box);\n }\n async function refreshTrackerSummary(force=false){\n const hashes=[...torrents.keys()].sort();\n const sig=`${hashes.length}:${hashes[0]||''}:${hashes[hashes.length-1]||''}:${trackerFaviconsEnabled?1:0}`;\n if(!force && sig===trackerSummarySignature && !Number(trackerSummary.pending||0)) return;\n trackerSummarySignature=sig;\n if(!hashes.length){ trackerSummary={hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0}; trackerSummaryStatus='empty'; renderTrackerFilters(); return; }\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':'loading';\n renderTrackerFilters();\n try{\n // Note: Do not send 13k hashes in the URL; the backend uses a local snapshot and reads the cache in small chunks.\n const j=await (await fetch('/api/trackers/summary?scan_limit=0&warm=1&bg_limit=80')).json();\n if(!j.ok && !j.summary) throw new Error(j.error||'Tracker summary failed');\n trackerSummary=j.summary||{hashes:{},trackers:[],scanned:0,errors:[],pending:0,cached:0};\n trackerSummaryStatus=(trackerSummary.trackers||[]).length?'ready':Number(trackerSummary.pending||0)?'empty':'empty';\n renderTrackerFilters();\n scheduleRender(true);\n if(Number(trackerSummary.pending||0)>0){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(true).catch(()=>{}), 5000);\n }\n }catch(e){ trackerSummaryStatus='error'; renderTrackerFilters(); console.warn('Tracker summary failed', e); }\n }\n function scheduleTrackerSummary(force=false){\n clearTimeout(trackerSummaryTimer);\n trackerSummaryTimer=setTimeout(()=>refreshTrackerSummary(force).catch(()=>{}), force?50:600);\n }\n function buildVisibleRows(){ visibleRows=[...torrents.values()].filter(rowVisible).sort(compareRows); $('statShown').textContent=visibleRows.length; }\n function visibleColumnKeys(){ return ['select', ...COLUMN_DEFS.map(([key])=>key)].filter(key => key === 'select' || !hiddenColumns.has(key)); }\n function applyColumnWidths(){\n // Note: Widths are applied to headers and virtualized body rows, keeping all cells aligned after live renders.\n const table = document.querySelector('.torrent-table');\n if(!table) return;\n let total = 0;\n visibleColumnKeys().forEach(key => { total += columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120; });\n table.style.width = `${total}px`;\n table.style.minWidth = `${total}px`;\n document.querySelectorAll('.torrent-table [data-col]').forEach(el=>{\n const key = el.dataset.col;\n const width = columnWidths[key] || DEFAULT_COLUMN_WIDTHS[key] || 120;\n el.style.width = `${width}px`;\n el.style.minWidth = `${width}px`;\n el.style.maxWidth = `${width}px`;\n });\n }\n function applyColumnVisibility(){\n document.querySelectorAll('[data-col]').forEach(el=>el.classList.toggle('hidden-col', hiddenColumns.has(el.dataset.col)));\n applyColumnWidths();\n }\n function saveColumnWidthsPreference(){\n saveBrowserViewPrefs({columnWidths});\n savePreferencePatch({table_columns_json:columnPrefsPayload()}, 300);\n }\n function setupColumnResizers(){\n document.querySelectorAll('.torrent-table thead th[data-col]').forEach(th=>{\n const key = th.dataset.col;\n if(!key || key === 'select' || th.querySelector('.column-resize-handle')) return;\n const handle = document.createElement('span');\n handle.className = 'column-resize-handle';\n handle.title = 'Drag to resize column';\n handle.setAttribute('aria-hidden', 'true');\n th.appendChild(handle);\n let startX = 0, startWidth = 0, dragged = false;\n const onMove = (event) => {\n dragged = true;\n columnWidths[key] = clampNumber(startWidth + event.clientX - startX, COLUMN_WIDTH_MIN, COLUMN_WIDTH_MAX, startWidth);\n applyColumnWidths();\n };\n const onUp = () => {\n document.body.classList.remove('resizing-columns');\n document.removeEventListener('pointermove', onMove);\n document.removeEventListener('pointerup', onUp);\n if(dragged) saveColumnWidthsPreference();\n };\n handle.addEventListener('pointerdown', event=>{\n event.preventDefault();\n event.stopPropagation();\n dragged = false;\n startX = event.clientX;\n startWidth = columnWidths[key] || th.getBoundingClientRect().width || DEFAULT_COLUMN_WIDTHS[key] || 120;\n document.body.classList.add('resizing-columns');\n document.addEventListener('pointermove', onMove);\n document.addEventListener('pointerup', onUp);\n });\n handle.addEventListener('click', event=>event.stopPropagation());\n });\n }\n function syncActiveFilterSelection(){ syncFilterButtons(); }\n function actionLabel(action){\n // Note: These labels are shown inside a torrent row, so they stay short and do not repeat the word torrent.\n const labels={start:'Start',pause:'Pause',stop:'Stop',resume:'Resume',recheck:'Check',reannounce:'Reannounce',remove:'Remove',move:'Move',set_label:'Set label',set_ratio_group:'Set ratio'};\n return labels[action] || `Working: ${action}`;\n }\n function actionIcon(action){\n return ({start:'fa-play',pause:'fa-pause',stop:'fa-stop',resume:'fa-play',recheck:'fa-rotate',reannounce:'fa-bullhorn',remove:'fa-trash',move:'fa-folder-open',set_label:'fa-tag',set_ratio_group:'fa-scale-balanced'}[action]) || 'fa-gears';\n }\n function markTorrentOperation(hashes, action, jobId, state='queued'){\n const label=actionLabel(action);\n [...new Set(hashes||[])].filter(Boolean).forEach(hash=>activeOperations.set(hash,{action,jobId,state,label,updatedAt:Date.now()}));\n scheduleRender(true);\n }\n function markQueuedJobs(response, fallbackHashes, action){\n // Note: Supports API responses that split one large user action into multiple queued bulk parts.\n const jobs=Array.isArray(response?.jobs)?response.jobs:[];\n if(jobs.length){ jobs.forEach(job=>markTorrentOperation(job.hashes||[],action,job.job_id,'queued')); return; }\n markTorrentOperation(fallbackHashes,action,response?.job_id,'queued');\n }\n function clearJobOperation(jobId, hashes=[]){\n if(jobId){ [...activeOperations].forEach(([hash,op])=>{ if(op.jobId===jobId) activeOperations.delete(hash); }); }\n (hashes||[]).forEach(hash=>activeOperations.delete(hash));\n scheduleRender(true);\n }\n function actionCompletionPatch(action, torrent){\n // Note: rTorrent can acknowledge light state actions before the next list read exposes the new status.\n const complete=Number(torrent?.complete||0) !== 0;\n if(['start','resume','unpause'].includes(action)) return {state:1, active:1, paused:false, post_check:false, status:complete?'Seeding':'Downloading'};\n if(action==='pause') return {state:1, active:0, paused:true, status:'Paused'};\n if(action==='stop') return {state:0, active:0, paused:false, post_check:false, status:'Stopped'};\n return null;\n }\n function applyActionCompletionState(action, hashes=[]){\n // Note: This optimistic patch keeps completed light actions visible while delayed cache refreshes settle.\n const unique=[...new Set(hashes||[])].filter(Boolean);\n let changed=false;\n unique.forEach(hash=>{\n const current=torrents.get(hash);\n const patch=actionCompletionPatch(action,current);\n if(!current || !patch) return;\n torrents.set(hash,{...current,...patch});\n changed=true;\n });\n if(changed) scheduleRender(true);\n }\n function activeOperationFor(t){ return activeOperations.get(t.hash) || null; }\n function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return ``+\n ``+\n `${torrentNameStatusIcon(t)}${esc(t.name)}`+\n `${statusBadge(t)}`+\n `${esc(t.size_h)}`+\n `${progress(t)}`+\n `${esc(t.down_rate_h)}`+\n `${esc(t.up_rate_h)}`+\n `${esc(t.eta_h||\"-\")}`+\n `${esc(t.seeds)}`+\n `${esc(t.peers)}`+\n `${esc(t.ratio)}`+\n `${esc(t.path)}`+\n `${labels||'-'}`+\n `${esc(t.ratio_group||'')}`+\n `${esc(t.down_total_h||'-')}`+\n `${esc(t.to_download_h||'-')}`+\n `${esc(t.up_total_h||'-')}`+\n `${esc(formatDateTime(t.created))}`+\n `${esc(formatDateTime(t.last_activity))}`+\n `${esc(t.priority ?? '-')}`+\n `${boolCell(t.state)}`+\n `${boolCell(t.active)}`+\n `${boolCell(t.complete)}`+\n `${esc(t.hashing ?? 0)}`+\n `${compactCell(t.message||'', 80)}`+\n `${esc(t.hash||'')}`+\n ``;\n }\n\n\n\n\n function renderMobile(){\n const list=$('mobileList');\n if(!list) return;\n const src=mobileVisibleRows();\n const rows=src.slice(0,250);\n renderMobileFilters(src);\n list.innerHTML=rows.map(t=>{\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', errorLog?'torrent-warning':''].filter(Boolean).join(' ');\n const lines=mobileInfoLines(t);\n // Note: Mobile details use a separate corner button so user-configurable action buttons keep their current order.\n return `
${torrentNameStatusIcon(t)}${esc(t.name)}
${lines.primary?`
${lines.primary}
`:''}${lines.secondary?`
${lines.secondary}
`:''}${mobileColumns.path?`
${esc(t.path)}
`:''}
${mobileColumns.progress?`
${progress(t)}
`:''}
`;\n }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...'));\n }\n function renderTable(){ updateBulkBar(); syncActiveFilterSelection(); renderCounts(); renderLabelFilters(); if(typeof renderHealthDashboard==='function') renderHealthDashboard(); if(typeof renderSmartViewsManager==='function') renderSmartViewsManager(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?`No torrents for this filter.`:loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const rowHeight=torrentRowHeight(); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/rowHeight)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/rowHeight)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${activeTrackerFilter}:${compactTorrentListEnabled?1:0}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*rowHeight,bottom=Math.max(0,(visibleRows.length-end)*rowHeight); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); }\n function scheduleRender(force=false){ if(force){lastRenderSignature='';renderVersion++;} if(renderPending)return; renderPending=true; requestAnimationFrame(()=>{renderPending=false;renderTable();}); }\n function patchRows(msg){ if(msg.summary) torrentSummary=msg.summary; (msg.removed||[]).forEach(h=>{torrents.delete(h);selected.delete(h);activeOperations.delete(h);if(selectedHash===h)selectedHash=null;}); (msg.added||[]).forEach(t=>torrents.set(t.hash,t)); (msg.updated||[]).forEach(p=>torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p})); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function applyLiveTorrentStats(msg){ (msg.updated||[]).forEach(p=>{ if(torrents.has(p.hash)) torrents.set(p.hash,{...(torrents.get(p.hash)||{}),...p}); }); if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle(); scheduleRender(true); if(selectedHash&&torrents.has(selectedHash)&&activeTab()==='general') renderGeneral(); }\n function selectedHashes(){ return [...selected]; }\n function updateBulkBar(){\n const bar=$(\"bulkBar\");\n if(!bar) return;\n // Note: The desktop bulk toolbar is hidden in mobile mode; mobile has its own compact actions in the filter bar.\n const isMobileMode = document.body.classList.contains('mobile-mode');\n const show = selected.size > 1 && !isMobileMode;\n bar.classList.toggle(\"d-none\", !show);\n bar.setAttribute('aria-hidden', show ? 'false' : 'true');\n const c=$(\"bulkSelectedCount\");\n if(c) c.textContent=selected.size;\n }\n function setSelectionRange(hash, keepExisting=false){ const current=visibleRows.findIndex(t=>t.hash===hash); const last=visibleRows.findIndex(t=>t.hash===lastSelectedHash); if(current<0 || last<0){ selected.add(hash); lastSelectedHash=hash; return; } if(!keepExisting) selected.clear(); const a=Math.min(current,last), b=Math.max(current,last); visibleRows.slice(a,b+1).forEach(t=>selected.add(t.hash)); selectedHash=hash; }\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 4ad3d1e..8587950 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1924,6 +1924,7 @@ body.mobile-mode .mobile-card { } .torrent-warning-icon { color: var(--bs-warning); + cursor: help; margin-right: 0.2rem; } .mobile-filter-bar { @@ -4656,6 +4657,28 @@ body, vertical-align: top; } +.operation-log-details { + max-width: 24rem; +} + +.operation-log-details summary { + cursor: pointer; + overflow-wrap: anywhere; +} + +.operation-log-details pre { + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: .5rem; + color: var(--bs-body-color); + font-size: .75rem; + margin: .5rem 0 0; + max-height: 18rem; + overflow: auto; + padding: .65rem; + white-space: pre-wrap; +} + @media (max-width: 760px) { .operation-log-type-filter, .operation-log-search { diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 028ea72..25eee33 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -298,7 +298,7 @@
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
Job scheduling
These settings are stored per active rTorrent profile. Light jobs are control actions such as start, stop, pause, resume, labels, ratio assignment, reannounce and speed limits. Heavy jobs are long or destructive actions such as move, remove and adding torrents.
-
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
+
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
Users
Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.
Labels
Create reusable labels and remove labels that are no longer needed.