diff --git a/pytorrent/db.py b/pytorrent/db.py index 672b665..5a7c4a1 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -234,6 +234,14 @@ CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, 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 = [ @@ -253,6 +261,7 @@ MIGRATIONS = [ "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 baseline_value TEXT", + "ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0", ] diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 1e131dc..4540a58 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -16,7 +16,7 @@ import xml.etree.ElementTree as ET from flask import Blueprint, jsonify, request from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS 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_summary import cached_summary 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//files") def torrent_files(torrent_hash: str): profile = preferences.active_profile() diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index f7dd6b9..d0cd51a 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -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: with connect() as conn: row = conn.execute( @@ -156,8 +163,10 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current if not row: return False previous = row.get('previous_label') or '' + live_label = _read_label(client, torrent_hash, current_label or '') 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) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True @@ -301,9 +310,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = pass for t in to_resume: 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 '')) c.call('d.resume', t['hash']) c.call('d.start', t['hash']) + _restore_auto_label(c, profile_id, t['hash'], None) resumed.append(t['hash']) except Exception: pass diff --git a/pytorrent/services/torrent_stats.py b/pytorrent/services/torrent_stats.py new file mode 100644 index 0000000..adff635 --- /dev/null +++ b/pytorrent/services/torrent_stats.py @@ -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 diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index 437d03e..af13c42 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -6,7 +6,7 @@ from ..config import POLL_INTERVAL from .preferences import active_profile, get_profile from .torrent_cache import torrent_cache 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 @@ -43,6 +43,9 @@ def register_socketio_handlers(socketio): heartbeat["ok"] = False heartbeat["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: try: result = smart_queue.check(profile, force=False) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 65b78ac..130403e 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -265,7 +265,7 @@ return badges.join(' ') || '-'; } 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),`
`]); + 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),`
`]); $('detailPane').innerHTML=table(['Flag','IP','Country','City','Client','%','DL','UL','Port','Flags','Actions'],rows); } async function peerAction(index, action){ @@ -637,7 +637,39 @@ }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } - $('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 `
${esc(label)}${esc(value ?? '-')}${note?`${esc(note)}`:''}
`; + } + 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 ? `
File metadata warnings: ${esc(stats.errors.length)} torrent(s). ${esc(stats.error||'')}
` : ''; + box.innerHTML=`
${cards.join('')}
${errors}`; + } + async function loadTorrentStats(force=false){ + const box=$('torrentStatsManager'); + if(!box) return; + box.innerHTML=' 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=`
${esc(e.message)}
`; } + } + + $('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();}); 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);}); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 9f74a96..dd8964c 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -1774,3 +1774,66 @@ body.mobile-mode #mobileList { #statusSockets { 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); +} diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index ab36752..8aa2488 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -146,7 +146,7 @@ - +