from __future__ import annotations from datetime import datetime, timezone from typing import Any import json import time from ..config import SMART_QUEUE_LABEL from ..db import connect, default_user_id, utcnow from . import rtorrent from .preferences import active_profile, get_profile def _ts(value: str | None) -> float: if not value: return 0.0 try: return datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp() except Exception: return 0.0 def _default_settings(user_id: int, profile_id: int) -> dict[str, Any]: return { 'user_id': user_id, 'profile_id': profile_id, 'enabled': 0, 'max_active_downloads': 5, 'stalled_seconds': 300, 'min_speed_bytes': 1024, 'min_seeds': 1, 'updated_at': utcnow(), } def get_settings(profile_id: int, user_id: int | None = None) -> dict[str, Any]: user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( 'SELECT * FROM smart_queue_settings WHERE user_id=? AND profile_id=?', (user_id, profile_id), ).fetchone() return row or _default_settings(user_id, profile_id) def save_settings(profile_id: int, data: dict[str, Any], user_id: int | None = None) -> dict[str, Any]: user_id = user_id or default_user_id() current = get_settings(profile_id, user_id) settings = { 'enabled': 1 if data.get('enabled', current.get('enabled')) else 0, 'max_active_downloads': max(1, int(data.get('max_active_downloads') or current.get('max_active_downloads') or 5)), 'stalled_seconds': max(30, int(data.get('stalled_seconds') or current.get('stalled_seconds') or 300)), 'min_speed_bytes': max(0, int(data.get('min_speed_bytes') or current.get('min_speed_bytes') or 0)), 'min_seeds': max(0, int(data.get('min_seeds') or current.get('min_seeds') or 0)), } now = utcnow() with connect() as conn: conn.execute( '''INSERT INTO smart_queue_settings(user_id,profile_id,enabled,max_active_downloads,stalled_seconds,min_speed_bytes,min_seeds,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(user_id, profile_id) DO UPDATE SET enabled=excluded.enabled, max_active_downloads=excluded.max_active_downloads, stalled_seconds=excluded.stalled_seconds, min_speed_bytes=excluded.min_speed_bytes, min_seeds=excluded.min_seeds, updated_at=excluded.updated_at''', (user_id, profile_id, settings['enabled'], settings['max_active_downloads'], settings['stalled_seconds'], settings['min_speed_bytes'], settings['min_seeds'], now), ) return get_settings(profile_id, user_id) def list_exclusions(profile_id: int, user_id: int | None = None) -> list[dict[str, Any]]: user_id = user_id or default_user_id() with connect() as conn: return conn.execute( 'SELECT * FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? ORDER BY created_at DESC', (user_id, profile_id), ).fetchall() def set_exclusion(profile_id: int, torrent_hash: str, excluded: bool, reason: str = '', user_id: int | None = None) -> None: user_id = user_id or default_user_id() now = utcnow() with connect() as conn: if excluded: conn.execute( 'INSERT OR REPLACE INTO smart_queue_exclusions(user_id,profile_id,torrent_hash,reason,created_at) VALUES(?,?,?,?,?)', (user_id, profile_id, torrent_hash, reason, now), ) else: conn.execute( 'DELETE FROM smart_queue_exclusions WHERE user_id=? AND profile_id=? AND torrent_hash=?', (user_id, profile_id, torrent_hash), ) def add_history(profile_id: int, event: str, paused: list[str] | None = None, resumed: list[str] | None = None, checked: int = 0, details: dict[str, Any] | None = None, user_id: int | None = None) -> None: user_id = user_id or default_user_id() paused = paused or [] resumed = resumed or [] details = details or {} with connect() as conn: conn.execute( 'INSERT INTO smart_queue_history(user_id,profile_id,event,paused_count,resumed_count,checked_count,details_json,created_at) VALUES(?,?,?,?,?,?,?,?)', (user_id, profile_id, event, len(paused), len(resumed), int(checked or 0), json.dumps({**details, 'paused': paused, 'resumed': resumed}), utcnow()), ) def list_history(profile_id: int, user_id: int | None = None, limit: int = 30) -> list[dict[str, Any]]: user_id = user_id or default_user_id() with connect() as conn: return conn.execute( 'SELECT * FROM smart_queue_history WHERE user_id=? AND profile_id=? ORDER BY created_at DESC LIMIT ?', (user_id, profile_id, max(1, min(int(limit or 30), 100))), ).fetchall() def count_history(profile_id: int, user_id: int | None = None) -> int: user_id = user_id or default_user_id() with connect() as conn: row = conn.execute( 'SELECT COUNT(*) AS count FROM smart_queue_history WHERE user_id=? AND profile_id=?', (user_id, profile_id), ).fetchone() return int((row or {}).get('count') or 0) def _excluded_hashes(profile_id: int, user_id: int) -> set[str]: return {r['torrent_hash'] for r in list_exclusions(profile_id, user_id)} def _remember_auto_label(profile_id: int, torrent_hash: str, previous_label: str) -> None: now = utcnow() with connect() as conn: row = conn.execute( 'SELECT previous_label FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash), ).fetchone() if row: conn.execute( 'UPDATE smart_queue_auto_labels SET updated_at=? WHERE profile_id=? AND torrent_hash=?', (now, profile_id, torrent_hash), ) else: conn.execute( 'INSERT INTO smart_queue_auto_labels(profile_id,torrent_hash,previous_label,created_at,updated_at) VALUES(?,?,?,?,?)', (profile_id, torrent_hash, previous_label, now, now), ) def _restore_auto_label(client: Any, profile_id: int, torrent_hash: str, current_label: str | None = None) -> bool: with connect() as conn: row = conn.execute( 'SELECT previous_label FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash), ).fetchone() if not row: return False previous = row.get('previous_label') or '' try: if current_label is None or current_label == SMART_QUEUE_LABEL: client.call('d.custom1.set', torrent_hash, previous) conn.execute('DELETE FROM smart_queue_auto_labels WHERE profile_id=? AND torrent_hash=?', (profile_id, torrent_hash)) return True except Exception: return False def _set_smart_queue_label(client: Any, torrent_hash: str, attempts: int = 3) -> bool: for attempt in range(max(1, attempts)): try: client.call('d.custom1.set', torrent_hash, SMART_QUEUE_LABEL) return True except Exception: if attempt < attempts - 1: time.sleep(0.05) return False def _mark_auto_paused(client: Any, profile_id: int, torrent: dict[str, Any]) -> bool: torrent_hash = str(torrent.get('hash') or '') if not torrent_hash: return False previous = str(torrent.get('label') or '') if previous != SMART_QUEUE_LABEL: _remember_auto_label(profile_id, torrent_hash, previous) return _set_smart_queue_label(client, torrent_hash) def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, Any]], keep_hashes: set[str]) -> list[str]: by_hash = {str(t.get('hash') or ''): t for t in torrents} restored: list[str] = [] with connect() as conn: rows = conn.execute('SELECT torrent_hash FROM smart_queue_auto_labels WHERE profile_id=?', (profile_id,)).fetchall() for row in rows: h = str(row.get('torrent_hash') or '') t = by_hash.get(h) if not h or h in keep_hashes: continue if t is None or int(t.get('complete') or 0): if _restore_auto_label(client, profile_id, h, None if t is None else str(t.get('label') or '')): restored.append(h) continue is_paused_or_stopped = bool(t.get('paused')) or not int(t.get('active') or 0) or not int(t.get('state') or 0) current_label = str(t.get('label') or '') if is_paused_or_stopped: if current_label != SMART_QUEUE_LABEL: _set_smart_queue_label(client, h) continue if _restore_auto_label(client, profile_id, h, current_label): restored.append(h) return restored 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: return {'ok': False, 'error': 'No active rTorrent profile'} user_id = user_id or default_user_id() profile_id = int(profile['id']) settings = get_settings(profile_id, user_id) if not force and not int(settings.get('enabled') or 0): add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False}, user_id) return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'message': 'Smart Queue disabled'} torrents = rtorrent.list_torrents(profile) excluded = _excluded_hashes(profile_id, user_id) downloading = [t for t in torrents if not int(t.get('complete') or 0) and int(t.get('state') or 0) and not t.get('paused') and t.get('hash') not in excluded] stopped = [t for t in torrents if not int(t.get('complete') or 0) and (not int(t.get('state') or 0) or t.get('paused')) and t.get('hash') not in excluded] min_speed = int(settings.get('min_speed_bytes') or 0) min_seeds = int(settings.get('min_seeds') or 0) stalled_seconds = int(settings.get('stalled_seconds') or 300) now = utcnow() now_ts = datetime.now(timezone.utc).timestamp() stalled: list[dict[str, Any]] = [] with connect() as conn: for t in downloading: is_stalled = int(t.get('down_rate') or 0) <= min_speed and int(t.get('seeds') or 0) <= min_seeds h = t.get('hash') if not h: continue if is_stalled: row = conn.execute('SELECT first_stalled_at FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)).fetchone() if row: conn.execute('UPDATE smart_queue_stalled SET updated_at=? WHERE profile_id=? AND torrent_hash=?', (now, profile_id, h)) first = row['first_stalled_at'] else: first = now conn.execute('INSERT OR REPLACE INTO smart_queue_stalled(profile_id,torrent_hash,first_stalled_at,updated_at) VALUES(?,?,?,?)', (profile_id, h, first, now)) if now_ts - _ts(first) >= stalled_seconds: stalled.append(t) else: conn.execute('DELETE FROM smart_queue_stalled WHERE profile_id=? AND torrent_hash=?', (profile_id, h)) # Candidates with visible sources are preferred. Do not touch excluded torrents. 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, ) 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 # newly resumed torrents, so already-active downloads could stay above the limit. pause_rank = sorted( downloading, key=lambda t: ( 0 if str(t.get('hash') or '') in stalled_hashes else 1, int(t.get('down_rate') or 0), int(t.get('seeds') or 0), int(t.get('peers') or 0), ), ) to_pause: list[dict[str, Any]] = pause_rank[:max(0, len(downloading) - max_active)] pause_hashes = {str(t.get('hash') or '') for t in to_pause} # When the cap is not exceeded, stalled downloads can still be rotated out # one-for-one with better stopped candidates while staying within max_active. if candidates: 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)) available_slots = max(0, max_active - active_after_pause) to_resume = candidates[:available_slots] c = rtorrent.client_for(profile) paused: list[str] = [] resumed: list[str] = [] label_failed: list[str] = [] for t in to_pause: try: c.call('d.pause', t['hash']) 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_resume: try: _restore_auto_label(c, profile_id, t['hash'], str(t.get('label') or '')) c.call('d.resume', t['hash']) c.call('d.start', t['hash']) resumed.append(t['hash']) except Exception: pass restored = _cleanup_auto_labels(c, profile_id, torrents, set(paused)) add_history(profile_id, 'force_check' if force else 'auto_check', paused, resumed, len(torrents), {'excluded': len(excluded), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'labels_restored': restored, 'labels_failed': label_failed, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after': active_after_pause + len(resumed)}, user_id) return {'ok': True, 'enabled': bool(settings.get('enabled')), 'paused': paused, 'resumed': resumed, 'labels_restored': restored, 'labels_failed': label_failed, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings}