new functions

This commit is contained in:
Mateusz Gruszczyński
2026-05-05 08:00:32 +02:00
parent 4b236c21f8
commit fa0d2f13fe
8 changed files with 304 additions and 6 deletions

View File

@@ -234,6 +234,14 @@ CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT value TEXT
); );
CREATE TABLE IF NOT EXISTS torrent_stats_cache (
profile_id INTEGER PRIMARY KEY,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_epoch REAL DEFAULT 0
);
""" """
MIGRATIONS = [ MIGRATIONS = [
@@ -253,6 +261,7 @@ MIGRATIONS = [
"ALTER TABLE automation_rules ADD COLUMN cooldown_minutes INTEGER DEFAULT 60", "ALTER TABLE automation_rules ADD COLUMN cooldown_minutes INTEGER DEFAULT 60",
"ALTER TABLE rtorrent_config_overrides ADD COLUMN apply_on_start INTEGER DEFAULT 0", "ALTER TABLE rtorrent_config_overrides ADD COLUMN apply_on_start INTEGER DEFAULT 0",
"ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT", "ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT",
"ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0",
] ]

View File

@@ -16,7 +16,7 @@ import xml.etree.ElementTree as ET
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS
from ..db import default_user_id, connect, utcnow from ..db import default_user_id, connect, utcnow
from ..services import preferences, rtorrent from ..services import preferences, rtorrent, torrent_stats
from ..services.torrent_cache import torrent_cache from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs, emergency_clear_jobs from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs, emergency_clear_jobs
@@ -410,6 +410,17 @@ def torrents():
}) })
@bp.get("/torrent-stats")
def torrent_stats_get():
profile = preferences.active_profile()
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
try:
# Note: Heavy file metadata is served from a 15-minute DB cache unless the user explicitly refreshes it.
return ok({"stats": torrent_stats.get(profile, force=force)})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 500
@bp.get("/torrents/<torrent_hash>/files") @bp.get("/torrents/<torrent_hash>/files")
def torrent_files(torrent_hash: str): def torrent_files(torrent_hash: str):
profile = preferences.active_profile() profile = preferences.active_profile()

View File

