new functions
This commit is contained in:
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
169
pytorrent/services/torrent_stats.py
Normal file
169
pytorrent/services/torrent_stats.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);});
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user