diff --git a/.gitignore b/.gitignore index b628e35..af84d72 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ data/logs/* todo.txt !pytorrent/static/libs/pytorrent-themes/ !pytorrent/static/libs/pytorrent-themes/** +smart_queue_scoring_todo.md diff --git a/pytorrent/db.py b/pytorrent/db.py index 91148d0..5140322 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -289,6 +289,10 @@ CREATE TABLE IF NOT EXISTS smart_queue_settings ( refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, + surge_refill_enabled INTEGER DEFAULT 0, + surge_refill_interval_minutes INTEGER DEFAULT 1440, + surge_refill_batch_size INTEGER DEFAULT 2000, + last_surge_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, @@ -573,6 +577,10 @@ MIGRATIONS = [ "ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT", + "ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_enabled INTEGER DEFAULT 0", + "ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_interval_minutes INTEGER DEFAULT 1440", + "ALTER TABLE smart_queue_settings ADD COLUMN surge_refill_batch_size INTEGER DEFAULT 2000", + "ALTER TABLE smart_queue_settings ADD COLUMN last_surge_refill_at TEXT", "ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50", "ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900", "ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1", @@ -665,8 +673,8 @@ PROFILE_ONLY_TABLES = { "indexes": ["CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_status ON rss_history(profile_id, status)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')"], }, "smart_queue_settings": { - "columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, prefer_partial_progress INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)", - "copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "prefer_partial_progress", "auto_stop_idle", "updated_at"], + "columns": "profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, surge_refill_enabled INTEGER DEFAULT 0, surge_refill_interval_minutes INTEGER DEFAULT 1440, surge_refill_batch_size INTEGER DEFAULT 2000, last_surge_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, prefer_partial_progress INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id)", + "copy": ["profile_id", "enabled", "max_active_downloads", "stalled_seconds", "min_speed_bytes", "min_seeds", "min_peers", "ignore_seed_peer", "ignore_speed", "manage_stopped", "cooldown_minutes", "last_run_at", "refill_enabled", "refill_interval_minutes", "last_refill_at", "surge_refill_enabled", "surge_refill_interval_minutes", "surge_refill_batch_size", "last_surge_refill_at", "stop_batch_size", "start_grace_seconds", "protect_active_below_cap", "prefer_partial_progress", "auto_stop_idle", "updated_at"], "indexes": [], }, "smart_queue_exclusions": { diff --git a/pytorrent/routes/smart_queue.py b/pytorrent/routes/smart_queue.py index bfad5fa..63529cb 100644 --- a/pytorrent/routes/smart_queue.py +++ b/pytorrent/routes/smart_queue.py @@ -14,7 +14,7 @@ def smart_queue_get(): exclusions = smart_queue.list_exclusions(profile['id']) history = smart_queue.list_history(profile['id'], limit=history_limit) history_total = smart_queue.count_history(profile['id']) - return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)}) + return ok({'settings': settings, 'exclusions': exclusions, 'history': history, 'history_total': history_total, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings), 'surge_refill_remaining_seconds': smart_queue.surge_refill_remaining(settings)}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc), 'settings': {}, 'exclusions': []}) @@ -29,7 +29,7 @@ def smart_queue_save(): try: payload = request.get_json(silent=True) or {} settings = smart_queue.save_settings(profile['id'], payload) - return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings)}) + return ok({'settings': settings, 'cooldown_remaining_seconds': smart_queue.cooldown_remaining(settings), 'refill_remaining_seconds': smart_queue.refill_remaining(settings), 'surge_refill_remaining_seconds': smart_queue.surge_refill_remaining(settings)}) except Exception as exc: return jsonify({'ok': False, 'error': str(exc)}) diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 55fdaa2..f2f8190 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -154,6 +154,10 @@ def _default_settings(profile_id: int) -> dict[str, Any]: 'refill_enabled': 1, 'refill_interval_minutes': 0, 'last_refill_at': None, + 'surge_refill_enabled': 0, + 'surge_refill_interval_minutes': 1440, + 'surge_refill_batch_size': 2000, + 'last_surge_refill_at': None, 'stop_batch_size': 50, 'start_grace_seconds': 900, 'protect_active_below_cap': 1, @@ -213,11 +217,15 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N # Note: Refill can be disabled, use the existing poller cadence, or run on a user-defined minute interval. settings['refill_enabled'] = 0 if refill_mode == 'off' else 1 settings['refill_interval_minutes'] = _int_setting(data, current, 'refill_interval_minutes', 5, 1) if refill_mode == 'custom' else 0 + # Note: Surge refill is a separate periodic over-cap starter; it never changes the normal target limit. + settings['surge_refill_enabled'] = 1 if data.get('surge_refill_enabled', current.get('surge_refill_enabled', 0)) else 0 + settings['surge_refill_interval_minutes'] = _int_setting(data, current, 'surge_refill_interval_minutes', 1440, 1) + settings['surge_refill_batch_size'] = _int_setting(data, current, 'surge_refill_batch_size', 2000, 1) now = utcnow() with connect() as conn: conn.execute( - '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,prefer_partial_progress,auto_stop_idle,refill_enabled,refill_interval_minutes,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + '''INSERT INTO smart_queue_settings(profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,min_peers,ignore_seed_peer,ignore_speed,manage_stopped,cooldown_minutes,stop_batch_size,start_grace_seconds,protect_active_below_cap,prefer_partial_progress,auto_stop_idle,refill_enabled,refill_interval_minutes,surge_refill_enabled,surge_refill_interval_minutes,surge_refill_batch_size,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, @@ -236,8 +244,11 @@ def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = N auto_stop_idle=excluded.auto_stop_idle, refill_enabled=excluded.refill_enabled, refill_interval_minutes=excluded.refill_interval_minutes, + surge_refill_enabled=excluded.surge_refill_enabled, + surge_refill_interval_minutes=excluded.surge_refill_interval_minutes, + surge_refill_batch_size=excluded.surge_refill_batch_size, updated_at=excluded.updated_at''', - (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['prefer_partial_progress'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], now), + (profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], settings['min_peers'], settings['ignore_seed_peer'], settings['ignore_speed'], settings['manage_stopped'], settings['cooldown_minutes'], settings['stop_batch_size'], settings['start_grace_seconds'], settings['protect_active_below_cap'], settings['prefer_partial_progress'], settings['auto_stop_idle'], settings['refill_enabled'], settings['refill_interval_minutes'], settings['surge_refill_enabled'], settings['surge_refill_interval_minutes'], settings['surge_refill_batch_size'], now), ) return get_settings(profile_id, user_id) @@ -1152,6 +1163,234 @@ def _refill_underfilled_queue(profile: dict, settings: dict[str, Any], profile_i 'start_source_skipped': len(source_skipped), 'checked': len(torrents), 'excluded': len(user_excluded), + 'rtorrent_cap': rtorrent_cap, + 'settings': settings, + } + + +def surge_refill_remaining(settings: dict[str, Any]) -> int: + """Return seconds until the next over-cap Surge refill may run.""" + # Note: Surge refill has its own timer because it intentionally starts more torrents than the normal cap. + if not int(settings.get('surge_refill_enabled') or 0): + return 0 + minutes = int(settings.get('surge_refill_interval_minutes') or 0) + if minutes <= 0: + return 0 + last = _ts(settings.get('last_surge_refill_at')) + if not last: + return 0 + return max(0, int((last + minutes * 60) - time.time())) + + +def _mark_surge_refill_run(profile_id: int, user_id: int) -> None: + # Note: The over-cap refill timer is updated even when no candidates are found, preventing tight retry loops. + with connect() as conn: + conn.execute('UPDATE smart_queue_settings SET last_surge_refill_at=?, updated_at=? WHERE profile_id=?', (utcnow(), utcnow(), profile_id)) + + +def _surge_refill_over_limit(profile: dict, settings: dict[str, Any], profile_id: int, user_id: int) -> dict[str, Any]: + """Start a large user-defined batch above the Smart Queue cap, then let normal checks drain it.""" + # Note: Surge refill never raises max_active_downloads; it only overfills once per configured interval. + torrents = rtorrent.list_torrents(profile) + user_excluded = _excluded_hashes(profile_id, user_id) + max_active = max(1, int(settings.get('max_active_downloads') or 5)) + batch_size = max(1, int(settings.get('surge_refill_batch_size') or 2000)) + 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')} + downloading = [ + t for t in torrents + if _is_running_download_slot(t) + and str(t.get('hash') or '') not in user_excluded + ] + stopped = [ + t for t in torrents + if str(t.get('hash') or '') not in user_excluded + and str(t.get('hash') or '') not in stalled_label_hashes + and _is_waiting_download_candidate(t, True) + and not _is_running_download_slot(t) + ] + if int(settings.get('auto_stop_idle') or 0) and not downloading and not stopped: + idle_details = { + 'decision': 'Smart Queue auto-stopped during Surge refill: no active or waiting downloads', + 'enabled': False, + 'auto_stop_idle': True, + 'surge_refill': True, + 'checked': len(torrents), + 'active_before': 0, + 'active_after_stop': 0, + 'active_after_expected': 0, + 'max_active_downloads': max_active, + 'surge_refill_batch_size': batch_size, + 'over_limit': 0, + 'stopped': [], + 'started': [], + 'start_requested': [], + 'stalled_detected': 0, + 'stalled_stopped': 0, + 'protected_stalled': 0, + 'excluded': len(user_excluded), + 'excluded_stalled': len(stalled_label_hashes), + } + _mark_surge_refill_run(profile_id, user_id) + _diagnostics_write('smart_queue.surge_refill_idle', {'profile_id': profile_id, 'checked': len(torrents)}, idle_details) + return _disable_when_idle(profile_id, user_id, torrents, idle_details) + + startable_stopped, source_skipped = _split_start_candidates(stopped) + prefer_partial_progress = bool(int(settings.get('prefer_partial_progress', 1) or 0)) + candidates = sorted( + startable_stopped, + key=lambda t: _start_candidate_sort_key(t, prefer_partial_progress), + reverse=True, + ) + c = rtorrent.client_for(profile) + rtorrent_cap = _ensure_rtorrent_download_cap(c, max(max_active, len(downloading) + batch_size)) + label_failed: list[str] = [] + to_start = candidates[:batch_size] + to_label_waiting = candidates[batch_size:] + + for t in to_label_waiting: + h = str(t.get('hash') or '') + if not h: + continue + try: + if not _mark_auto_stopped(c, profile_id, t): + label_failed.append(h) + except Exception: + label_failed.append(h) + + start_summary = _start_and_verify_downloads(c, profile_id, to_start) + active_verified = start_summary['active_verified'] + start_pending_confirmation = start_summary.get('start_pending_confirmation', []) + start_failed = start_summary['start_failed'] + start_requested = start_summary['start_requested'] + start_results = start_summary['start_results'] + _record_start_grace(profile_id, start_requested) + for h in start_requested: + _restore_auto_label(c, profile_id, h, None) + try: + rtorrent.clear_post_check_download_label(c, h, None) + except Exception: + label_failed.append(h) + + keep_labels = ( + {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(start_requested)} + ) + restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, True) + active_transferring = sum(1 for t in downloading if int(t.get('down_rate') or 0) > 0 or int(t.get('up_rate') or 0) > 0) + active_rtorrent = sum(1 for t in downloading if int(t.get('active') or 0)) + active_state = sum(1 for t in downloading if int(t.get('state') or 0)) + active_after_expected = len(downloading) + len(start_requested) + over_limit_expected = max(0, active_after_expected - max_active) + if start_requested: + decision = f'Surge refill requested {len(start_requested)} over-cap start(s); normal checks will drain overflow' + blocked_reason = '' + elif not candidates: + decision = 'Surge refill skipped: no stopped candidates available' + blocked_reason = 'no_candidates' + else: + decision = 'Surge refill ran but rTorrent did not confirm new starts yet' + blocked_reason = 'start_not_confirmed' + details = { + 'decision': decision, + 'blocked_reason': blocked_reason, + 'enabled': bool(settings.get('enabled')), + 'surge_refill': True, + 'surge_refill_interval_minutes': int(settings.get('surge_refill_interval_minutes') or 0), + 'surge_refill_batch_size': batch_size, + 'active_before': len(downloading), + 'active_after_expected': active_after_expected, + 'active_transferring_count': active_transferring, + 'active_rtorrent_count': active_rtorrent, + 'active_state_count': active_state, + 'max_active_downloads': max_active, + 'over_limit': over_limit_expected, + 'candidates': len(candidates), + 'started_planned': len(to_start), + 'waiting_labeled': len(to_label_waiting), + 'start_requested': start_requested, + 'start_results': start_results, + 'active_verified_count': len(active_verified), + 'pending_confirmation_count': len(start_pending_confirmation), + 'start_pending_confirmation': start_pending_confirmation, + 'start_failed': start_failed, + 'labels_failed': label_failed, + 'labels_restored': restored, + 'start_source_skipped': len(source_skipped), + 'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')), + 'rtorrent_cap': rtorrent_cap, + 'excluded': len(user_excluded), + 'excluded_stalled': len(stalled_label_hashes), + } + _diagnostics_write( + 'smart_queue.surge_refill', + { + 'profile_id': profile_id, + 'checked': len(torrents), + 'active_before': len(downloading), + 'active_after_expected': active_after_expected, + 'max_active_downloads': max_active, + 'over_limit': over_limit_expected, + 'batch_size': batch_size, + 'candidates': len(candidates), + 'requested': len(start_requested), + 'verified': len(active_verified), + 'pending': len(start_pending_confirmation), + 'start_failed': len(start_failed), + 'waiting_labeled': len(to_label_waiting), + 'blocked_reason': blocked_reason, + 'rtorrent_cap_updated': bool(rtorrent_cap.get('updated')), + }, + { + 'rtorrent_cap': rtorrent_cap, + 'settings': { + 'surge_refill_interval_minutes': int(settings.get('surge_refill_interval_minutes') or 0), + 'surge_refill_batch_size': batch_size, + 'prefer_partial_progress': prefer_partial_progress, + }, + 'to_start': _diagnostics_torrents(to_start), + 'to_label_waiting': _diagnostics_torrents(to_label_waiting), + 'source_skipped': _diagnostics_torrents(source_skipped), + 'pending_confirmation': _diagnostics_sample(start_pending_confirmation), + 'start_failed': _diagnostics_sample(start_failed), + 'labels_failed': _diagnostics_sample(label_failed), + }, + ) + _mark_surge_refill_run(profile_id, user_id) + add_history(profile_id, 'surge_refill', [], start_requested, len(torrents), details, user_id) + settings = get_settings(profile_id, user_id) + return { + 'ok': True, + 'enabled': bool(settings.get('enabled')), + 'surge_refill': True, + 'cooldown_skipped': True, + 'refill_mode': _refill_mode(settings), + 'refill_remaining_seconds': refill_remaining(settings), + 'surge_refill_remaining_seconds': surge_refill_remaining(settings), + 'paused': [], + 'resumed': start_requested, + 'stopped': [], + 'started': start_requested, + 'start_requested': start_requested, + 'start_batch_size': start_summary['start_batch_size'], + 'start_verify_attempts': start_summary['start_verify_attempts'], + 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], + 'waiting_labeled': len(to_label_waiting), + 'labels_restored': restored, + 'labels_failed': label_failed, + 'start_failed': start_failed, + 'start_no_effect': start_summary['start_no_effect'], + 'start_pending_confirmation': start_pending_confirmation, + 'active_verified': active_verified, + 'active_before': len(downloading), + 'active_after_expected': active_after_expected, + 'over_limit': over_limit_expected, + 'active_transferring_count': active_transferring, + 'active_rtorrent_count': active_rtorrent, + 'active_state_count': active_state, + 'blocked_reason': blocked_reason, + 'start_source_skipped': len(source_skipped), + 'checked': len(torrents), + 'excluded': len(user_excluded), 'settings': settings, } @@ -1177,13 +1416,18 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = profile_id = int(profile['id']) settings = get_settings(profile_id, user_id) remaining = cooldown_remaining(settings) + if not force and int(settings.get('enabled') or 0) and int(settings.get('surge_refill_enabled') or 0) and not surge_refill_remaining(settings): + try: + return _surge_refill_over_limit(profile, settings, profile_id, user_id) + except Exception as exc: + return {'ok': True, 'enabled': True, 'surge_refill': False, 'settings': settings, 'error': str(exc)} if remaining and not force: if int(settings.get('enabled') or 0): refill_wait = refill_remaining(settings) if not int(settings.get('refill_enabled') or 0): - return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'settings': settings} + return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_disabled': True, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings} if refill_wait: - return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'settings': settings} + return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'refill_wait_seconds': refill_wait, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings} try: # Note: Cooldown still blocks the full Smart Queue pass, but configured refill may fill free slots safely. refill = _refill_underfilled_queue(profile, settings, profile_id, user_id) @@ -1191,7 +1435,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = return refill except Exception as exc: return {'ok': True, 'enabled': True, 'cooldown_skipped': True, 'cooldown_refill': False, 'cooldown_remaining_seconds': remaining, 'settings': settings, 'error': str(exc)} - return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'settings': settings} + return {'ok': True, 'enabled': bool(settings.get('enabled')), 'cooldown_skipped': True, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings), 'settings': settings} if not force and not int(settings.get('enabled') or 0): restored: list[str] = [] try: @@ -1534,4 +1778,4 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = mark_run(profile_id, user_id) settings = get_settings(profile_id, user_id) remaining = cooldown_remaining(settings) - 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, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'prefer_partial_progress': prefer_partial_progress, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining} + 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, 'start_batch_size': start_summary['start_batch_size'], 'start_verify_attempts': start_summary['start_verify_attempts'], 'start_verify_delay_seconds': start_summary['start_verify_delay_seconds'], 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_pending_confirmation': start_pending_confirmation, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'start_source_skipped': len(source_skipped), 'ignore_seed_peer': ignore_seed_peer, 'ignore_speed': ignore_speed, 'ignored_seed_peer_count': ignored_seed_peer_count if ignore_seed_peer else 0, 'ignored_speed_count': ignored_speed_count if ignore_speed else 0, 'stalled_seconds': stalled_seconds, 'stalled_timer_key': timer_key, 'stop_batch_size': stop_batch_size, 'start_grace_seconds': start_grace_seconds, 'protect_active_below_cap': protect_active_below_cap, 'prefer_partial_progress': prefer_partial_progress, 'auto_stop_idle': bool(int(settings.get('auto_stop_idle') or 0)), 'stalled_replacement_allowed': stalled_replacement_allowed, 'start_grace_protected': len(start_grace_hashes), 'replacement_capacity': replacement_capacity, 'protected_stalled': protected_stalled, 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings, 'cooldown_remaining_seconds': remaining, 'surge_refill_remaining_seconds': surge_refill_remaining(settings)} diff --git a/pytorrent/static/js/smartQueue.js b/pytorrent/static/js/smartQueue.js index a19ec6b..49cbb01 100644 --- a/pytorrent/static/js/smartQueue.js +++ b/pytorrent/static/js/smartQueue.js @@ -1 +1 @@ -export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; 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 ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' \u00b7 ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function updateSmartQueueExclusionCounter(){\n // Note: The counter is UI-only and does not change how exceptions are saved.\n const counter=$('smartExclusionCounter');\n if(!counter) return;\n const selected=smartQueueExcludedSet().size;\n const visible=[...document.querySelectorAll('.smart-exclusion-choice-row')].filter(row=>!row.classList.contains('d-none')).length;\n counter.textContent=`${selected} selected \u00b7 ${visible} visible`;\n }\n function smartQueueVisibleExceptionRows(){\n return [...document.querySelectorAll('.smart-exclusion-choice-row')].filter(row=>!row.classList.contains('d-none'));\n }\n function setSmartQueueVisibleExceptions(checked){\n // Note: Bulk actions affect only visible filtered rows, preserving hidden selections.\n smartQueueVisibleExceptionRows().forEach(row=>{\n const input=row.querySelector('.smart-exclusion-choice');\n if(input) input.checked=checked;\n });\n updateSmartQueueExclusionCounter();\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n list.querySelectorAll('.smart-exclusion-choice').forEach(input=>input.addEventListener('change', updateSmartQueueExclusionCounter));\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n const onlySelected=!!$('smartExclusionOnlySelected')?.checked;\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n const matchesQuery=!query || row.textContent.toLowerCase().includes(query);\n const matchesSelected=!onlySelected || !!row.querySelector('.smart-exclusion-choice')?.checked;\n row.classList.toggle('d-none', !(matchesQuery && matchesSelected));\n });\n updateSmartQueueExclusionCounter();\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n if($('smartExclusionOnlySelected')) $('smartExclusionOnlySelected').checked=false;\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartPreferPartialProgress')) $('smartPreferPartialProgress').checked=st.prefer_partial_progress!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Slot details','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n const slotNote=d.cooldown_refill ? `state ${d.active_state_count ?? '-'} / rT active ${d.active_rtorrent_count ?? '-'} / transferring ${d.active_transferring_count ?? '-'}` : '-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(slotNote),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n if(interval) interval.disabled=mode!=='custom';\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','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);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,prefer_partial_progress:$('smartPreferPartialProgress')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n"; +export const smartQueueSource = " function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }\n function smartQueueToastMessage(r){ const pending=r.start_pending_confirmation?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const startFailed=r.start_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; 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 ignoredSpeed=(r.ignore_speed||r.settings?.ignore_speed)?Number(r.ignored_speed_count||0):0; const tail=pending?`, pending confirm ${pending}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const ignoredSpeedTail=(r.ignore_speed||r.settings?.ignore_speed)?`, ignored speed ${ignoredSpeed}`:''; const failTail=`${stopFailed?`, stop failed ${stopFailed}`:''}${startFailed?`, start failed ${startFailed}`:''}`; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${ignoredSpeedTail}${failTail}${cap}`; }\n function buildSmartQueueNerdStats(hist=[], totalHistory=0){\n // Note: Small Smart Queue telemetry for automation nerds; it reads history only and does not affect queue behavior.\n const stats=hist.reduce((acc,h)=>{\n const details=smartHistoryDetails(h);\n const stopped=Number(h.paused_count||0);\n const started=Number(h.resumed_count||0);\n const checked=Number(h.checked_count||0);\n const over=Number(details.over_limit||0);\n const stopFailed=Array.isArray(details.stop_failed)?details.stop_failed.length:0;\n acc.checked += checked;\n acc.stopped += stopped;\n acc.started += started;\n acc.overLimit += over;\n acc.stopFailed += stopFailed;\n if(over>0) acc.overEvents += 1;\n return acc;\n },{checked:0,stopped:0,started:0,overLimit:0,overEvents:0,stopFailed:0});\n const latest=hist[0]||null;\n return {...stats,total:Number(totalHistory||hist.length||0),sample:hist.length,latestEvent:smartHistoryDetails(latest||{}).decision||latest?.event||'-',latestAt:latest?.created_at||''};\n }\n\n function renderSmartQueueNerdStats(stats){\n // Note: Compact cards keep the extra diagnostics readable above Automation history without changing the history table.\n if(!stats) return '
No Smart Queue stats yet.
';\n const cards=[\n ['Runs',stats.total,`${stats.sample} loaded`],\n ['Checked',stats.checked,'torrent scans'],\n ['Stopped',stats.stopped,'queue trims'],\n ['Started',stats.started,'queue fills'],\n ['Over limit',stats.overEvents,`${stats.overLimit} total over`],\n ['Stop failed',stats.stopFailed,'rTorrent rejects'],\n ['Latest',stats.latestEvent,stats.latestAt?dateCell(stats.latestAt):'no timestamp'],\n ];\n return `
${cards.map(([label,value,hint])=>`
${esc(label)}${esc(value)}${hint}
`).join('')}
`;\n }\n function formatDurationLeft(seconds){ seconds=Math.max(0,Math.floor(Number(seconds||0))); if(!seconds) return \"ready\"; const m=Math.floor(seconds/60), s=seconds%60; return m?`${m}m ${String(s).padStart(2,\"0\")}s`:`${s}s`; }\n function updateCooldownBadge(id, seconds){\n const el=$(id); if(!el) return;\n const value=Math.max(0,Math.floor(Number(seconds||0)));\n el.dataset.seconds=String(value);\n el.textContent=`next: ${formatDurationLeft(value)}`;\n }\n function tickCooldowns(){\n document.querySelectorAll(\".cooldown-live\").forEach(el=>{\n let v=Math.max(0,Number(el.dataset.seconds||0));\n if(v>0){ v-=1; el.dataset.seconds=String(v); }\n el.textContent=`next: ${formatDurationLeft(v)}`;\n });\n }\n setInterval(tickCooldowns,1000);\n\n function smartQueueTorrentLabel(t){\n const bits=[t.name || t.hash, t.label ? `label: ${t.label}` : '', t.status || '', t.size_h || ''].filter(Boolean);\n return bits.join(' \u00b7 ');\n }\n function smartQueueExcludedSet(){\n return new Set([...document.querySelectorAll('.smart-exclusion-choice:checked')].map(input=>input.value).filter(Boolean));\n }\n function updateSmartQueueExclusionCounter(){\n // Note: The counter is UI-only and does not change how exceptions are saved.\n const counter=$('smartExclusionCounter');\n if(!counter) return;\n const selected=smartQueueExcludedSet().size;\n const visible=[...document.querySelectorAll('.smart-exclusion-choice-row')].filter(row=>!row.classList.contains('d-none')).length;\n counter.textContent=`${selected} selected \u00b7 ${visible} visible`;\n }\n function smartQueueVisibleExceptionRows(){\n return [...document.querySelectorAll('.smart-exclusion-choice-row')].filter(row=>!row.classList.contains('d-none'));\n }\n function setSmartQueueVisibleExceptions(checked){\n // Note: Bulk actions affect only visible filtered rows, preserving hidden selections.\n smartQueueVisibleExceptionRows().forEach(row=>{\n const input=row.querySelector('.smart-exclusion-choice');\n if(input) input.checked=checked;\n });\n updateSmartQueueExclusionCounter();\n }\n function renderSmartQueueExclusionChoices(exclusions=[]){\n const list=$('smartExclusionChoiceList');\n if(!list) return;\n const excluded=new Set((exclusions||[]).map(x=>String(x.torrent_hash||'')));\n selectedHashes().forEach(hash=>excluded.add(String(hash)));\n const rows=[...torrents.values()].sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')));\n const fallback=(exclusions||[])\n .filter(x=>x.torrent_hash && !torrents.has(x.torrent_hash))\n .map(x=>({hash:x.torrent_hash,name:`Missing from current list: ${x.torrent_hash}`,label:x.reason||'manual exception'}));\n const all=[...rows, ...fallback];\n list.innerHTML=all.length ? all.map(t=>{\n const hash=String(t.hash||'');\n const checked=excluded.has(hash) ? 'checked' : '';\n return ``;\n }).join('') : '
No torrents are loaded for this profile.
';\n list.querySelectorAll('.smart-exclusion-choice').forEach(input=>input.addEventListener('change', updateSmartQueueExclusionCounter));\n filterSmartQueueExclusionChoices();\n }\n function filterSmartQueueExclusionChoices(){\n const query=($('smartExclusionSearch')?.value||'').trim().toLowerCase();\n const onlySelected=!!$('smartExclusionOnlySelected')?.checked;\n document.querySelectorAll('.smart-exclusion-choice-row').forEach(row=>{\n const matchesQuery=!query || row.textContent.toLowerCase().includes(query);\n const matchesSelected=!onlySelected || !!row.querySelector('.smart-exclusion-choice')?.checked;\n row.classList.toggle('d-none', !(matchesQuery && matchesSelected));\n });\n updateSmartQueueExclusionCounter();\n }\n async function openSmartQueueExclusionModal(){\n await loadSmartQueue();\n const modalEl=$('smartExclusionModal');\n if(!modalEl) return;\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n if($('smartExclusionOnlySelected')) $('smartExclusionOnlySelected').checked=false;\n renderSmartQueueExclusionChoices(current.exclusions||[]);\n $('smartExclusionSearch')?.focus();\n bootstrap.Modal.getOrCreateInstance(modalEl).show();\n }\n async function saveSmartQueueExclusionChoices(){\n const current=await fetch('/api/smart-queue?history_limit=1').then(r=>r.json()).catch(()=>({exclusions:[]}));\n const before=new Set((current.exclusions||[]).map(x=>String(x.torrent_hash||'')));\n const after=smartQueueExcludedSet();\n const add=[...after].filter(hash=>!before.has(hash));\n const remove=[...before].filter(hash=>!after.has(hash));\n if(!add.length && !remove.length){\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n return toast('Smart Queue exceptions unchanged','secondary');\n }\n setBusy(true);\n try{\n for(const hash of add) await post('/api/smart-queue/exclusion',{hash,excluded:true,reason:'manual'});\n for(const hash of remove) await post('/api/smart-queue/exclusion',{hash,excluded:false,reason:'manual'});\n bootstrap.Modal.getInstance($('smartExclusionModal'))?.hide();\n toast('Smart Queue exceptions saved','success');\n await loadSmartQueue();\n }catch(e){\n toast(e.message,'danger');\n }finally{\n setBusy(false);\n }\n }\n async function loadSmartQueue(){\n if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');\n if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');\n const historyLimit=smartHistoryExpanded?100:10;\n const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();\n if(!j.ok) return;\n const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];\n const totalHistory=Number(j.history_total ?? hist.length);\n if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;\n if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;\n if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;\n if($('smartStopBatch')) $('smartStopBatch').value=st.stop_batch_size||50;\n if($('smartStartGrace')) $('smartStartGrace').value=st.start_grace_seconds||900;\n if($('smartProtectActiveBelowCap')) $('smartProtectActiveBelowCap').checked=st.protect_active_below_cap!==0;\n if($('smartPreferPartialProgress')) $('smartPreferPartialProgress').checked=st.prefer_partial_progress!==0;\n if($('smartAutoStopIdle')) $('smartAutoStopIdle').checked=!!st.auto_stop_idle;\n if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);\n if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;\n if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;\n if($('smartIgnoreSeedPeer')) $('smartIgnoreSeedPeer').checked=!!st.ignore_seed_peer;\n if($('smartIgnoreSpeed')) $('smartIgnoreSpeed').checked=!!st.ignore_speed;\n if($('smartCooldown')) $('smartCooldown').value=st.cooldown_minutes||10;\n const refillMode=!Number(st.refill_enabled ?? 1) ? 'off' : (Number(st.refill_interval_minutes||0)>0 ? 'custom' : 'auto');\n if($('smartRefillMode')) $('smartRefillMode').value=refillMode;\n if($('smartRefillInterval')) $('smartRefillInterval').value=Number(st.refill_interval_minutes||0)>0 ? st.refill_interval_minutes : 5;\n if($('smartSurgeRefillEnabled')) $('smartSurgeRefillEnabled').checked=!!Number(st.surge_refill_enabled||0);\n if($('smartSurgeRefillInterval')) $('smartSurgeRefillInterval').value=st.surge_refill_interval_minutes||1440;\n if($('smartSurgeRefillBatch')) $('smartSurgeRefillBatch').value=st.surge_refill_batch_size||2000;\n updateSmartRefillControls();\n updateCooldownBadge('smartCooldownBadge', Number(j.cooldown_remaining_seconds||0));\n if($('smartCooldownHint')) $('smartCooldownHint').textContent=st.enabled ? `Automatic run every ${st.cooldown_minutes||10} minute(s). Manual check ignores cooldown.` : 'Smart Queue is disabled; timer starts after it is enabled and runs once.';\n if($('smartRefillHint')) $('smartRefillHint').textContent=smartRefillHintText(refillMode, Number(st.refill_interval_minutes||0), Number(j.refill_remaining_seconds||0));\n if($('smartSurgeRefillHint')) $('smartSurgeRefillHint').textContent=smartSurgeRefillHintText(!!Number(st.surge_refill_enabled||0), Number(st.surge_refill_interval_minutes||1440), Number(st.surge_refill_batch_size||2000), Number(j.surge_refill_remaining_seconds||0));\n if($('smartManager')){\n const nameForHash=hash=>torrents.get(hash)?.name || hash;\n $('smartManager').innerHTML=ex.length\n ? responsiveTable(['Torrent','Hash','Reason','Created','Action'],ex.map(x=>[esc(nameForHash(x.torrent_hash)),esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table')\n : '
No Smart Queue exceptions. Use Manage exceptions to choose torrents ignored by Smart Queue.
';\n }\n if($('smartHistory')){\n const body=hist.length\n ? responsiveTable(['Time','Event','Checked','Active','Slot details','Limit','Over','Stopped','Requested','Verified','Pending','Stalled'],hist.map(h=>{\n // Note: Pending and Stalled are separate audit columns so delayed starts and stopped stalled torrents are visible independently.\n const d=smartHistoryDetails(h);\n const activeBefore=d.active_before ?? '-';\n const activeAfter=d.active_after_expected ?? d.active_after_stop ?? '-';\n const limit=d.max_active_downloads ?? '-';\n const requested=Number(d.start_requested_count ?? (d.start_requested||[]).length ?? 0);\n const verified=Number(d.active_verified_count ?? (d.active_verified||[]).length ?? 0);\n const pending=Number(d.pending_confirmation_count ?? (d.start_pending_confirmation||[]).length ?? 0);\n const stalledDetected=Number(d.stalled_detected||0);\n const stalledStopped=Number(d.stalled_stopped||0);\n const stalledProtected=Number(d.protected_stalled||0);\n const stalledText=stalledDetected?`${stalledStopped}/${stalledDetected}${stalledProtected?` protected ${stalledProtected}`:''}`:'-';\n const slotNote=d.cooldown_refill ? `state ${d.active_state_count ?? '-'} / rT active ${d.active_rtorrent_count ?? '-'} / transferring ${d.active_transferring_count ?? '-'}` : '-';\n return [dateCell(h.created_at),esc(d.decision||h.event||'-'),esc(h.checked_count||d.checked||0),esc(`${activeBefore}->${activeAfter}`),esc(slotNote),esc(limit),esc(d.over_limit||0),esc(h.paused_count||0),esc(requested),esc(verified),esc(pending||'-'),esc(stalledText)];\n }),'smart-history-table')\n : '
No Smart Queue operations yet.
';\n const canToggle=totalHistory>10;\n const toggle=canToggle?``:'';\n const clear=totalHistory?``:'';\n $('smartHistory').innerHTML=`${body}${toggle}${clear}`;\n }\n }\n function smartRefillHintText(mode, minutes, remainingSeconds){\n // Note: Refill mode controls only the lightweight slot top-up during cooldown, not the full Smart Queue pass.\n if(mode==='off') return 'Refill is disabled. Smart Queue will only fill slots during full checks or manual checks.';\n if(mode==='custom'){\n const wait=Number(remainingSeconds||0)>0 ? ` Next refill in ${formatDurationLeft(remainingSeconds)}.` : '';\n return `Refill runs at most every ${Math.max(1, Number(minutes||5))} minute(s) while Smart Queue is in cooldown.${wait}`;\n }\n return 'Refill uses the current automatic poller cadence during cooldown, usually about every 2 minutes.';\n }\n function smartSurgeRefillHintText(enabled, minutes, batchSize, remainingSeconds){\n // Note: Surge refill is intentionally separate from normal refill and may overfill the active queue.\n if(!enabled) return 'Surge refill is disabled. Normal Smart Queue limits remain unchanged.';\n const wait=Number(remainingSeconds||0)>0 ? ` Next Surge refill in ${formatDurationLeft(remainingSeconds)}.` : ' Ready for the next automatic Surge refill.';\n return `Every ${Math.max(1, Number(minutes||1440))} minute(s), start up to ${Math.max(1, Number(batchSize||2000))} stopped torrents above the target active limit.${wait}`;\n }\n function updateSmartRefillControls(){\n const mode=$('smartRefillMode')?.value||'auto';\n const interval=$('smartRefillInterval');\n const surgeEnabled=!!$('smartSurgeRefillEnabled')?.checked;\n if(interval) interval.disabled=mode!=='custom';\n if($('smartSurgeRefillInterval')) $('smartSurgeRefillInterval').disabled=!surgeEnabled;\n if($('smartSurgeRefillBatch')) $('smartSurgeRefillBatch').disabled=!surgeEnabled;\n }\n async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toastMessage('toast.noTorrentsSelected','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);} }\n async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,stop_batch_size:$('smartStopBatch')?.value||50,start_grace_seconds:$('smartStartGrace')?.value||900,protect_active_below_cap:$('smartProtectActiveBelowCap')?.checked,prefer_partial_progress:$('smartPreferPartialProgress')?.checked,auto_stop_idle:$('smartAutoStopIdle')?.checked,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value,ignore_seed_peer:$('smartIgnoreSeedPeer')?.checked,ignore_speed:$('smartIgnoreSpeed')?.checked,cooldown_minutes:$('smartCooldown')?.value||10,refill_mode:$('smartRefillMode')?.value||'auto',refill_interval_minutes:$('smartRefillInterval')?.value||5,surge_refill_enabled:$('smartSurgeRefillEnabled')?.checked,surge_refill_interval_minutes:$('smartSurgeRefillInterval')?.value||1440,surge_refill_batch_size:$('smartSurgeRefillBatch')?.value||2000}); toast('Smart Queue saved','success'); await loadSmartQueue(); }\n"; diff --git a/pytorrent/static/js/smartQueueEvents.js b/pytorrent/static/js/smartQueueEvents.js index bf130da..f86f70d 100644 --- a/pytorrent/static/js/smartQueueEvents.js +++ b/pytorrent/static/js/smartQueueEvents.js @@ -1 +1 @@ -export const smartQueueEventsSource = "$('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'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();}); "; +export const smartQueueEventsSource = "$('smartRefillMode')?.addEventListener('change',updateSmartRefillControls); $('smartSurgeRefillEnabled')?.addEventListener('change',updateSmartRefillControls); $('smartSaveBtn')?.addEventListener('click',saveSmartQueue); $('smartCheckBtn')?.addEventListener('click',async()=>{setBusy(true); try{const j=await post('/api/smart-queue/check',{}); if(j.queued){toastMessage('toast.smartQueueCheckQueued','success'); await loadJobs().catch(()=>{}); await loadSmartQueue(); return;} const r=j.result||{}; if(j.torrent_patch) patchRows(j.torrent_patch); toast(smartQueueToastMessage(r),'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();}); "; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 4d45517..14a7ae4 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -269,15 +269,26 @@ body { letter-spacing: 0.2px; } .initial-loader-spinner { + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; margin: 1.4rem 0 1rem; } +.initial-loader-easter-egg-image, .initial-loader-prank img { + display: block; + width: auto; max-width: min(100%, 320px); + height: auto; max-height: 220px; object-fit: contain; border-radius: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); } +.initial-loader-easter-egg-image { + contain: layout paint; +} .prank-click-image { position: fixed; z-index: 9500; @@ -2967,6 +2978,19 @@ body.mobile-mode .mobile-filter-bar { display: grid; gap: 0.3rem; } + +.smart-surge-refill-card .smart-refill-controls { + grid-template-columns: minmax(84px, 0.6fr) minmax(110px, 1fr) minmax(110px, 1fr); + width: min(450px, 100%); +} + +.smart-refill-switch { + align-content: end; +} + +.smart-refill-switch .form-check-input { + margin: 0; +} .disk-monitor-shell { display: grid; grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr); diff --git a/pytorrent/static/tracker_favicons b/pytorrent/static/tracker_favicons deleted file mode 120000 index d4b544b..0000000 --- a/pytorrent/static/tracker_favicons +++ /dev/null @@ -1 +0,0 @@ -../../data/tracker_favicons \ No newline at end of file diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 935b516..9e46009 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -326,7 +326,7 @@
RSS downloader
Feeds are checked by schedule and every match is logged per feed/rule.
Feed
Rule
Feeds, rules and matches
Choose columns visible in the torrent list.
-
Smart Queue
Automatic queue balancing for slow or stalled downloads.
Run Smart Queue during polling. Stopped torrents are managed; Paused torrents stay user-controlled.
When enabled, Smart Queue disables itself after a check finds no active downloads and no waiting stopped candidates. Enable it again manually when you add more work.
Next Smart Queue runnext: readyAutomatic runs use the cooldown below. Manual Check now still runs immediately.
Queue refill during cooldownAutomatic keeps the current poller cadence. Custom runs only after the selected number of minutes. Off disables refill completely.
Recommended for best efficiency. When enabled, Smart Queue refills empty slots first and does not stop stalled downloads while active downloads are below the cap. Stalled cleanup resumes once the cap is reached or exceeded. Disable only if you prefer aggressive cleanup over keeping the active count near the cap.
Start stopped torrents with existing progress first, so Smart Queue finishes already started downloads before opening fresh ones.
When enabled, Smart Queue does not use seed/peer count as a stalled criterion.
When enabled, low speed is not required. With source and speed ignores enabled, only Stalled after seconds decides.
Choose torrents ignored by Smart Queue. Existing behavior stays unchanged for all non-excluded torrents.
Last operations
+
Smart Queue
Automatic queue balancing for slow or stalled downloads.
Run Smart Queue during polling. Stopped torrents are managed; Paused torrents stay user-controlled.
When enabled, Smart Queue disables itself after a check finds no active downloads and no waiting stopped candidates. Enable it again manually when you add more work.
Next Smart Queue runnext: readyAutomatic runs use the cooldown below. Manual Check now still runs immediately.
Queue refill during cooldownAutomatic keeps the current poller cadence. Custom runs only after the selected number of minutes. Off disables refill completely.
Surge refillPeriodically starts a large batch above the active-download target. Normal Smart Queue checks keep replacing stalled items and drain overflow back toward the target.
Recommended for best efficiency. When enabled, Smart Queue refills empty slots first and does not stop stalled downloads while active downloads are below the cap. Stalled cleanup resumes once the cap is reached or exceeded. Disable only if you prefer aggressive cleanup over keeping the active count near the cap.
Start stopped torrents with existing progress first, so Smart Queue finishes already started downloads before opening fresh ones.
When enabled, Smart Queue does not use seed/peer count as a stalled criterion.
When enabled, low speed is not required. With source and speed ignores enabled, only Stalled after seconds decides.
Choose torrents ignored by Smart Queue. Existing behavior stays unchanged for all non-excluded torrents.
Last operations
Automations / rules
Build a rule as: conditions first, then ordered actions. Matching torrents are handled as one batch and the cooldown is applied to the whole rule.
1. Rule
2. Conditions
3. Actions, in order
Rules
History
rTorrent config
Grouped rTorrent runtime settings with inline recommendations and compatibility status.
Reference value is kept from the first override save. Later saves add or clear differences without replacing the original reference.
No changes
Loading config...
Cleanup / retention
One place to clear logs and active profile caches. Pending/running jobs, rules, settings and torrents are preserved.
Loading cleanup data...