diff --git a/.env.example b/.env.example
index 412ad1a..7bf614d 100644
--- a/.env.example
+++ b/.env.example
@@ -70,3 +70,9 @@ PYTORRENT_SESSION_COOKIE_SECURE=false
# bypass auth on specific hosts (ex. local ip)
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
PYTORRENT_AUTH_BYPASS_USER=admin
+
+# db vacuum
+PYTORRENT_DB_VACUUM_ENABLE=true
+PYTORRENT_DB_VACUUM_EVERY_SECONDS=86400
+PYTORRENT_DB_VACUUM_MIN_FREE_MB=512
+PYTORRENT_DB_VACUUM_MIN_FREE_RATIO=0.25
\ No newline at end of file
diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py
index 7243441..17ed555 100644
--- a/pytorrent/routes/_shared.py
+++ b/pytorrent/routes/_shared.py
@@ -22,8 +22,8 @@ from flask import Blueprint, jsonify, request, abort, send_file, redirect, Respo
# Note: url_for is exported through this shared module for API routes that build temporary in-app links.
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR
from ..db import connect, utcnow
-from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
-from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control
+from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin
+from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance
from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs
@@ -260,10 +260,13 @@ def _table_count(table: str, where: str = "", params: tuple = ()) -> int:
def _db_size() -> dict:
try:
- size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
- return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)}
+ return database_maintenance.database_status()
except Exception as exc:
- return {"path": str(DB_PATH), "size": 0, "size_h": "0 B", "error": str(exc)}
+ try:
+ size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
+ except Exception:
+ size = 0
+ return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size), "error": str(exc)}
def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
@@ -312,6 +315,7 @@ def cleanup_summary() -> dict:
"operation_logs": operation_logs.retention_label(operation_log_retention),
},
"database": _db_size(),
+ "admin": is_admin(current_user()),
}
def active_default_download_path(profile: dict | None) -> str:
diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py
index b6a2162..49707dc 100644
--- a/pytorrent/routes/system.py
+++ b/pytorrent/routes/system.py
@@ -203,6 +203,17 @@ def cleanup_jobs():
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
+@bp.post("/cleanup/database/vacuum")
+def cleanup_database_vacuum():
+ require_admin()
+ data = request.get_json(silent=True) or {}
+ try:
+ result = database_maintenance.vacuum_database(force=bool(data.get("force")))
+ return ok({"vacuum": result, "cleanup": cleanup_summary()})
+ except Exception as exc:
+ return jsonify({"ok": False, "error": str(exc), "cleanup": cleanup_summary()}), 400
+
+
@bp.post("/cleanup/smart-queue")
def cleanup_smart_queue():
diff --git a/pytorrent/services/database_maintenance.py b/pytorrent/services/database_maintenance.py
new file mode 100644
index 0000000..95b108c
--- /dev/null
+++ b/pytorrent/services/database_maintenance.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import shutil
+import sqlite3
+import threading
+import time
+from typing import Any
+
+from ..config import DB_PATH
+
+_VACUUM_LOCK = threading.Lock()
+MIN_DISK_HEADROOM_BYTES = 128 * 1024 * 1024
+
+
+def _human_size(value: int | float | None) -> str:
+ size = float(value or 0)
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
+ idx = 0
+ while size >= 1024 and idx < len(units) - 1:
+ size /= 1024.0
+ idx += 1
+ if idx == 0:
+ return f"{int(size)} {units[idx]}"
+ return f"{size:.2f} {units[idx]}"
+
+
+def _connect() -> sqlite3.Connection:
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(DB_PATH, timeout=60, isolation_level=None)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA busy_timeout = 60000")
+ return conn
+
+
+def _pragma_int(conn: sqlite3.Connection, pragma_name: str) -> int:
+ row = conn.execute(f"PRAGMA {pragma_name}").fetchone()
+ if row is None:
+ return 0
+ return int(row[0] or 0)
+
+
+def database_status() -> dict[str, Any]:
+ size_bytes = DB_PATH.stat().st_size if DB_PATH.exists() else 0
+ wal_path = DB_PATH.with_name(DB_PATH.name + "-wal")
+ wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0
+ page_size = 0
+ page_count = 0
+ freelist_count = 0
+ error = None
+ if DB_PATH.exists():
+ try:
+ with _connect() as conn:
+ page_size = _pragma_int(conn, "page_size")
+ page_count = _pragma_int(conn, "page_count")
+ freelist_count = _pragma_int(conn, "freelist_count")
+ except Exception as exc:
+ error = str(exc)
+ free_bytes = int(page_size * freelist_count)
+ logical_bytes = int(page_size * page_count)
+ free_ratio = (free_bytes / logical_bytes) if logical_bytes else 0.0
+ try:
+ disk = shutil.disk_usage(str(DB_PATH.parent))
+ disk_free = int(disk.free)
+ except Exception:
+ disk_free = 0
+ return {
+ "path": str(DB_PATH),
+ "size": int(size_bytes),
+ "size_h": _human_size(size_bytes),
+ "wal_size": int(wal_bytes),
+ "wal_size_h": _human_size(wal_bytes),
+ "page_size": page_size,
+ "page_count": page_count,
+ "freelist_count": freelist_count,
+ "free_inside": free_bytes,
+ "free_inside_h": _human_size(free_bytes),
+ "free_ratio": round(free_ratio, 4),
+ "free_ratio_percent": round(free_ratio * 100, 2),
+ "disk_free": disk_free,
+ "disk_free_h": _human_size(disk_free),
+ "vacuum_running": _VACUUM_LOCK.locked(),
+ "error": error,
+ }
+
+
+def _checkpoint_truncate(conn: sqlite3.Connection) -> dict[str, int] | None:
+ try:
+ row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
+ if row is None:
+ return None
+ return {"busy": int(row[0] or 0), "log": int(row[1] or 0), "checkpointed": int(row[2] or 0)}
+ except sqlite3.DatabaseError:
+ return None
+
+
+def vacuum_database(force: bool = False) -> dict[str, Any]:
+ if not DB_PATH.exists():
+ raise FileNotFoundError(f"Database not found: {DB_PATH}")
+ if not _VACUUM_LOCK.acquire(blocking=False):
+ raise RuntimeError("Database vacuum is already running")
+ try:
+ before = database_status()
+ required_free = int(before.get("size") or 0) + MIN_DISK_HEADROOM_BYTES
+ available_free = int(before.get("disk_free") or 0)
+ if available_free and available_free < required_free:
+ raise RuntimeError(
+ "Not enough free disk space for VACUUM: "
+ f"need about {_human_size(required_free)}, have {_human_size(available_free)}"
+ )
+ if not force and int(before.get("free_inside") or 0) <= 0:
+ return {"ok": True, "skipped": True, "reason": "No free pages inside SQLite database", "before": before, "after": before}
+ started = time.perf_counter()
+ with _connect() as conn:
+ checkpoint_before = _checkpoint_truncate(conn)
+ conn.execute("VACUUM")
+ checkpoint_after = _checkpoint_truncate(conn)
+ after = database_status()
+ return {
+ "ok": True,
+ "skipped": False,
+ "duration_seconds": round(time.perf_counter() - started, 3),
+ "checkpoint_before": checkpoint_before,
+ "checkpoint_after": checkpoint_after,
+ "before": before,
+ "after": after,
+ "reclaimed": max(0, int(before.get("size") or 0) - int(after.get("size") or 0)),
+ "reclaimed_h": _human_size(max(0, int(before.get("size") or 0) - int(after.get("size") or 0))),
+ }
+ finally:
+ _VACUUM_LOCK.release()
diff --git a/pytorrent/static/js/backupCleanupRtconfigEvents.js b/pytorrent/static/js/backupCleanupRtconfigEvents.js
index e7af131..12f4be5 100644
--- a/pytorrent/static/js/backupCleanupRtconfigEvents.js
+++ b/pytorrent/static/js/backupCleanupRtconfigEvents.js
@@ -1 +1 @@
-export const backupCleanupRtconfigEventsSource = "$('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupPollerDiagnosticsBtn')) return runCleanupAction('/api/cleanup/poller-diagnostics','Reset poller diagnostics'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); ";
+export const backupCleanupRtconfigEventsSource = "$('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupPollerDiagnosticsBtn')) return runCleanupAction('/api/cleanup/poller-diagnostics','Reset poller diagnostics'); if(e.target.closest('#cleanupDatabaseVacuumBtn')) return runCleanupAction('/api/cleanup/database/vacuum','Compact SQLite database'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); ";
diff --git a/pytorrent/static/js/cleanupTools.js b/pytorrent/static/js/cleanupTools.js
index 4b6f9f3..19afb97 100644
--- a/pytorrent/static/js/cleanupTools.js
+++ b/pytorrent/static/js/cleanupTools.js
@@ -1 +1 @@
-export const cleanupToolsSource = " function cleanupCountCard(label, value, note=''){\n return `
${esc(label)} ${esc(value ?? 0)} ${note?`${esc(note)} `:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n const poller=data.poller_runtime||{};\n const pollerCards=[\n cleanupCountCard('Live poll counter', poller.live_poll_count ?? 0, 'lightweight speed/status loop'),\n cleanupCountCard('List poll counter', poller.list_poll_count ?? 0, 'full snapshot/diff loop'),\n cleanupCountCard('Poller skipped emits', poller.skipped_emissions ?? 0, 'diagnostic counter only')\n ];\n box.innerHTML=`${cards.join('')}${pollerCards.join('')}
Profile cache Clears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Clear profile cachePoller diagnostics Resets in-memory live/list poller counters only. Polling, saved settings and torrent data stay unchanged.
Reset poller diagnosticsLogs and history Pending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
Clear job logs Clear Smart Queue logs Clear operation logs Clear Planner logs Clear automation logs Clear all logs
Refresh
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n";
+export const cleanupToolsSource = " function cleanupCountCard(label, value, note=''){\n return `${esc(label)} ${esc(value ?? 0)} ${note?`${esc(note)} `:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||''),\n cleanupCountCard('SQLite free inside', db.free_inside_h||'0 B', `${db.free_ratio_percent ?? 0}% reusable`),\n cleanupCountCard('SQLite WAL', db.wal_size_h||'0 B', 'write-ahead log')\n ];\n const poller=data.poller_runtime||{};\n const pollerCards=[\n cleanupCountCard('Live poll counter', poller.live_poll_count ?? 0, 'lightweight speed/status loop'),\n cleanupCountCard('List poll counter', poller.list_poll_count ?? 0, 'full snapshot/diff loop'),\n cleanupCountCard('Poller skipped emits', poller.skipped_emissions ?? 0, 'diagnostic counter only')\n ];\n const adminSection = data.admin ? `Database compact Reclaims free pages after retention cleanup and truncates WAL. It can briefly block database writes.
Compact database ` : '';\n box.innerHTML=`${cards.join('')}${pollerCards.join('')}
Profile cache Clears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Clear profile cachePoller diagnostics Resets in-memory live/list poller counters only. Polling, saved settings and torrent data stay unchanged.
Reset poller diagnostics ${adminSection}Logs and history Pending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
Clear job logs Clear Smart Queue logs Clear operation logs Clear Planner logs Clear automation logs Clear all logs
Refresh
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n if(j.vacuum){\n const vacuum=j.vacuum||{};\n if(vacuum.skipped){\n toast(`Database compact skipped: ${vacuum.reason || 'nothing to reclaim'}`,'warning');\n } else {\n toast(`Database compact done. Reclaimed ${vacuum.reclaimed_h || '0 B'}.`,'success');\n }\n } else {\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n }\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n";
diff --git a/pytorrent/static/js/mobileTorrentDetails.js b/pytorrent/static/js/mobileTorrentDetails.js
index 87410a1..f046fd4 100644
--- a/pytorrent/static/js/mobileTorrentDetails.js
+++ b/pytorrent/static/js/mobileTorrentDetails.js
@@ -1 +1 @@
-export const mobileTorrentDetailsSource = " function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `${esc(label)} ${mobileDetailValue(value)}
`).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')} `;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function scheduleMobileReverseDnsRefresh(hash, torrent, payload){\n // Note: Mobile details refresh only the peers payload while preserving already-loaded files and trackers.\n clearTimeout(mobileReverseDnsRefreshTimer);\n if(!hasPendingReverseDns(payload.peers?.value?.peers || [])){ mobileReverseDnsRefreshAttempts=0; return; }\n if(!hash || mobileReverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n mobileReverseDnsRefreshAttempts+=1;\n mobileReverseDnsRefreshTimer=setTimeout(async()=>{\n if(selectedHash!==hash || !$('mobileDetailsModal')?.classList.contains('show')) return;\n try{\n const peersJson=await fetchMobileDetailsJson(hash, 'peers');\n const nextPayload={...payload, peers:{status:'fulfilled', value:peersJson}};\n const body=$('mobileDetailsBody');\n if(body && selectedHash===hash){\n body.innerHTML=renderMobileDetailsContent(torrent, nextPayload);\n scheduleMobileReverseDnsRefresh(hash, torrent, nextPayload);\n }\n }catch(_){ }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return 'No peers returned by rTorrent.
';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table mobile-details-peers-table peers-table-hosts' : 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `${renderFileInfoButton(file)}
`;\n return [\n `${esc(file.path || file.name || '-')} `,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)} `,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return 'No files returned by rTorrent.
';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `${esc(t.url || '-')} Seeds / Peers: ${esc(trackerSeedsPeers(t))} `;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = ` ${esc(title)}${meta ? `${esc(meta)} ` : ''} `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `${titleMarkup} ${body} `;\n }\n return ``;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = ` Loading torrent details...
`;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || 'No trackers returned by rTorrent. ';\n const generalBody = `${esc(t.name || '-')}
Path ${esc(fullPath)}
Hash ${esc(t.hash || '-')}
${mobileDetailsStatCards(t)}
`;\n const messageBody = `${esc(t.message || 'No message.')}
`;\n const errorBox = failures.length ? `Partial details loaded ${esc(failures.join(' | '))}
` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', ``, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = ' Loading peers, files and trackers...
';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n mobileReverseDnsRefreshAttempts=0;\n scheduleMobileReverseDnsRefresh(hash, t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `Details failed ${esc(e.message)}
`;\n }\n }\n\n";
+export const mobileTorrentDetailsSource = " function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `${esc(label)} ${mobileDetailValue(value)}
`).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')} `;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function scheduleMobileReverseDnsRefresh(hash, torrent, payload){\n // Note: Mobile details refresh only the peers payload while preserving already-loaded files and trackers.\n clearTimeout(mobileReverseDnsRefreshTimer);\n if(!hasPendingReverseDns(payload.peers?.value?.peers || [])){ mobileReverseDnsRefreshAttempts=0; return; }\n if(!hash || mobileReverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n mobileReverseDnsRefreshAttempts+=1;\n mobileReverseDnsRefreshTimer=setTimeout(async()=>{\n if(selectedHash!==hash || !$('mobileDetailsModal')?.classList.contains('show')) return;\n try{\n const peersJson=await fetchMobileDetailsJson(hash, 'peers');\n const nextPayload={...payload, peers:{status:'fulfilled', value:peersJson}};\n const body=$('mobileDetailsBody');\n if(body && selectedHash===hash){\n body.innerHTML=renderMobileDetailsContent(torrent, nextPayload);\n scheduleMobileReverseDnsRefresh(hash, torrent, nextPayload);\n }\n }catch(_){ }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return 'No peers returned by rTorrent.
';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table mobile-details-peers-table peers-table-hosts' : 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `${renderFileInfoButton(file)}
`;\n return [\n `${esc(file.path || file.name || '-')} `,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)} `,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return 'No files returned by rTorrent.
';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerRows(trackers){\n // Note: Mobile trackers mirror the desktop tracker table instead of using a separate list layout.\n return (trackers || []).slice(0, 12).map(t => [\n `#${esc(t.index)} `,\n trackerUrlCell(t),\n trackerEnabledCell(t.enabled),\n esc(trackerSeedsPeers(t)),\n esc(t.downloaded ?? '-'),\n fmtTs(t.last_announce),\n ]);\n }\n function mobileDetailsTrackerTable(trackers){\n const rows = mobileDetailsTrackerRows(trackers);\n if(!rows.length) return 'No trackers returned by rTorrent.
';\n return responsiveTable(['#', 'URL', 'On', 'Seeds / Peers', 'Done', 'Last announce'], rows, 'tracker-table mobile-details-trackers-table');\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = ` ${esc(title)}${meta ? `${esc(meta)} ` : ''} `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `${titleMarkup} ${body} `;\n }\n return ``;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = ` Loading torrent details...
`;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerTable = mobileDetailsTrackerTable(trackers);\n const generalBody = `${esc(t.name || '-')}
Path ${esc(fullPath)}
Hash ${esc(t.hash || '-')}
${mobileDetailsStatCards(t)}
`;\n const messageBody = `${esc(t.message || 'No message.')}
`;\n const errorBox = failures.length ? `Partial details loaded ${esc(failures.join(' | '))}
` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', trackerTable, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = ' Loading peers, files and trackers...
';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n mobileReverseDnsRefreshAttempts=0;\n scheduleMobileReverseDnsRefresh(hash, t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `Details failed ${esc(e.message)}
`;\n }\n }\n\n";
diff --git a/pytorrent/static/js/torrentTrackerDetails.js b/pytorrent/static/js/torrentTrackerDetails.js
index f93ca1c..ffae31b 100644
--- a/pytorrent/static/js/torrentTrackerDetails.js
+++ b/pytorrent/static/js/torrentTrackerDetails.js
@@ -1 +1 @@
-export const torrentTrackerDetailsSource = " function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx} `, `${url || '- '} `, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), ` Delete
`];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '- ','No trackers. ','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n";
+export const torrentTrackerDetailsSource = " function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function trackerUrlCell(t){\n const url=String(t.url||'').trim();\n // Note: Tracker URLs now use the same single-line ellipsis behavior as peer table cells.\n return url ? `${esc(url)} ` : '- ';\n }\n function trackerEnabledCell(enabled){\n // Note: Tracker enabled state is rendered as a compact badge to keep row height aligned with peers.\n return enabled ? 'yes ' : 'no ';\n }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx} `, trackerUrlCell(t), trackerEnabledCell(t.enabled), esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), ` Delete
`];\n });\n // Note: Trackers now use the same fixed responsive table pattern as peers for consistent row text and spacing.\n pane.innerHTML=`${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '- ','No trackers. ','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n";
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index 8c15a66..2f9c3f7 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -2221,8 +2221,55 @@ body.mobile-mode .mobile-filter-bar {
max-width: 520px;
}
+.tracker-table {
+ min-width: 960px;
+ table-layout: fixed;
+ width: 100%;
+}
+
+.tracker-table th,
+.tracker-table td {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: middle;
+}
+
+.tracker-table th:nth-child(1),
+.tracker-table td:nth-child(1) {
+ width: 6%;
+}
+
+.tracker-table th:nth-child(2),
+.tracker-table td:nth-child(2) {
+ width: 38%;
+}
+
+.tracker-table th:nth-child(3),
+.tracker-table td:nth-child(3),
+.tracker-table th:nth-child(4),
+.tracker-table td:nth-child(4),
+.tracker-table th:nth-child(5),
+.tracker-table td:nth-child(5) {
+ width: 10%;
+}
+
+.tracker-table th:nth-child(6),
+.tracker-table td:nth-child(6) {
+ width: 16%;
+}
+
+.tracker-table th:nth-child(7),
+.tracker-table td:nth-child(7) {
+ width: 10%;
+}
+
.tracker-url-text {
- word-break: break-all;
+ display: block;
+ max-width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.tool-note {
@@ -5450,10 +5497,11 @@ body,
.mobile-details-files-table {
margin-bottom: 0;
+ min-width: 760px;
}
-.mobile-details-files-table {
- min-width: 760px;
+.mobile-details-trackers-table {
+ margin-bottom: 0;
}
.mobile-details-files-table .file-progress {