609 lines
28 KiB
Python
609 lines
28 KiB
Python
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,
|
|
'manage_stopped': 0,
|
|
'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)),
|
|
# Note: Switch chroni całkiem zatrzymane torrenty przed automatycznym startem; domyślnie Smart Queue zarządza tylko paused.
|
|
'manage_stopped': 1 if data.get('manage_stopped', current.get('manage_stopped')) else 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,manage_stopped,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,
|
|
manage_stopped=excluded.manage_stopped,
|
|
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'], settings['manage_stopped'], 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 _read_label(client: Any, torrent_hash: str, fallback: str = '') -> str:
|
|
try:
|
|
return str(client.call('d.custom1', torrent_hash) or '')
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
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()
|
|
live_label = _read_label(client, torrent_hash, current_label or '')
|
|
if not row:
|
|
if live_label != SMART_QUEUE_LABEL:
|
|
return False
|
|
try:
|
|
# Note: Czyści label Smart Queue także wtedy, gdy torrent został oznaczony wcześniej, ale nie ma już wpisu z poprzednim labelem.
|
|
client.call('d.custom1.set', torrent_hash, '')
|
|
return True
|
|
except Exception:
|
|
return False
|
|
previous = row.get('previous_label') or ''
|
|
try:
|
|
# Note: Przy wznowieniu Smart Queue oddaje poprzedni label tylko wtedy, gdy nadal widzi swój label techniczny.
|
|
if live_label == SMART_QUEUE_LABEL or current_label is None:
|
|
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 _call_rtorrent_setter(client: Any, method: str, value: int) -> bool:
|
|
"""Set a scalar rTorrent setting while tolerating XMLRPC signature differences."""
|
|
for args in ((int(value),), ('', int(value))):
|
|
try:
|
|
client.call(method, *args)
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _ensure_rtorrent_download_cap(client: Any, max_active: int) -> dict[str, Any]:
|
|
"""Raise rTorrent download caps that can silently limit Smart Queue to one item."""
|
|
result: dict[str, Any] = {'checked': False, 'updated': False, 'items': []}
|
|
# Note: rTorrent może mieć osobny limit globalny i per-throttle. Gdy div=1,
|
|
# startowanie kończy się praktycznie jednym aktywnym torrentem mimo targetu 100.
|
|
for key in ('throttle.max_downloads.global', 'throttle.max_downloads.div'):
|
|
item: dict[str, Any] = {'key': key, 'checked': False, 'updated': False}
|
|
try:
|
|
current = int(client.call(key) or 0)
|
|
item.update({'checked': True, 'current': current, 'target': int(max_active)})
|
|
result['checked'] = True
|
|
# Note: 0 oznacza unlimited; podnosimy tylko dodatnie limity niższe od targetu.
|
|
if 0 < current < max_active:
|
|
ok = _call_rtorrent_setter(client, f'{key}.set', int(max_active))
|
|
item['updated'] = ok
|
|
if ok:
|
|
result['updated'] = True
|
|
item['new'] = int(max_active)
|
|
result.setdefault('current', current)
|
|
result['new'] = int(max_active)
|
|
except Exception as exc:
|
|
item.update({'error': str(exc)})
|
|
result['items'].append(item)
|
|
return result
|
|
|
|
|
|
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."""
|
|
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.
|
|
return rtorrent.start_or_resume_hash(client, h)
|
|
|
|
|
|
def _verify_started_downloads(client: Any, hashes: list[str], attempts: int = 10, delay: float = 0.5) -> tuple[list[str], list[dict[str, Any]]]:
|
|
"""Verify starts after rTorrent has time to process resume/start commands."""
|
|
pending = [h for h in hashes if h]
|
|
started: list[str] = []
|
|
no_effect: list[dict[str, Any]] = []
|
|
seen_started: set[str] = set()
|
|
last_state: dict[str, dict[str, Any]] = {}
|
|
|
|
for attempt in range(max(1, attempts)):
|
|
if attempt:
|
|
time.sleep(delay)
|
|
for h in list(pending):
|
|
live = _read_live_start_state(client, h)
|
|
last_state[h] = live
|
|
if live.get('started'):
|
|
seen_started.add(h)
|
|
pending.remove(h)
|
|
if not pending:
|
|
break
|
|
|
|
started = [h for h in hashes if h in seen_started]
|
|
no_effect = [last_state.get(h, {'hash': h, 'started': False}) for h in hashes if h and h not in seen_started]
|
|
return started, no_effect
|
|
|
|
def _read_live_start_state(client: Any, torrent_hash: str) -> dict[str, Any]:
|
|
result: dict[str, Any] = {'hash': torrent_hash}
|
|
fields = (
|
|
('state', 'd.state'),
|
|
('active', 'd.is_active'),
|
|
('open', 'd.is_open'),
|
|
('priority', 'd.priority'),
|
|
('message', 'd.message'),
|
|
('label', 'd.custom1'),
|
|
)
|
|
for key, method in fields:
|
|
try:
|
|
value = client.call(method, torrent_hash)
|
|
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.
|
|
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:
|
|
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 _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool:
|
|
if not torrent or int(torrent.get('complete') or 0):
|
|
return False
|
|
if str(torrent.get('label') or '') == SMART_QUEUE_LABEL:
|
|
return True
|
|
# Note: Paused w rTorrent zwykle ma state=1 i active=0, więc nie wolno wymagać state=0.
|
|
# Dzięki temu Smart Queue widzi pauzowane torrenty jako oczekujące i może później dobić target kolejki.
|
|
if bool(torrent.get('paused')):
|
|
return True
|
|
# Note: Całkiem zatrzymane pozycje są zarządzane tylko po włączeniu opcji Use stopped torrents.
|
|
if not manage_stopped:
|
|
return False
|
|
return not int(torrent.get('state') or 0)
|
|
|
|
|
|
def _clear_untracked_smart_queue_label(client: Any, torrent_hash: str, current_label: str) -> bool:
|
|
if current_label != SMART_QUEUE_LABEL:
|
|
return False
|
|
try:
|
|
# Note: Czyści osierocony label Smart Queue, gdy brak wpisu z poprzednim labelem w bazie.
|
|
client.call('d.custom1.set', torrent_hash, '')
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str, Any]], keep_hashes: set[str], manage_stopped: bool = True) -> 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()
|
|
tracked_hashes = {str(row.get('torrent_hash') or '') for row in rows if row.get('torrent_hash')}
|
|
|
|
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
|
|
current_label = '' if t is None else str(t.get('label') or '')
|
|
if not _is_smart_queue_hold(t, manage_stopped):
|
|
if _restore_auto_label(client, profile_id, h, None if t is None else current_label):
|
|
restored.append(h)
|
|
continue
|
|
if current_label != SMART_QUEUE_LABEL:
|
|
_set_smart_queue_label(client, h)
|
|
|
|
for h, t in by_hash.items():
|
|
if not h or h in keep_hashes or h in tracked_hashes or _is_smart_queue_hold(t, manage_stopped):
|
|
continue
|
|
if _clear_untracked_smart_queue_label(client, h, str(t.get('label') or '')):
|
|
restored.append(h)
|
|
return restored
|
|
|
|
|
|
def _is_running_download_slot(t: dict[str, Any]) -> bool:
|
|
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
|
|
# Note: Limit Smart Queue oznacza docelową liczbę realnie aktywnych slotów.
|
|
# Paused potrafi mieć state=1/open=1, dlatego slot liczymy dopiero po d.is_active=1.
|
|
if int(t.get('complete') or 0):
|
|
return False
|
|
if str(t.get('label') or '') == SMART_QUEUE_LABEL:
|
|
return False
|
|
status = str(t.get('status') or '').lower()
|
|
if status == 'checking' or status == 'paused' or bool(t.get('paused')):
|
|
return False
|
|
return bool(int(t.get('active') or 0))
|
|
|
|
|
|
def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> bool:
|
|
"""Return True for paused/held torrents Smart Queue may resume later."""
|
|
if int(t.get('complete') or 0):
|
|
return False
|
|
if str(t.get('label') or '') == SMART_QUEUE_LABEL:
|
|
return True
|
|
# Note: Paused jest podstawowym źródłem dobijania kolejki, niezależnie od opcji manage_stopped.
|
|
if bool(t.get('paused')) or str(t.get('status') or '').lower() == 'paused':
|
|
return True
|
|
# Note: Stopped dokładamy tylko wtedy, gdy użytkownik zaznaczył Use stopped torrents.
|
|
return bool(manage_stopped) and not int(t.get('state') or 0)
|
|
|
|
|
|
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):
|
|
restored: list[str] = []
|
|
try:
|
|
# Note: Przy wyłączonym Smart Queue sprzątamy wyłącznie techniczne labele, bez startowania lub pauzowania torrentów.
|
|
torrents = rtorrent.list_torrents(profile)
|
|
restored = _cleanup_auto_labels(rtorrent.client_for(profile), profile_id, torrents, set(), bool(settings.get('manage_stopped')))
|
|
except Exception:
|
|
restored = []
|
|
add_history(profile_id, 'skipped_disabled', [], [], 0, {'enabled': False, 'labels_restored': restored}, user_id)
|
|
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'}
|
|
|
|
torrents = rtorrent.list_torrents(profile)
|
|
excluded = _excluded_hashes(profile_id, user_id)
|
|
manage_stopped = bool(settings.get('manage_stopped'))
|
|
def is_managed_hold(t: dict[str, Any]) -> bool:
|
|
return str(t.get('label') or '') == SMART_QUEUE_LABEL
|
|
|
|
# Note: Slot Smart Queue liczymy po d.is_active, bo Paused może mieć state=1/open=1 i nie może zajmować miejsca w limicie.
|
|
downloading = [
|
|
t for t in torrents
|
|
if _is_running_download_slot(t)
|
|
and not is_managed_hold(t)
|
|
and t.get('hash') not in excluded
|
|
]
|
|
# Note: Kandydaci obejmują także zwykłe Paused bez labela. Inaczej kolejka widzi tylko 1-2 sztuki
|
|
# i nie potrafi dobić do zadanego targetu 100.
|
|
stopped = [
|
|
t for t in torrents
|
|
if t.get('hash') not in excluded
|
|
and _is_waiting_download_candidate(t, manage_stopped)
|
|
and not _is_running_download_slot(t)
|
|
]
|
|
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}
|
|
|
|
# Note: Stalled jest wymieniany nie tylko przy pelnej kolejce. Najpierw wypelniamy wolne sloty,
|
|
# a dopiero nadmiar kandydatow zuzywamy na rotacje martwych/stalled pobran.
|
|
free_slots_before_pause = max(0, max_active - max(0, len(downloading) - len(to_pause)))
|
|
stalled_rotation_slots = max(0, len(candidates) - free_slots_before_pause)
|
|
for t in stalled:
|
|
h = str(t.get('hash') or '')
|
|
if not h or h in pause_hashes or stalled_rotation_slots <= 0:
|
|
continue
|
|
to_pause.append(t)
|
|
pause_hashes.add(h)
|
|
stalled_rotation_slots -= 1
|
|
|
|
active_after_pause = max(0, len(downloading) - len(to_pause))
|
|
|
|
c = rtorrent.client_for(profile)
|
|
rtorrent_cap = _ensure_rtorrent_download_cap(c, max_active)
|
|
paused: list[str] = []
|
|
resumed: list[str] = []
|
|
label_failed: list[str] = []
|
|
start_failed: list[dict[str, str]] = []
|
|
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)
|
|
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]
|
|
active_slots = active_after_pause
|
|
max_resume_attempts = max(len(candidate_queue), max_active * 3)
|
|
|
|
# Note: Resume działa w rundach aż do pełnego limitu z ustawień. Po każdej rundzie
|
|
# pobieramy świeży snapshot z rTorrent, bo masowe d.resume/d.start nie zawsze widać
|
|
# natychmiast w d.is_active na pojedynczym RPC.
|
|
while candidate_queue and active_slots < max_active and len(attempted_hashes) < max_resume_attempts:
|
|
slots_left = max_active - active_slots
|
|
# Note: Bierzemy mały nadmiar kandydatów tylko wtedy, gdy poprzednie resume nie zwiększyło
|
|
# liczby aktywnych slotów; to naprawia przypadek, gdy część pauzowanych nie wstaje po komendzie.
|
|
batch_size = min(len(candidate_queue), max(1, slots_left))
|
|
batch = candidate_queue[:batch_size]
|
|
candidate_queue = candidate_queue[batch_size:]
|
|
batch_requested: list[str] = []
|
|
|
|
for t in batch:
|
|
h = str(t.get('hash') or '')
|
|
if not h or h in attempted_hashes:
|
|
continue
|
|
attempted_hashes.add(h)
|
|
try:
|
|
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 not batch_requested:
|
|
continue
|
|
|
|
active_verified, batch_no_effect = _verify_started_downloads(c, batch_requested)
|
|
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)
|
|
|
|
fresh_active_slots, fresh_torrents = _refresh_active_slots(profile, excluded, manage_stopped)
|
|
active_slots = max(active_slots, fresh_active_slots)
|
|
|
|
# Note: Jeżeli rTorrent wznowił torrent dopiero po odświeżeniu listy, dopisujemy go
|
|
# do resumed i zdejmujemy techniczny label Smart Queue.
|
|
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 active_slots < max_active and not candidate_queue:
|
|
# Note: Ostatnia próba dla pozycji, które przyjęły start, ale jeszcze nie pokazały active=1.
|
|
time.sleep(0.75)
|
|
fresh_active_slots, fresh_torrents = _refresh_active_slots(profile, excluded, manage_stopped)
|
|
active_slots = max(active_slots, fresh_active_slots)
|
|
|
|
resumed_set = set(resumed)
|
|
waiting_hashes = {
|
|
str(t.get('hash') or '')
|
|
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 resumed_set
|
|
}
|
|
|
|
# Note: Kazdy kandydat niewznowiony w tej rundzie zostaje oznaczony jako oczekujacy,
|
|
# dzieki czemu kolejne cykle nadal dobieraja go z pauzy/labela Smart Queue.
|
|
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
|
|
| {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 resumed_set}
|
|
)
|
|
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), '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}
|
|
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}
|