diff --git a/.env.example b/.env.example index ba07c7c..78f7d5d 100644 --- a/.env.example +++ b/.env.example @@ -25,4 +25,4 @@ PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 PYTORRENT_JOBS_RETENTION_DAYS=30 PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS=30 PYTORRENT_LOG_RETENTION_DAYS=30 -PYTORRENT_SMART_QUEUE_LABEL="Smart Queue Paused" \ No newline at end of file +PYTORRENT_SMART_QUEUE_LABEL="Smart Queue" diff --git a/pytorrent/config.py b/pytorrent/config.py index cac301b..97f7f20 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -70,5 +70,5 @@ TRAFFIC_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_TRAFFIC_HISTORY_RETENTION_D JOBS_RETENTION_DAYS = _env_int("PYTORRENT_JOBS_RETENTION_DAYS", 30, 1) SMART_QUEUE_HISTORY_RETENTION_DAYS = _env_int("PYTORRENT_SMART_QUEUE_HISTORY_RETENTION_DAYS", 30, 1) LOG_RETENTION_DAYS = _env_int("PYTORRENT_LOG_RETENTION_DAYS", 30, 1) -SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Paused") +SMART_QUEUE_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_LABEL", "Smart Queue Stopped") SMART_QUEUE_STALLED_LABEL = os.getenv("PYTORRENT_SMART_QUEUE_STALLED_LABEL", "Stalled") diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index 4298f95..142920e 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -1216,6 +1216,27 @@ def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: return result +def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: + """Stop an active rTorrent item without using pause semantics.""" + h = str(torrent_hash or '') + if not h: + return {'hash': h, 'ok': False, 'error': 'missing hash'} + before = _download_runtime_state(c, h) + result = {'hash': h, 'before': before, 'commands': []} + if before.get('stopped'): + result.update({'ok': True, 'skipped': 'already_stopped', 'after': before}) + return result + try: + # Note: Smart Queue now enforces the queue with d.stop only; user-paused torrents stay untouched. + c.call('d.stop', h) + result['commands'].append('d.stop') + result['after'] = _download_runtime_state(c, h) + result['ok'] = True + except Exception as exc: + result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)}) + return result + + def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: """Resume only a paused rTorrent item; never convert it through stop/start.""" h = str(torrent_hash or '') diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index c1558f6..0675e4b 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -38,7 +38,7 @@ def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: 'min_speed_bytes': 1024, 'min_seeds': 1, 'min_peers': 0, - 'manage_stopped': 0, + 'manage_stopped': 1, 'updated_at': utcnow(), } @@ -64,8 +64,8 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N 'min_seeds': _int_setting(data, current, 'min_seeds', 0, 0), # Note: Min peers is optional; when set, stalled detection requires low speed, low seeds and low peers. 'min_peers': _int_setting(data, current, 'min_peers', 0, 0), - # Note: This switch protects fully stopped torrents from automatic starts; by default Smart Queue manages only paused items. - 'manage_stopped': 1 if data.get('manage_stopped', current.get('manage_stopped')) else 0, + # Note: Compatibility field retained; enabled Smart Queue always manages stopped torrents and never manages user-paused torrents. + 'manage_stopped': 1, } now = utcnow() with connect() as conn: @@ -241,7 +241,7 @@ def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current except Exception: return False try: - # Note: Starting a torrent removes only Smart Queue's technical marker, so labels added while paused stay untouched. + # Note: Starting a torrent removes only Smart Queue's technical marker, so labels added while stopped stay untouched. if _has_smart_queue_label(live_label): client.call('d.custom1.set', torrent_hash, _without_smart_queue_label(live_label)) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) @@ -291,14 +291,13 @@ def _ensure_rtorrent_download_cap(client: Any, max_active: int) -> dict[str, Any def _start_download(client: Any, torrent: dict[str, Any]) -> dict[str, Any]: - """Resume paused torrents through rTorrent's pause model.""" + """Start only stopped Smart Queue candidates; paused torrents are a user decision.""" h = str(torrent.get('hash') or '') if not h: return {'hash': h, 'ok': False, 'error': 'missing hash'} - if bool(torrent.get('paused')) or str(torrent.get('status') or '').lower() == 'paused' or int(torrent.get('state') or 0): - # Note: Smart Queue candidates paused with d.pause must be resumed with d.resume, without d.start/d.stop. - return rtorrent.resume_paused_hash(client, h) - # Note: Only optional manage_stopped uses the start path for fully stopped torrents. + if _is_user_paused(torrent): + # Note: Smart Queue never unpauses user-paused torrents; it manages only stopped items. + return {'hash': h, 'ok': False, 'skipped': 'user_paused'} return rtorrent.start_or_resume_hash(client, h) @@ -342,11 +341,16 @@ def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]: result[key] = int(value or 0) if key in {'state', 'active', 'open', 'priority'} else str(value or '') except Exception as exc: result[f'{key}_error'] = str(exc) - # Note: Do not treat d.is_open or state=1 as resumed; Paused can also have those values. - # Smart Queue counts a start only after d.is_active=1, meaning the pause was actually removed. + # Note: Smart Queue starts only stopped torrents; a slot counts only after d.is_active=1. result['started'] = bool(int(result.get('active') or 0)) return result + +def _is_user_paused(torrent: dict[str, Any]) -> bool: + """Return True for torrents paused by the user; Smart Queue must not touch them.""" + status = str(torrent.get('status') or '').lower() + return bool(torrent.get('paused')) or status == 'paused' + def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str = '', attempts: int = 3) -> bool: labels = _label_names(current_label) if SMART_QUEUE_LABEL in labels: @@ -364,7 +368,7 @@ def _set_smart_queue_label(client: Any, torrent_hash: str, current_label: str = return False -def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool: +def _mark_auto_stopped(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool: torrent_hash = str(torrent.get('hash') or '') if not torrent_hash: return False @@ -379,15 +383,12 @@ def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = return False if _has_stalled_label(str(torrent.get('label') or '')): return False + if _is_user_paused(torrent): + # Note: Paused torrents are always treated as user-controlled and are not Smart Queue holds. + return False if _has_smart_queue_label(str(torrent.get('label') or '')): return True - # Note: Paused in rTorrent usually has state=1 and active=0, so state=0 must not be required. - # This lets Smart Queue treat paused torrents as pending and fill the queue target later. - if bool(torrent.get('paused')): - return True - # Note: Fully stopped items are managed only when Use stopped torrents is enabled. - if not manage_stopped: - return False + # Note: Smart Queue manages stopped torrents by default; the old manage_stopped flag is ignored for compatibility. return not int(torrent.get('state') or 0) @@ -432,31 +433,30 @@ def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, def _is_running_download_slot(t: dict[str, Any]) -> bool: """Return True for incomplete torrents that already occupy a Smart Queue slot.""" - # Note: The Smart Queue limit means the target number of actually active slots. - # Paused can have state=1/open=1, so a slot is counted only after d.is_active=1. + # Note: Target active downloads counts only actually active downloads; paused items are user-controlled. if int(t.get('complete') or 0): return False if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_label(str(t.get('label') or '')): return False status = str(t.get('status') or '').lower() - if status == 'checking' or status == 'paused' or bool(t.get('paused')): + if status == 'checking' or _is_user_paused(t): return False return bool(int(t.get('active') or 0)) def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> bool: - """Return True for paused/held torrents Smart Queue may resume later.""" + """Return True for stopped torrents Smart Queue may start later.""" if int(t.get('complete') or 0): return False if _has_stalled_label(str(t.get('label') or '')): return False + if _is_user_paused(t): + # Note: User-paused torrents are never candidates, even when they have no Smart Queue label. + return False if _has_smart_queue_label(str(t.get('label') or '')): return True - # Note: Paused items are the primary source for filling the queue, regardless of manage_stopped. - if bool(t.get('paused')) or str(t.get('status') or '').lower() == 'paused': - return True - # Note: Stopped items are added only when the user enabled Use stopped torrents. - return bool(manage_stopped) and not int(t.get('state') or 0) + # Note: Enabled Smart Queue manages all stopped torrents; no separate stopped-torrent switch is needed. + return not int(t.get('state') or 0) def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> dict[str, Any]: @@ -471,17 +471,17 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = try: # Note: When Smart Queue is disabled, only technical labels are cleaned up, without starting or pausing torrents. torrents = rtorrent.list_torrents(profile) - restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), bool(settings.get('manage_stopped'))) + restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), True) except Exception: restored = [] add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id) - return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} + return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} torrents = rtorrent.list_torrents(profile) # Note: Torrents marked as Stalled are treated as queue-blocked even when there are no other pending downloads. stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')} excluded = _excluded_hashes(profile_id, user_id) | stalled_label_hashes - manage_stopped = bool(settings.get('manage_stopped')) + manage_stopped = True def is_managed_hold(t: dict[str, Any]) -> bool: return _has_smart_queue_label(str(t.get('label') or '')) @@ -538,8 +538,8 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = stalled_hashes = {str(t.get('hash') or '') for t in stalled} # Enforce the hard active-download cap first. The previous logic only limited - # newly resumed torrents, so already-active downloads could stay above the limit. - pause_rank = sorted( + # newly started torrents, so already-active downloads could stay above the limit. + stop_rank = sorted( downloading, key=lambda t: ( 0 if str(t.get('hash') or '') in stalled_hashes else 1, @@ -548,84 +548,84 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = int(t.get('peers') or 0), ), ) - to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)] - pause_hashes = {str(t.get('hash') or '') for t in to_pause} + to_stop: list[dict[str, Any]] = stop_rank[:max(0, len(downloading) - max_active)] + stop_hashes = {str(t.get('hash') or '') for t in to_stop} # Note: Confirmed stalled downloads are removed from the active queue immediately, then new candidates can fill those slots. for t in stalled: h = str(t.get('hash') or '') - if h and h not in pause_hashes: - to_pause.append(t) - pause_hashes.add(h) + if h and h not in stop_hashes: + to_stop.append(t) + stop_hashes.add(h) - active_after_pause = max(0, len(downloading) - len(to_pause)) - available_slots = max(0, max_active - active_after_pause) - to_resume = candidates[:available_slots] + active_after_stop = max(0, len(downloading) - len(to_stop)) + available_slots = max(0, max_active - active_after_stop) + to_start = candidates[:available_slots] # Note: Items outside the current start batch are explicitly marked as pending Smart Queue items. to_label_waiting = candidates[available_slots:] c = rtorrent.client_for(profile) rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active) - paused: list[str] = [] - resumed: list[str] = [] + stopped_by_queue: list[str] = [] + started_by_queue: list[str] = [] label_failed: list[str] = [] stalled_labeled: list[str] = [] start_failed: list[dict[str, str]] = [] start_no_effect: list[dict[str, Any]] = [] - resume_requested: list[str] = [] + start_requested: list[str] = [] start_results: list[dict[str, Any]] = [] - for t in to_pause: + for t in to_stop: try: h = str(t.get('hash') or '') - pause_result = rtorrent.pause_hash(c, h) - if not pause_result.get('ok'): - raise RuntimeError(pause_result.get('error') or 'pause failed') + stop_result = rtorrent.stop_hash(c, h) + if not stop_result.get('ok'): + raise RuntimeError(stop_result.get('error') or 'stop failed') if h in stalled_hashes: if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))): stalled_labeled.append(h) else: label_failed.append(h) - elif not _mark_auto_paused(c, profile_id, t): + elif not _mark_auto_stopped(c, profile_id, t): label_failed.append(h) - paused.append(h) + stopped_by_queue.append(h) except Exception: pass for t in to_label_waiting: h = str(t.get('hash') or '') - if not h or h in pause_hashes: + if not h or h in stop_hashes: continue try: - if not _mark_auto_paused(c, profile_id, t): + if not _mark_auto_stopped(c, profile_id, t): label_failed.append(h) except Exception: label_failed.append(h) # Note: Start the whole candidate batch in one round. Remove the label after an accepted RPC, # because rTorrent may keep some items in its own queue with active=0 despite a valid d.start/d.resume. - for t in to_resume: + for t in to_start: h = str(t.get('hash') or '') if not h: continue try: result = _start_download(c, t) start_results.append(result) - resume_requested.append(h) + start_requested.append(h) except Exception as exc: start_failed.append({'hash': h, 'error': str(exc)}) - active_verified, start_no_effect = _verify_started_downloads(c, resume_requested) + active_verified, start_no_effect = _verify_started_downloads(c, start_requested) for h in active_verified: _restore_auto_label(c, profile_id, h, None) - # Note: History shows only torrents actually unpaused, not just the number of sent commands. - resumed = list(active_verified) + # Note: History shows only torrents actually started, not just the number of sent commands. + started_by_queue = list(active_verified) keep_labels = ( - set(paused) + set(stopped_by_queue) | {str(t.get('hash') or '') for t in to_label_waiting} - | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(resumed)} + | {str(t.get('hash') or '') for t in stopped if _has_smart_queue_label(str(t.get('label') or '')) and str(t.get('hash') or '') not in set(started_by_queue)} ) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped) - details = {'excluded': len(excluded), 'excluded_stalled': len(stalled_label_hashes), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap} - add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id) - return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} + details = {'excluded': len(excluded), 'excluded_stalled': len(stalled_label_hashes), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'start_requested': start_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': True, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_stop + len(started_by_queue), 'stopped_planned': len(to_stop), 'started_planned': len(to_start), 'paused_planned': len(to_stop), 'resumed_planned': len(to_start), 'rtorrent_cap': rtorrent_cap} + add_history(profile_id, 'force_check' if force else 'auto_check', stopped_by_queue, started_by_queue, len(torrents), {**details, 'stopped': stopped_by_queue, 'started': started_by_queue}, user_id) + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': stopped_by_queue, 'resumed': started_by_queue, 'stopped': stopped_by_queue, 'started': started_by_queue, 'start_requested': start_requested, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index a2a3ada..d89083e 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -73,8 +73,8 @@ def register_socketio_handlers(socketio): result = smart_queue.check(profile, force=False) if result.get("enabled"): _emit_profile(socketio, "smart_queue_update", result, pid) - if result.get("paused") or result.get("resumed") or result.get("resume_requested"): - # Note: After Smart Queue changes, refresh cache immediately so the Downloading list does not wait for the next poller cycle. + if result.get("stopped") or result.get("started") or result.get("start_requested") or result.get("paused") or result.get("resumed"): + # Note: Note: After Smart Queue STOP/START changes, refresh cache immediately so the Downloading list does not wait for the next poller cycle. queue_diff = torrent_cache.refresh(profile) if queue_diff.get("ok"): _emit_profile(socketio, "torrent_patch", {**queue_diff, "summary": cached_summary(pid, torrent_cache.snapshot(pid), force=True)}, pid) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 58acfd7..31c0e9a 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -415,9 +415,9 @@ $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value}); loadRatios(); }); async function loadRss(){ const j=await (await fetch('/api/rss')).json(); const feeds=j.feeds||[], rules=j.rules||[]; if($('rssManager')) $('rssManager').innerHTML=`
Feeds
${table(['Name','URL','Last error'],feeds.map(f=>[esc(f.name),esc(f.url),esc(f.last_error||'')]))}
Rules
${table(['Name','Pattern','Path','Label'],rules.map(r=>[esc(r.name),esc(r.pattern),esc(r.save_path),esc(r.label)]))}`; } - async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartManageStopped')) $('smartManageStopped').checked=!!st.manage_stopped; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``])):'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Paused','Resumed'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'
No Smart Queue operations yet.
'; const canToggle=totalHistory>10; const toggle=canToggle?``:''; $('smartHistory').innerHTML=`${body}${toggle}`; } } + async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``])):'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Stopped','Started'],hist.map(h=>[dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(h.paused_count||0),esc(h.resumed_count||0)])):'
No Smart Queue operations yet.
'; const canToggle=totalHistory>10; const toggle=canToggle?``:''; $('smartHistory').innerHTML=`${body}${toggle}`; } } async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } - async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,manage_stopped:$('smartManageStopped')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); } + async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); } async function loadAuthUsers(){ if(!window.PYTORRENT.authEnabled || !$('authUsersManager')) return; @@ -885,7 +885,7 @@ }catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } - $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',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(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('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||{}; if(j.torrent_patch) patchRows(j.torrent_patch); const noEffect=r.start_no_effect?.length||0; const requested=r.resume_requested?.length||0; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${r.paused?.length||0}, resumed ${r.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'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('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue and automation 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');}); + $('toolsModal')?.addEventListener('show.bs.modal',()=>{refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadAppStatus();loadPreferences();loadAuthUsers();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',users:'toolUsers',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(); if(tool==='users') loadAuthUsers();}; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); $('torrentStatsRefreshBtn')?.addEventListener('click',()=>loadTorrentStats(true)); $('authUserSaveBtn')?.addEventListener('click',saveAuthUser); $('authUserCancelBtn')?.addEventListener('click',resetAuthUserForm); $('authUsersManager')?.addEventListener('click',async e=>{ const edit=e.target.closest('.auth-edit'); const del=e.target.closest('.auth-delete'); if(edit){ editAuthUser(JSON.parse(edit.dataset.user||'{}')); return; } if(del && confirm('Delete user?')){ await fetch(`/api/auth/users/${del.dataset.id}`,{method:'DELETE'}); loadAuthUsers(); } }); $('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||{}; if(j.torrent_patch) patchRows(j.torrent_patch); const noEffect=r.start_no_effect?.length||0; const requested=r.start_requested?.length||0; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'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('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue and automation 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); $('automationAddConditionBtn')?.addEventListener('click',()=>{automationConditions.push(automationCondition()); renderAutomationBuilder();}); $('automationAddEffectBtn')?.addEventListener('click',()=>{automationEffects.push(automationEffect()); renderAutomationBuilder();}); $('automationConditionList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-condition'); if(!b)return; automationConditions.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationEffectList')?.addEventListener('click',e=>{const b=e.target.closest('.automation-remove-effect'); if(!b)return; automationEffects.splice(Number(b.dataset.index||0),1); renderAutomationBuilder();}); $('automationCancelEditBtn')?.addEventListener('click',resetAutomationForm); $('automationSaveBtn')?.addEventListener('click',saveAutomation); $('automationExportBtn')?.addEventListener('click',exportAutomations); $('automationImportBtn')?.addEventListener('click',()=>$('automationImportFile')?.click()); $('automationImportFile')?.addEventListener('change',e=>importAutomations(e.target.files?.[0])); $('automationCheckBtn')?.addEventListener('click',async()=>{setBusy(true);try{const j=await post('/api/automations/check',{}); const torrents=j.result?.applied?.length||0; const batches=j.result?.batches?.length||0; toast(`Automations applied ${torrents} torrent(s) in ${batches} batch(es)`,'success'); await loadAutomations();}catch(e){toast(e.message,'danger');}finally{setBusy(false);}}); $('automationManager')?.addEventListener('click',async e=>{const toggle=e.target.closest('.automation-toggle'); if(toggle){ await toggleAutomationRule(automationRulesCache.find(r=>String(r.id)===String(toggle.dataset.id))); return; } const edit=e.target.closest('.automation-edit'); if(edit){ editAutomationRule(automationRulesCache.find(r=>String(r.id)===String(edit.dataset.id))); return; } 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();}); $('automationHistory')?.addEventListener('click',e=>{ if(e.target.closest('#automationClearHistoryBtn')) clearAutomationHistory(); }); 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);}); @@ -1057,6 +1057,6 @@ ${disk.error}`:''}`; b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary"); loadTrafficHistory(b.dataset.range||"7d"); })); - socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ const noEffect=msg.start_no_effect?.length||0; const requested=msg.resume_requested?.length||0; const cap=msg.rtorrent_cap?.updated?`, cap ${msg.rtorrent_cap.current}->${msg.rtorrent_cap.new}`:''; const waiting=msg.waiting_labeled||0; const stalled=msg.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: paused ${msg.paused?.length||0}, resumed ${msg.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();}); + socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ const noEffect=msg.start_no_effect?.length||0; const requested=msg.start_requested?.length||0; const cap=msg.rtorrent_cap?.updated?`, cap ${msg.rtorrent_cap.current}->${msg.rtorrent_cap.new}`:''; const waiting=msg.waiting_labeled||0; const stalled=msg.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; toast(`Smart Queue: stopped ${msg.stopped?.length||msg.paused?.length||0}, started ${msg.started?.length||msg.resumed?.length||0}${tail}${waitTail}${stalledTail}${cap}`,'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();}); updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); })(); diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index a0361d3..4afc7d3 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -148,7 +148,7 @@ - +