@@ -147,6 +147,13 @@ def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str
) )
def _read_label(client: Any, torrent_hash: str, fallback: str = '') -> str:
try:
return str(client.call('d.custom1', torrent_hash) or '')
except Exception:
return fallback
def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current_label: str | None = None) -> bool: def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current_label: str | None = None) -> bool:
with connect() as conn: with connect() as conn:
row = conn.execute( row = conn.execute(
@@ -156,8 +163,10 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current
if not row: if not row:
return False return False
previous = row.get('previous_label') or '' previous = row.get('previous_label') or ''
live_label = _read_label(client, torrent_hash, current_label or '')
try: try:
if current_label is None or current_label == SMART_QUEUE_LABEL: # Note: Before Smart Queue resumes a paused torrent, restore the user label only when the auto label is still present.
if live_label == SMART_QUEUE_LABEL or current_label is None:
client.call('d.custom1.set', torrent_hash, previous) client.call('d.custom1.set', torrent_hash, previous)
conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash))
return True return True
@@ -301,9 +310,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
pass pass
for t in to_resume: for t in to_resume:
try: try:
# Note: Resume path explicitly removes Smart Queue auto label before and after start to cover stale cache labels.
_restore_auto_label(c, profile_id, t['hash'], str(t.get('label') or '')) _restore_auto_label(c, profile_id, t['hash'], str(t.get('label') or ''))
c.call('d.resume', t['hash']) c.call('d.resume', t['hash'])
c.call('d.start', t['hash']) c.call('d.start', t['hash'])
_restore_auto_label(c, profile_id, t['hash'], None)
resumed.append(t['hash']) resumed.append(t['hash'])
except Exception: except Exception:
pass pass

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import json
import threading
import time
from typing import Any
from ..db import connect, utcnow
from . import rtorrent
from .torrent_cache import torrent_cache
CACHE_SECONDS = 15 * 60
_STARTUP_DELAY_SECONDS = 3 * 60
_STARTED_AT = time.monotonic()
_LOCK = threading.Lock()
def _human_size(value: int | float) -> str:
size = float(value or 0)
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
if abs(size) < 1024 or unit == "PiB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} PiB"
def _empty(profile_id: int, error: str = "") -> dict[str, Any]:
now = utcnow()
return {
"profile_id": profile_id,
"torrent_count": 0,
"complete_count": 0,
"incomplete_count": 0,
"total_torrent_size": 0,
"total_torrent_size_h": _human_size(0),
"total_file_size": 0,
"total_file_size_h": _human_size(0),
"file_count": 0,
"seeds_total": 0,
"peers_total": 0,
"down_rate_total": 0,
"up_rate_total": 0,
"down_rate_total_h": "0 B/s",
"up_rate_total_h": "0 B/s",
"sampled_torrents": 0,
"errors": [],
"error": error,
"created_at": now,
"updated_at": now,
"age_seconds": 0,
"stale": True,
}
def _load_cached(profile_id: int) -> dict[str, Any] | None:
with connect() as conn:
row = conn.execute("SELECT * FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).fetchone()
if not row:
return None
payload = json.loads(row.get("payload_json") or "{}")
payload["created_at"] = row.get("created_at")
payload["updated_at"] = row.get("updated_at")
try:
payload["age_seconds"] = max(0, int(time.time() - float(row.get("updated_epoch") or 0)))
except Exception:
payload["age_seconds"] = 0
payload["stale"] = payload["age_seconds"] >= CACHE_SECONDS
return payload
def _save(profile_id: int, payload: dict[str, Any]) -> dict[str, Any]:
now = utcnow()
payload = dict(payload)
payload["updated_at"] = now
payload["age_seconds"] = 0
payload["stale"] = False
with connect() as conn:
conn.execute(
"""
INSERT INTO torrent_stats_cache(profile_id,payload_json,created_at,updated_at,updated_epoch)
VALUES(?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
payload_json=excluded.payload_json,
updated_at=excluded.updated_at,
updated_epoch=excluded.updated_epoch
""",
(profile_id, json.dumps(payload), now, now, time.time()),
)
return payload
def collect(profile: dict) -> dict[str, Any]:
"""Collect heavier torrent/file statistics on demand or every cache window."""
profile_id = int(profile.get("id") or 0)
torrents = rtorrent.list_torrents(profile)
total_torrent_size = sum(int(t.get("size") or 0) for t in torrents)
seeds_total = sum(int(t.get("seeds") or 0) for t in torrents)
peers_total = sum(int(t.get("peers") or 0) for t in torrents)
down_rate_total = sum(int(t.get("down_rate") or 0) for t in torrents)
up_rate_total = sum(int(t.get("up_rate") or 0) for t in torrents)
total_file_size = 0
file_count = 0
errors: list[dict[str, str]] = []
# Note: File metadata is queried per torrent only during cached statistics refresh, not during every UI poll.
for torrent in torrents:
h = str(torrent.get("hash") or "")
if not h:
continue
try:
files = rtorrent.torrent_files(profile, h)
file_count += len(files)
total_file_size += sum(int(f.get("size") or 0) for f in files)
except Exception as exc:
errors.append({"hash": h, "name": str(torrent.get("name") or ""), "error": str(exc)})
torrent_cache.refresh(profile)
payload = {
"profile_id": profile_id,
"torrent_count": len(torrents),
"complete_count": sum(1 for t in torrents if int(t.get("complete") or 0)),
"incomplete_count": sum(1 for t in torrents if not int(t.get("complete") or 0)),
"total_torrent_size": total_torrent_size,
"total_torrent_size_h": _human_size(total_torrent_size),
"total_file_size": total_file_size,
"total_file_size_h": _human_size(total_file_size),
"file_count": file_count,
"seeds_total": seeds_total,
"peers_total": peers_total,
"down_rate_total": down_rate_total,
"up_rate_total": up_rate_total,
"down_rate_total_h": rtorrent.human_rate(down_rate_total),
"up_rate_total_h": rtorrent.human_rate(up_rate_total),
"sampled_torrents": len(torrents),
"errors": errors[:25],
"error": "" if not errors else f"File metadata failed for {len(errors)} torrent(s)",
"created_at": utcnow(),
}
return _save(profile_id, payload)
def get(profile: dict | None, force: bool = False) -> dict[str, Any]:
if not profile:
return _empty(0, "No active rTorrent profile")
profile_id = int(profile.get("id") or 0)
cached = _load_cached(profile_id)
if cached and not force and not cached.get("stale"):
return cached
if cached and not force:
return cached
with _LOCK:
cached = _load_cached(profile_id)
if cached and not force and not cached.get("stale"):
return cached
return collect(profile)
def maybe_refresh(profile: dict | None, force: bool = False) -> dict[str, Any] | None:
if not profile:
return None
if not force and time.monotonic() - _STARTED_AT < _STARTUP_DELAY_SECONDS:
return None
cached = _load_cached(int(profile.get("id") or 0))
if cached and not cached.get("stale") and not force:
return cached
try:
return get(profile, force=True)
except Exception:
return cached

View File

@@ -6,7 +6,7 @@ from ..config import POLL_INTERVAL
from .preferences import active_profile, get_profile from .preferences import active_profile, get_profile
from .torrent_cache import torrent_cache from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary from .torrent_summary import cached_summary
from . import rtorrent, smart_queue, traffic_history, automation_rules from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats
_started = False _started = False
@@ -43,6 +43,9 @@ def register_socketio_handlers(socketio):
heartbeat["ok"] = False heartbeat["ok"] = False
heartbeat["error"] = str(exc) heartbeat["error"] = str(exc)
socketio.emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)}) socketio.emit("rtorrent_error", {"profile_id": profile["id"], "error": str(exc)})
if tick % max(1, int(15 * 60 / POLL_INTERVAL)) == 0:
# Note: Torrent statistics are refreshed in the background every 15 minutes after startup delay.
torrent_stats.maybe_refresh(profile, force=False)
if tick % max(1, int(30 / POLL_INTERVAL)) == 0: if tick % max(1, int(30 / POLL_INTERVAL)) == 0:
try: try:
result = smart_queue.check(profile, force=False) result = smart_queue.check(profile, force=False)

