fix queue

This commit is contained in:
Mateusz Gruszczyński
2026-05-05 19:19:47 +02:00
parent 45cb6cbb3a
commit aea3c92830
3 changed files with 161 additions and 213 deletions

View File

@@ -493,7 +493,7 @@ def torrent_action(action_name: str):
if not profile: if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400 return jsonify({"ok": False, "error": "No profile"}), 400
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
allowed = {"start", "pause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"} allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
if action_name not in allowed: if action_name not in allowed:
return jsonify({"ok": False, "error": "Unknown action"}), 400 return jsonify({"ok": False, "error": "Unknown action"}), 400
if action_name in {"move", "remove"}: if action_name in {"move", "remove"}:

View File

@@ -1167,61 +1167,123 @@ def apply_startup_overrides(profile: dict) -> dict:
return set_config(profile, values, apply_now=True, apply_on_start=True) return set_config(profile, values, apply_now=True, apply_on_start=True)
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: def _int_rpc(c: ScgiRtorrentClient, method: str, h: str, default: int = 0) -> int:
"""Start stopped torrents and resume torrents paused with d.pause.""" try:
return int(c.call(method, h) or 0)
except Exception:
return default
def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> str:
try:
return str(c.call(method, h) or '')
except Exception:
return default
def _download_runtime_state(c: ScgiRtorrentClient, h: str) -> dict:
"""Read rTorrent state using the native pause model: stopped, paused or active."""
state = _int_rpc(c, 'd.state', h)
active = _int_rpc(c, 'd.is_active', h)
opened = _int_rpc(c, 'd.is_open', h)
# Note: W rTorrent pauza nie zmienia d.state. Paused to state=1, open=1, active=0.
return {
'state': state,
'open': opened,
'active': active,
'paused': bool(state and opened and not active),
'stopped': not bool(state),
'message': _str_rpc(c, 'd.message', h),
}
def pause_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
"""Pause an active rTorrent item without stopping or closing it."""
h = str(torrent_hash or '') h = str(torrent_hash or '')
if not h: if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'} return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result: dict = {'hash': h, 'commands': []} result = {'hash': h, 'before': before, 'commands': []}
try: try:
result['state_before'] = int(c.call('d.state', h) or 0) # Note: Smart Queue zatrzymuje slot przez d.pause, nie przez d.stop, żeby późniejsze d.resume działało jak w ruTorrent.
c.call('d.pause', h)
result['commands'].append('d.pause')
result['after'] = _download_runtime_state(c, h)
result['ok'] = True
except Exception as exc: except Exception as exc:
result['state_before_error'] = str(exc) result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
result['state_before'] = 0 return result
# Note: Ręczne Start i Smart Queue muszą zdejmować pause przez d.resume; samo d.start
# nie rusza torrentów zatrzymanych wcześniej komendą d.pause. def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
for method in ('d.resume',): """Resume only a paused rTorrent item; never convert it through stop/start."""
h = str(torrent_hash or '')
if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('stopped'):
result.update({'ok': False, 'skipped': 'stopped_not_paused', 'after': before})
return result
if before.get('active'):
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
try: try:
c.call(method, h) # Note: ruTorrent dla od-pauzowania wysyła odpowiednik unpause/d.resume. Nie dokładamy d.start/d.open,
result['commands'].append(method) # bo to są komendy dla stanu Stopped/Open, a nie dla czystego Paused.
c.call('d.resume', h)
result['commands'].append('d.resume')
result['after'] = _download_runtime_state(c, h)
result['ok'] = True
except Exception as exc: except Exception as exc:
result.setdefault('ignored_errors', []).append(f'{method}: {exc}') result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)})
return result
def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
"""Start stopped torrents or resume torrents paused with d.pause, without mixing both paths."""
h = str(torrent_hash or '')
if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('active'):
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
if before.get('paused') or (before.get('state') and not before.get('active')):
# Note: Paused w rTorrent wznawiamy tylko przez d.resume; d.start jest tu celowo pomijane.
resumed = resume_paused_hash(c, h)
resumed['mode'] = 'resume_paused'
return resumed
# Note: d.open bywa potrzebne po całkowitym stop/close; dla już otwartych torrentów jest bezpiecznie ignorowane.
try: try:
# Note: d.start zostaje wyłącznie dla Stopped/closed, czyli dla stanu innego niż pause->resume.
c.call('d.open', h) c.call('d.open', h)
result['commands'].append('d.open') result['commands'].append('d.open')
except Exception as exc: except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.open: {exc}') result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
for method in ('d.start', 'd.resume', 'd.try_start'):
try: try:
c.call(method, h) c.call('d.start', h)
result['commands'].append(method) result['commands'].append('d.start')
except Exception as exc: except Exception as exc:
result.setdefault('ignored_errors', []).append(f'{method}: {exc}') result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
try: try:
result['state_after'] = int(c.call('d.state', h) or 0) c.call('d.try_start', h)
except Exception as exc: result['commands'].append('d.try_start')
result['state_after_error'] = str(exc) except Exception as exc2:
try: result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}')
result['active_after'] = int(c.call('d.is_active', h) or 0) result['ok'] = False
except Exception as exc: result['after'] = _download_runtime_state(c, h)
result['active_after_error'] = str(exc) result['ok'] = result.get('ok', True)
result['ok'] = True
return result return result
def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None) -> dict: def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None) -> dict:
payload = payload or {} payload = payload or {}
c = client_for(profile) c = client_for(profile)
methods = { methods = {
"start": "d.start",
"pause": "d.pause",
"stop": "d.stop", "stop": "d.stop",
"resume": "d.resume",
"recheck": "d.check_hash", "recheck": "d.check_hash",
"reannounce": "d.tracker_announce", "reannounce": "d.tracker_announce",
"remove": "d.erase", "remove": "d.erase",
@@ -1289,8 +1351,16 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
c.call("d.directory.set", h, path) c.call("d.directory.set", h, path)
results.append(item) results.append(item)
return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "results": results} return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "results": results}
if name in {"start", "resume"}: if name == "pause":
# Note: Start działa teraz także dla pozycji Paused, bo wykonuje pełną sekwencję resume/open/start. # Note: Pauza aplikacji jest teraz czystym d.pause, żeby późniejszy resume działał bez stop/start.
results = [pause_hash(c, h) for h in torrent_hashes]
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
if name in {"resume", "unpause"}:
# Note: Resume/Unpause używa wyłącznie d.resume dla stanu Paused.
results = [resume_paused_hash(c, h) for h in torrent_hashes]
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}
if name == "start":
# Note: Start rozdziela Stopped od Paused; paused idzie przez d.resume, stopped przez d.start.
results = [start_or_resume_hash(c, h) for h in torrent_hashes] results = [start_or_resume_hash(c, h) for h in torrent_hashes]
return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results} return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results}

