diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index f15067f..bcfa41d 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -493,7 +493,7 @@ def torrent_action(action_name: str): if not profile: return jsonify({"ok": False, "error": "No profile"}), 400 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: return jsonify({"ok": False, "error": "Unknown action"}), 400 if action_name in {"move", "remove"}: diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py index 9b81b44..2378ca2 100644 --- a/pytorrent/services/rtorrent.py +++ b/pytorrent/services/rtorrent.py @@ -1167,61 +1167,123 @@ def apply_startup_overrides(profile: dict) -> dict: return set_config(profile, values, apply_now=True, apply_on_start=True) -def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: - """Start stopped torrents and resume torrents paused with d.pause.""" +def _int_rpc(c: ScgiRtorrentClient, method: str, h: str, default: int = 0) -> int: + 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 '') if not h: return {'hash': h, 'ok': False, 'error': 'missing hash'} - - result: dict = {'hash': h, 'commands': []} + before = _download_runtime_state(c, h) + result = {'hash': h, 'before': before, 'commands': []} 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: - result['state_before_error'] = str(exc) - result['state_before'] = 0 + result.update({'ok': False, 'error': str(exc), 'after': _download_runtime_state(c, h)}) + 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. - for method in ('d.resume',): - try: - c.call(method, h) - result['commands'].append(method) - except Exception as exc: - result.setdefault('ignored_errors', []).append(f'{method}: {exc}') - # Note: d.open bywa potrzebne po całkowitym stop/close; dla już otwartych torrentów jest bezpiecznie ignorowane. +def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: + """Resume only a paused rTorrent item; never convert it through stop/start.""" + h = str(torrent_hash or '') + 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: + # Note: ruTorrent dla od-pauzowania wysyła odpowiednik unpause/d.resume. Nie dokładamy d.start/d.open, + # 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: + 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 + + try: + # Note: d.start zostaje wyłącznie dla Stopped/closed, czyli dla stanu innego niż pause->resume. c.call('d.open', h) result['commands'].append('d.open') except Exception as exc: result.setdefault('ignored_errors', []).append(f'd.open: {exc}') - - for method in ('d.start', 'd.resume', 'd.try_start'): + try: + c.call('d.start', h) + result['commands'].append('d.start') + except Exception as exc: + result.setdefault('ignored_errors', []).append(f'd.start: {exc}') try: - c.call(method, h) - result['commands'].append(method) - except Exception as exc: - result.setdefault('ignored_errors', []).append(f'{method}: {exc}') - - try: - result['state_after'] = int(c.call('d.state', h) or 0) - except Exception as exc: - result['state_after_error'] = str(exc) - try: - result['active_after'] = int(c.call('d.is_active', h) or 0) - except Exception as exc: - result['active_after_error'] = str(exc) - result['ok'] = True + c.call('d.try_start', h) + result['commands'].append('d.try_start') + except Exception as exc2: + result.setdefault('ignored_errors', []).append(f'd.try_start: {exc2}') + result['ok'] = False + result['after'] = _download_runtime_state(c, h) + result['ok'] = result.get('ok', True) return result def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | None = None) -> dict: payload = payload or {} c = client_for(profile) methods = { - "start": "d.start", - "pause": "d.pause", "stop": "d.stop", - "resume": "d.resume", "recheck": "d.check_hash", "reannounce": "d.tracker_announce", "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) results.append(item) return {"ok": True, "count": len(torrent_hashes), "move_data": move_data, "results": results} - if name in {"start", "resume"}: - # Note: Start działa teraz także dla pozycji Paused, bo wykonuje pełną sekwencję resume/open/start. + if name == "pause": + # 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] return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results} diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py index 42e658d..3950473 100644 --- a/pytorrent/services/smart_queue.py +++ b/pytorrent/services/smart_queue.py @@ -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]: - """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 '') if not h: 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) @@ -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 '') except Exception as exc: result[f'{key}_error'] = str(exc) - # Note: Realny slot liczymy po d.is_active=1. Dodatkowo zwracamy state/open/priority, - # bo przy masowym resume rTorrent czasem przyjmuje start, ale aktywuje transfer dopiero w kolejnym ticku. + # Note: Nie uznajemy d.is_open ani state=1 za wznowienie; Paused też potrafi mieć te wartości. + # 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['start_accepted'] = bool(int(result.get('state') or 0) or int(result.get('open') or 0)) 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: for attempt in range(max(1, attempts)): 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) -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]: profile = profile or active_profile() if not profile: @@ -505,27 +449,13 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = else: 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. - fresh_candidates = sorted( - [ - 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 - ], + candidates = sorted( + stopped, key=lambda t: (int(t.get('seeds') or 0), int(t.get('peers') or 0), int(t.get('down_rate') or 0)), reverse=True, ) - expired_candidates = sorted( - [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 + max_active = max(1, int(settings.get('max_active_downloads') or 5)) stalled_hashes = {str(t.get('hash') or '') for t in stalled} # 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)] 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. - # Dzięki temu jeden cykl odpala pełną kolejkę, a wymiana następuje dopiero po czasie kontrolnym. - protected_holds = {h for h in pending_holds if h and h not in pause_hashes and h not in excluded} - active_after_pause = max(0, len(downloading) - len(to_pause)) - effective_slots_after_pause = active_after_pause + len(protected_holds) - - # 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) - pause_hashes.add(h) - replacement_capacity -= 1 + # Note: Rotacja stalled działa tylko przy pełnej kolejce. Gdy brakuje slotów, Smart Queue ma + # najpierw dobrać brakujące pozycje, a nie pauzować już istniejące lub błędnie uznane za stalled. + if candidates and len(downloading) >= max_active: + replaceable_stalled = [t for t in stalled if str(t.get('hash') or '') not in pause_hashes] + for t in replaceable_stalled[:max(0, len(candidates) - len(to_pause))]: + to_pause.append(t) + pause_hashes.add(str(t.get('hash') or '')) active_after_pause = max(0, len(downloading) - len(to_pause)) - protected_holds = {h for h in protected_holds if h not in pause_hashes} - effective_slots_after_pause = active_after_pause + len(protected_holds) + available_slots = max(0, max_active - active_after_pause) + 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) rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active) @@ -571,89 +495,21 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = start_no_effect: list[dict[str, Any]] = [] resume_requested: list[str] = [] start_results: list[dict[str, Any]] = [] - attempted_hashes: set[str] = set() for t in to_pause: - h = str(t.get('hash') or '') - if not h: - continue try: - c.call('d.pause', h) + 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(h) - paused.append(h) + label_failed.append(t['hash']) + paused.append(t['hash']) 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: + for t in to_label_waiting: 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) - start_results.append(result) - resume_requested.append(h) - batch_requested.append(h) - except Exception as exc: - start_failed.append({'hash': h, 'error': str(exc)}) - time.sleep(0.03) - - if batch_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: - if h not in resumed: - _restore_auto_label(c, profile_id, h, None) - resumed.append(h) - if active_verified: - with connect() as conn: - for h in active_verified: - conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)) - - 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: + if not h or h in pause_hashes: continue try: if not _mark_auto_paused(c, profile_id, t): @@ -661,8 +517,30 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool = except Exception: label_failed.append(h) - keep_labels = set(paused) | waiting_hashes | protected_holds | expired_holds + # 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 '') + if not h: + continue + try: + result = _start_download(c, t) + start_results.append(result) + resume_requested.append(h) + except Exception as exc: + start_failed.append({'hash': h, 'error': str(exc)}) + + active_verified, start_no_effect = _verify_started_downloads(c, resume_requested) + for h in active_verified: + _restore_auto_label(c, profile_id, h, None) + # Note: Historia pokazuje tylko torrenty faktycznie zdjęte z pauzy, a nie samą liczbę wysłanych komend. + resumed = list(active_verified) + keep_labels = ( + set(paused) + | {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)} + ) 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) - 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}