View File

@@ -265,7 +265,7 @@
return badges.join(' ') || '<span class="text-muted">-</span>'; return badges.join(' ') || '<span class="text-muted">-</span>';
} }
function renderPeers(peers){ function renderPeers(peers){
const rows=(peers||[]).map(p=>[flag(p.country_iso),esc(p.ip),esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p),`<div class="peer-actions"><button class="btn btn-xs btn-outline-warning peer-action" data-peer-index="${esc(p.index)}" data-peer-action="disconnect" title="Kick peer"><i class="fa-solid fa-user-slash"></i><span>Kick</span></button><button class="btn btn-xs btn-outline-secondary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="snub" title="Snub peer"><i class="fa-solid fa-volume-xmark"></i><span>Snub</span></button><button class="btn btn-xs btn-outline-primary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="unsnub" title="Unsnub peer"><i class="fa-solid fa-volume-high"></i><span>Unsnub</span></button><button class="btn btn-xs btn-outline-danger peer-action" data-peer-index="${esc(p.index)}" data-peer-action="ban" title="Ban peer if supported"><i class="fa-solid fa-ban"></i><span>Ban</span></button></div>`]); const rows=(peers||[]).map(p=>[flag(p.country_iso),`<span class="peer-ip">${esc(p.ip)}<a class="peer-ip-link" href="https://ipinfo.io/${encodeURIComponent(p.ip||'')}" target="_blank" rel="noopener noreferrer" title="Open IP info"><i class="fa-solid fa-link"></i></a></span>`,esc(p.country),esc(p.city),esc(p.client),progressBar(p.completed,'peer-progress'),esc(p.down_rate_h),esc(p.up_rate_h),esc(p.port),peerBadges(p),`<div class="peer-actions"><button class="btn btn-xs btn-outline-warning peer-action" data-peer-index="${esc(p.index)}" data-peer-action="disconnect" title="Kick peer"><i class="fa-solid fa-user-slash"></i><span>Kick</span></button><button class="btn btn-xs btn-outline-secondary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="snub" title="Snub peer"><i class="fa-solid fa-volume-xmark"></i><span>Snub</span></button><button class="btn btn-xs btn-outline-primary peer-action" data-peer-index="${esc(p.index)}" data-peer-action="unsnub" title="Unsnub peer"><i class="fa-solid fa-volume-high"></i><span>Unsnub</span></button><button class="btn btn-xs btn-outline-danger peer-action" data-peer-index="${esc(p.index)}" data-peer-action="ban" title="Ban peer if supported"><i class="fa-solid fa-ban"></i><span>Ban</span></button></div>`]);
$('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags','Actions'],rows); $('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags','Actions'],rows);
} }
async function peerAction(index, action){ async function peerAction(index, action){
@@ -637,7 +637,39 @@
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; } }catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
} }
$('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); 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('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('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); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');}); function torrentStatsCard(label, value, note=''){
return `<div class="torrent-stats-card"><b>${esc(label)}</b><span>${esc(value ?? '-')}</span>${note?`<small>${esc(note)}</small>`:''}</div>`;
}
function renderTorrentStats(stats={}){
const box=$('torrentStatsManager');
if(!box) return;
const age=Number(stats.age_seconds||0);
const updated=stats.updated_at ? String(stats.updated_at).replace('T',' ').replace(/\+00:00$/,' UTC') : '-';
const cards=[
torrentStatsCard('Torrents', stats.torrent_count, `${stats.complete_count||0} complete / ${stats.incomplete_count||0} incomplete`),
torrentStatsCard('Torrent size', stats.total_torrent_size_h || fmtBytes(stats.total_torrent_size)),
torrentStatsCard('Files size', stats.total_file_size_h || fmtBytes(stats.total_file_size), `${stats.file_count||0} files`),
torrentStatsCard('Seeds / peers', `${stats.seeds_total||0} / ${stats.peers_total||0}`, 'current sum from last sample'),
torrentStatsCard('Speed DL / UL', `${stats.down_rate_total_h||'0 B/s'} / ${stats.up_rate_total_h||'0 B/s'}`),
torrentStatsCard('Sampled', stats.sampled_torrents ?? 0, stats.stale?'cache is stale':'cache is fresh')
];
if($('torrentStatsMeta')) $('torrentStatsMeta').textContent=`Updated: ${updated}, age: ${age}s`;
const errors=Array.isArray(stats.errors)&&stats.errors.length ? `<div class="alert alert-warning py-2 mt-3 mb-0">File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}</div>` : '';
box.innerHTML=`<div class="torrent-stats-grid">${cards.join('')}</div>${errors}`;
}
async function loadTorrentStats(force=false){
const box=$('torrentStatsManager');
if(!box) return;
box.innerHTML='<span class="spinner-border spinner-border-sm"></span> Loading torrent statistics...';
try{
const j=await (await fetch(`/api/torrent-stats${force?'?force=1':''}`)).json();
if(!j.ok) throw new Error(j.error||'Torrent statistics failed');
renderTorrentStats(j.stats||{});
if(force) toast('Torrent statistics refreshed','success');
}catch(e){ box.innerHTML=`<div class="text-danger">${esc(e.message)}</div>`; }
}
$('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',appstatus:'toolAppstatus'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='preferences') loadPreferences();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('rssFeedBtn')?.addEventListener('click',async()=>{await post('/api/rss/feeds',{name:$('rssName').value,url:$('rssUrl').value}); loadRss();}); $('rssRuleBtn')?.addEventListener('click',async()=>{await post('/api/rss/rules',{name:$('rssRuleName').value,pattern:$('rssPattern').value,save_path:$('rssPath').value,label:$('rssLabel').value}); loadRss();}); $('rssCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/rss/check',{}); toast(`RSS queued ${j.queued} item(s)`,'success');}catch(e){toast(e.message,'danger');} finally{setBusy(false);}}); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); const r=j.result||{}; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}`,'success'); await loadSmartQueue();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('smartManager')?.addEventListener('click',async e=>{const h=e.target.closest('.smart-unexclude')?.dataset.hash; if(!h)return; await post('/api/smart-queue/exclusion',{hash:h,excluded:false}); await loadSmartQueue();}); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); 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('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job and Smart Queue logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('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); $('peersRefreshSelect')?.addEventListener('change',async e=>{peersRefreshSeconds=Number(e.target.value||0); await post('/api/preferences',{peers_refresh_seconds:peersRefreshSeconds}).catch(()=>{}); setupPeersRefresh(activeTab()); toast('Peers refresh preference saved','success');});
$('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();}); $('autoConditionType')?.addEventListener('change',updateAutomationForm); $('autoEffectType')?.addEventListener('change',updateAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); toast(`Automations applied ${j.result?.applied?.length||0} item(s)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const id=e.target.closest('.automation-delete')?.dataset.id;if(!id)return;if(!confirm('Delete this automation rule?'))return;const r=await fetch('/api/automations/'+id,{method:'DELETE'});const j=await r.json();if(!j.ok)toast(j.error||'Delete failed','danger');await loadAutomations();});
document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} }); document.addEventListener('click',async e=>{ const btn=e.target.closest('.delete-label'); if(!btn)return; if(!confirm('Delete this label?')) return; setBusy(true); try{ const r=await fetch('/api/labels/'+btn.dataset.id,{method:'DELETE'}); const j=await r.json(); if(!j.ok) throw new Error(j.error||'Delete failed'); await loadLabels(); toast('Label deleted','success'); }catch(err){toast(err.message,'danger');} finally{setBusy(false);} });
$('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);}); $('bulkClearBtn')?.addEventListener('click',()=>{selected.clear(); selectedHash=null; lastSelectedHash=null; updateBulkBar(); if($('selectAll')) $('selectAll').checked=false; if($('detailPane')) $('detailPane').innerHTML='Select a torrent.'; setupPeersRefresh('general'); scheduleRender(true);});

View File

@@ -1774,3 +1774,66 @@ body.mobile-mode #mobileList {
#statusSockets { #statusSockets {
white-space: nowrap; white-space: nowrap;
} }
/* Torrent statistics */
.torrent-stats-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.torrent-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.torrent-stats-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.85rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.45);
}
.torrent-stats-card b {
color: var(--bs-secondary-color);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.torrent-stats-card span {
font-size: 1.05rem;
font-weight: 700;
}
.torrent-stats-card small {
color: var(--bs-secondary-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Peer table links */
.peer-ip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.peer-ip-link {
color: var(--bs-secondary-color);
font-size: 0.75rem;
text-decoration: none;
}
.peer-ip-link:hover {
color: var(--bs-primary);
}

File diff suppressed because one or more lines are too long