View File

@@ -226,11 +226,14 @@ def _ensure_rtorrent_download_cap(client: Any, max_active: int) -> dict[str, Any
def _start_download(client: Any, torrent: dict[str, Any]) -> dict[str, Any]: def _start_download(client: Any, torrent: dict[str, Any]) -> dict[str, Any]:
"""Resume paused torrents and open/start stopped torrents with the same path as manual Start.""" """Resume paused torrents through rTorrent's pause model."""
h = str(torrent.get('hash') or '') h = str(torrent.get('hash') or '')
if not h: if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'} return {'hash': h, 'ok': False, 'error': 'missing hash'}
# Note: Smart Queue używa tej samej sekwencji co ręczny Start, żeby Paused nie zostawał w pauzie po samym d.start. if bool(torrent.get('paused')) or str(torrent.get('status') or '').lower() == 'paused' or int(torrent.get('state') or 0):
# Note: Kandydaci Smart Queue po d.pause mają być wznawiani przez d.resume, bez d.start/d.stop.
return rtorrent.resume_paused_hash(client, h)
# Note: Tylko opcjonalne manage_stopped korzysta ze ścieżki start dla całkowicie zatrzymanych torrentów.
return rtorrent.start_or_resume_hash(client, h) return rtorrent.start_or_resume_hash(client, h)
@@ -274,25 +277,11 @@ def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]:
result[key] = int(value or 0) if key in {'state', 'active', 'open', 'priority'} else str(value or '') result[key] = int(value or 0) if key in {'state', 'active', 'open', 'priority'} else str(value or '')
except Exception as exc: except Exception as exc:
result[f'{key}_error'] = str(exc) result[f'{key}_error'] = str(exc)
# Note: Realny slot liczymy po d.is_active=1. Dodatkowo zwracamy state/open/priority, # Note: Nie uznajemy d.is_open ani state=1 za wznowienie; Paused też potrafi mieć te wartości.
# bo przy masowym resume rTorrent czasem przyjmuje start, ale aktywuje transfer dopiero w kolejnym ticku. # Smart Queue zalicza start dopiero po d.is_active=1, czyli po realnym zdjęciu pauzy.
result['started'] = bool(int(result.get('active') or 0)) result['started'] = bool(int(result.get('active') or 0))
result['start_accepted'] = bool(int(result.get('state') or 0) or int(result.get('open') or 0))
return result return result
def _refresh_active_slots(profile: dict, excluded: set[str], manage_stopped: bool) -> tuple[int, list[dict[str, Any]]]:
"""Read a fresh torrent snapshot and count real active Smart Queue slots."""
fresh = rtorrent.list_torrents(profile)
active = [
t for t in fresh
if str(t.get('hash') or '') not in excluded
and _is_running_download_slot(t)
]
# Note: Po batchowym resume nie ufamy staremu snapshotowi; odświeżenie z rTorrent
# pozwala dobić kolejkę także wtedy, gdy aktywacja nastąpiła z opóźnieniem.
return len(active), fresh
def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool: def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool:
for attempt in range(max(1, attempts)): for attempt in range(max(1, attempts)):
try: try:
@@ -395,51 +384,6 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b
return bool(manage_stopped) and not int(t.get('state') or 0) return bool(manage_stopped) and not int(t.get('state') or 0)
def _smart_queue_hold_state(profile_id: int, torrents: list[dict[str, Any]], stalled_seconds: int, now: str, now_ts: float) -> tuple[set[str], set[str], set[str]]:
"""Return pending, expired and all Smart Queue technical holds."""
technical_holds = {
str(t.get('hash') or '')
for t in torrents
if str(t.get('hash') or '') and str(t.get('label') or '') == SMART_QUEUE_LABEL and not int(t.get('complete') or 0)
}
pending: set[str] = set()
expired: set[str] = set()
if not technical_holds:
return pending, expired, technical_holds
with connect() as conn:
for h in technical_holds:
row = conn.execute(
'SELECT first_stalled_at FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?',
(profile_id, h),
).fetchone()
if not row:
# Note: Label Smart Queue bez timera traktujemy jako świeżą próbę, żeby pierwszy cykl nie wymieniał go natychmiast.
conn.execute(
'INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at) VALUES(?,?,?,?)',
(profile_id, h, now, now),
)
pending.add(h)
continue
first = row['first_stalled_at']
conn.execute('UPDATE smart_queue_stalled SET updated_at=? WHERE profile_id=? AND torrent_hash=?', (now, profile_id, h))
if now_ts - _ts(first) >= stalled_seconds:
expired.add(h)
else:
pending.add(h)
return pending, expired, technical_holds
def _remember_resume_attempt(profile_id: int, torrent_hash: str, now: str) -> None:
if not torrent_hash:
return
with connect() as conn:
# Note: Timer próby resume jest używany jako karencja przed wymianą nieaktywnego torrenta.
conn.execute(
'INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at) VALUES(?,?,?,?)',
(profile_id, torrent_hash, now, now),
)
def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> dict[str, Any]: def check(profile: dict | None = None, user_id: int | None = None, force: bool = False) -> dict[str, Any]:
profile = profile or active_profile() profile = profile or active_profile()
if not profile: if not profile:
@@ -505,27 +449,13 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
else: else:
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)) conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
max_active = max(1, int(settings.get('max_active_downloads') or 5))
pending_holds, expired_holds, technical_holds = _smart_queue_hold_state(profile_id, stopped, stalled_seconds, now, now_ts)
# Candidates with visible sources are preferred. Do not touch excluded torrents. # Candidates with visible sources are preferred. Do not touch excluded torrents.
fresh_candidates = sorted( candidates = sorted(
[ stopped,
t for t in stopped
if str(t.get('hash') or '')
and str(t.get('hash') or '') not in pending_holds
and str(t.get('hash') or '') not in expired_holds
],
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)), key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
reverse=True, reverse=True,
) )
expired_candidates = sorted( max_active = max(1, int(settings.get('max_active_downloads') or 5))
[t for t in stopped if str(t.get('hash') or '') in expired_holds],
key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)),
reverse=True,
)
# Note: Najpierw dobieramy nowe pozycje, a dopiero gdy nie ma alternatyw, ponawiamy stare nieaktywne próby.
candidates = fresh_candidates + expired_candidates
stalled_hashes = {str(t.get('hash') or '') for t in stalled} stalled_hashes = {str(t.get('hash') or '') for t in stalled}
# Enforce the hard active-download cap first. The previous logic only limited # Enforce the hard active-download cap first. The previous logic only limited
@@ -542,25 +472,19 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)] to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)]
pause_hashes = {str(t.get('hash') or '') for t in to_pause} pause_hashes = {str(t.get('hash') or '') for t in to_pause}
# Note: Nieaktywne próby resume trzymamy jako zajęte sloty przez stalled_seconds. # Note: Rotacja stalled działa tylko przy pełnej kolejce. Gdy brakuje slotów, Smart Queue ma
# Dzięki temu jeden cykl odpala pełną kolejkę, a wymiana następuje dopiero po czasie kontrolnym. # najpierw dobrać brakujące pozycje, a nie pauzować już istniejące lub błędnie uznane za stalled.
protected_holds = {h for h in pending_holds if h and h not in pause_hashes and h not in excluded} if candidates and len(downloading) >= max_active:
active_after_pause = max(0, len(downloading) - len(to_pause)) replaceable_stalled = [t for t in stalled if str(t.get('hash') or '') not in pause_hashes]
effective_slots_after_pause = active_after_pause + len(protected_holds) for t in replaceable_stalled[:max(0, len(candidates) - len(to_pause))]:
# Note: Stalled wymieniamy tylko wtedy, gdy jest czym go zastąpić i po uwzględnieniu slotów w karencji.
replacement_capacity = max(0, len(candidates) - max(0, max_active - effective_slots_after_pause))
for t in stalled:
h = str(t.get('hash') or '')
if not h or h in pause_hashes or replacement_capacity <= 0:
continue
to_pause.append(t) to_pause.append(t)
pause_hashes.add(h) pause_hashes.add(str(t.get('hash') or ''))
replacement_capacity -= 1
active_after_pause = max(0, len(downloading) - len(to_pause)) active_after_pause = max(0, len(downloading) - len(to_pause))
protected_holds = {h for h in protected_holds if h not in pause_hashes} available_slots = max(0, max_active - active_after_pause)
effective_slots_after_pause = active_after_pause + len(protected_holds) to_resume = candidates[:available_slots]
# Note: Pozycje poza bieżącą pulą startu zostają jawnie oznaczone jako oczekujące Smart Queue.
to_label_waiting = candidates[available_slots:]
c = rtorrent.client_for(profile) c = rtorrent.client_for(profile)
rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active) rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active)
@@ -571,98 +495,52 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
start_no_effect: list[dict[str, Any]] = [] start_no_effect: list[dict[str, Any]] = []
resume_requested: list[str] = [] resume_requested: list[str] = []
start_results: list[dict[str, Any]] = [] start_results: list[dict[str, Any]] = []
attempted_hashes: set[str] = set()
for t in to_pause: for t in to_pause:
try:
pause_result = rtorrent.pause_hash(c, t['hash'])
if not pause_result.get('ok'):
raise RuntimeError(pause_result.get('error') or 'pause failed')
if not _mark_auto_paused(c, profile_id, t):
label_failed.append(t['hash'])
paused.append(t['hash'])
except Exception:
pass
for t in to_label_waiting:
h = str(t.get('hash') or '')
if not h or h in pause_hashes:
continue
try:
if not _mark_auto_paused(c, profile_id, t):
label_failed.append(h)
except Exception:
label_failed.append(h)
# Note: Startujemy całą pulę kandydatów w jednej rundzie. Label zdejmujemy po zaakceptowanym RPC,
# bo rTorrent może trzymać część pozycji w swojej kolejce z active=0 mimo poprawnego d.start/d.resume.
for t in to_resume:
h = str(t.get('hash') or '') h = str(t.get('hash') or '')
if not h: if not h:
continue continue
try: try:
c.call('d.pause', h)
if not _mark_auto_paused(c, profile_id, t):
label_failed.append(h)
paused.append(h)
except Exception:
pass
candidate_queue = [
t for t in candidates
if str(t.get('hash') or '')
and str(t.get('hash') or '') not in pause_hashes
and str(t.get('hash') or '') not in protected_holds
]
slots_left = max(0, max_active - effective_slots_after_pause)
batch = candidate_queue[:slots_left]
batch_requested: list[str] = []
# Note: Smart Queue wykonuje jeden masowy strzał do pełnego targetu. Nie dobiera kolejnych
# w tym samym przebiegu tylko dlatego, że rTorrent nie pokazał od razu d.is_active=1.
for t in batch:
h = str(t.get('hash') or '')
if not h or h in attempted_hashes:
continue
attempted_hashes.add(h)
try:
if not _mark_auto_paused(c, profile_id, t):
label_failed.append(h)
_remember_resume_attempt(profile_id, h, now)
result = _start_download(c, t) result = _start_download(c, t)
start_results.append(result) start_results.append(result)
resume_requested.append(h) resume_requested.append(h)
batch_requested.append(h)
except Exception as exc: except Exception as exc:
start_failed.append({'hash': h, 'error': str(exc)}) start_failed.append({'hash': h, 'error': str(exc)})
time.sleep(0.03)
if batch_requested: active_verified, start_no_effect = _verify_started_downloads(c, resume_requested)
# Note: Weryfikacja jest informacyjna i służy zdjęciu technicznego labela, ale nie uruchamia kolejnego batcha.
active_verified, batch_no_effect = _verify_started_downloads(c, batch_requested, attempts=4, delay=0.4)
start_no_effect.extend(batch_no_effect)
for h in active_verified: for h in active_verified:
if h not in resumed:
_restore_auto_label(c, profile_id, h, None) _restore_auto_label(c, profile_id, h, None)
resumed.append(h) # Note: Historia pokazuje tylko torrenty faktycznie zdjęte z pauzy, a nie samą liczbę wysłanych komend.
if active_verified: resumed = list(active_verified)
with connect() as conn: keep_labels = (
for h in active_verified: set(paused)
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)) | {str(t.get('hash') or '') for t in to_label_waiting}
| {str(t.get('hash') or '') for t in stopped if str(t.get('label') or '') == SMART_QUEUE_LABEL and str(t.get('hash') or '') not in set(resumed)}
fresh_active_slots, fresh_torrents = _refresh_active_slots(profile, excluded, manage_stopped)
fresh_by_hash = {str(t.get('hash') or ''): t for t in fresh_torrents}
for h in batch_requested:
live_t = fresh_by_hash.get(h)
if live_t and _is_running_download_slot(live_t) and h not in resumed:
_restore_auto_label(c, profile_id, h, None)
resumed.append(h)
if resumed:
with connect() as conn:
for h in resumed:
conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h))
active_slots = max(effective_slots_after_pause, fresh_active_slots)
else:
active_slots = effective_slots_after_pause
resumed_set = set(resumed)
waiting_hashes = (
(technical_holds | set(batch_requested) | {str(t.get('hash') or '') for t in candidate_queue})
- resumed_set
- pause_hashes
) )
waiting_hashes = {h for h in waiting_hashes if h}
# Note: Kandydaci niewznowieni zostają oznaczeni jako oczekujący; po stalled_seconds mogą zostać wymienieni na innych.
for t in candidates:
h = str(t.get('hash') or '')
if not h or h not in waiting_hashes:
continue
try:
if not _mark_auto_paused(c, profile_id, t):
label_failed.append(h)
except Exception:
label_failed.append(h)
keep_labels = set(paused) | waiting_hashes | protected_holds | expired_holds
restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped) restored = _cleanup_auto_labels(c, profile_id, torrents, keep_labels, manage_stopped)
details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': resumed, 'attempted_count': len(attempted_hashes), 'waiting_labeled': len(waiting_hashes), 'pending_holds': len(protected_holds), 'expired_holds': len(expired_holds), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_slots, 'paused_planned': len(to_pause), 'resumed_planned': len(attempted_hashes), 'stalled_detected': len(stalled), 'stalled_paused': len([h for h in paused if h in stalled_hashes]), 'rtorrent_cap': rtorrent_cap} details = {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'resume_requested': resume_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': manage_stopped, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_pause + len(resumed), 'paused_planned': len(to_pause), 'resumed_planned': len(to_resume), 'rtorrent_cap': rtorrent_cap}
add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id) add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), details, user_id)
return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(waiting_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': resumed, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings} return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'resume_requested': resume_requested, 'waiting_labeled': len(to_label_waiting), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings}