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 `${esc(t.name||hash)} ${esc(smartQueueTorrentLabel(t))} `;\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),` remove exception `]),'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?` ${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)}) `:'';\n const clear=totalHistory?` Clear history `:'';\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 `${esc(t.name||hash)} ${esc(smartQueueTorrentLabel(t))} `;\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),` remove exception `]),'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?` ${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)}) `:'';\n const clear=totalHistory?` Clear history `:'';\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 @@
-
+