This commit is contained in:
Mateusz Gruszczyński
2026-05-08 08:08:23 +02:00
parent 7fc505cfac
commit 3fae7550cc

View File

@@ -302,7 +302,7 @@ def _start_download(client: Any, torrent: dict[str, Any]) -> dict[str, Any]:
def _verify_started_downloads(client: Any, hashes: list[str], attempts: int = 10, delay: float = 0.5) -> tuple[list[str], list[dict[str, Any]]]: 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.""" """Verify starts after rTorrent has time to process manual-equivalent start commands."""
pending = [h for h in hashes if h] pending = [h for h in hashes if h]
started: list[str] = [] started: list[str] = []
no_effect: list[dict[str, Any]] = [] no_effect: list[dict[str, Any]] = []
@@ -341,8 +341,9 @@ 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: Smart Queue starts only stopped torrents; a slot counts only after d.is_active=1. # Note: Manual Start in rTorrent is successful when d.state becomes 1.
result['started'] = bool(int(result.get('active') or 0)) # d.is_active can stay 0 for queued/idle downloads, so it must not be used as the only success check.
result['started'] = bool(int(result.get('state') or 0) or int(result.get('active') or 0))
return result return result
@@ -433,20 +434,21 @@ def _cleanup_auto_labels(client: Any, profile_id: int, torrents: list[dict[str,
def _is_running_download_slot(t: dict[str, Any]) -> bool: def _is_running_download_slot(t: dict[str, Any]) -> bool:
"""Return True for incomplete torrents that already occupy a Smart Queue slot.""" """Return True for incomplete torrents that already occupy a Smart Queue slot."""
# Note: Target active downloads counts the whole live queue, including torrents manually started by the user. # Note: The queue limit follows the same state that a manual Start changes: d.state=1.
# d.is_active only means currently active in the engine and misses started-but-idle torrents.
if int(t.get('complete') or 0): if int(t.get('complete') or 0):
return False return False
if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_label(str(t.get('label') or '')): if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_label(str(t.get('label') or '')):
return False return False
status = str(t.get('status') or '').lower() status = str(t.get('status') or '').lower()
if status == 'checking' or _is_user_paused(t): if status == 'checking':
return False return False
return bool(int(t.get('active') or 0)) return bool(int(t.get('state') or 0) or int(t.get('active') or 0))
def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int) -> bool: def _is_low_activity_download(t: dict[str, Any], min_speed: int, min_seeds: int, min_peers: int) -> bool:
"""Return True when an active torrent may be stopped to enforce Smart Queue limits.""" """Return True when a started torrent is weak and should be stopped first."""
# Note: Manual starts are managed, but healthy downloads are not stopped only because the queue is above the limit. # Note: These settings define stop priority; the hard queue cap still applies to the full started queue.
low_speed = int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0)) low_speed = int(t.get('down_rate') or 0) <= max(0, int(min_speed or 0))
low_seeds = int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0)) low_seeds = int(t.get('seeds') or 0) <= max(0, int(min_seeds or 0))
low_peers = int(t.get('peers') or 0) <= max(0, int(min_peers or 0)) if min_peers > 0 else False low_peers = int(t.get('peers') or 0) <= max(0, int(min_peers or 0)) if min_peers > 0 else False
@@ -494,15 +496,14 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
def is_managed_hold(t: dict[str, Any]) -> bool: def is_managed_hold(t: dict[str, Any]) -> bool:
return _has_smart_queue_label(str(t.get('label') or '')) return _has_smart_queue_label(str(t.get('label') or ''))
# Note: Count Smart Queue slots by d.is_active because Paused can have state=1/open=1 and must not occupy the limit. # Note: Count Smart Queue slots by d.state because this is what manual Start changes in rTorrent.
downloading = [ downloading = [
t for t in torrents t for t in torrents
if _is_running_download_slot(t) if _is_running_download_slot(t)
and not is_managed_hold(t) and not is_managed_hold(t)
and t.get('hash') not in excluded and t.get('hash') not in excluded
] ]
# Note: Candidates also include regular Paused items without a label. Otherwise the queue sees only one or two items # Note: Waiting candidates are only stopped items; already-started/manual-started items are handled above.
# and cannot fill the configured target of 100.
stopped = [ stopped = [
t for t in torrents t for t in torrents
if t.get('hash') not in excluded if t.get('hash') not in excluded
@@ -550,13 +551,15 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
max_active = max(1, int(settings.get('max_active_downloads') or 5)) max_active = max(1, int(settings.get('max_active_downloads') or 5))
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 across the whole active queue, including manual starts. # Enforce the hard active-download cap across the whole started queue, including manual starts.
# Note: Only low-activity/no-source downloads are eligible, so Smart Queue does not punish healthy manual starts. # Note: Weak/no-source torrents are stopped first, but the cap is still enforced when the overflow is larger.
over_limit = max(0, len(downloading) - max_active) over_limit = max(0, len(downloading) - max_active)
stop_eligible_hashes = {str(t.get('hash') or '') for t in stop_eligible}
stop_rank = sorted( stop_rank = sorted(
stop_eligible, downloading,
key=lambda t: ( key=lambda t: (
0 if str(t.get('hash') or '') in stalled_hashes else 1, 0 if str(t.get('hash') or '') in stalled_hashes else 1,
0 if str(t.get('hash') or '') in stop_eligible_hashes else 1,
int(t.get('down_rate') or 0), int(t.get('down_rate') or 0),
int(t.get('seeds') or 0), int(t.get('seeds') or 0),
int(t.get('peers') or 0), int(t.get('peers') or 0),
@@ -573,7 +576,7 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
stop_hashes.add(h) stop_hashes.add(h)
active_after_stop = max(0, len(downloading) - len(to_stop)) active_after_stop = max(0, len(downloading) - len(to_stop))
# Note: When healthy manual starts keep the queue above the limit, Smart Queue waits instead of starting more. # Note: After hard-cap enforcement, new starts use only real free slots.
available_slots = max(0, max_active - active_after_stop) available_slots = max(0, max_active - active_after_stop)
to_start = candidates[:available_slots] to_start = candidates[:available_slots]
# Note: Items outside the current start batch are explicitly marked as pending Smart Queue items. # Note: Items outside the current start batch are explicitly marked as pending Smart Queue items.
@@ -641,6 +644,6 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
| {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(started_by_queue)} | {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(started_by_queue)}
) )
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), 'excluded_stalled': len(stalled_label_hashes), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'start_requested': start_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': True, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_stop + len(started_by_queue), 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': max(0, over_limit - len(to_stop)), 'stopped_planned': len(to_stop), 'started_planned': len(to_start), 'paused_planned': len(to_stop), 'resumed_planned': len(to_start), 'rtorrent_cap': rtorrent_cap} details = {'excluded': len(excluded), 'excluded_stalled': len(stalled_label_hashes), 'enabled': bool(settings.get('enabled')), 'auto_label': SMART_QUEUE_LABEL, 'stalled_label': SMART_QUEUE_STALLED_LABEL, 'stalled_labeled': stalled_labeled, 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'start_results': start_results, 'start_requested': start_requested, 'active_verified': active_verified, 'waiting_labeled': len(to_label_waiting), 'manage_stopped': True, 'max_active_downloads': max_active, 'active_before': len(downloading), 'active_after_expected': active_after_stop + len(started_by_queue), 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': 0, 'stopped_planned': len(to_stop), 'started_planned': len(to_start), 'paused_planned': len(to_stop), 'resumed_planned': len(to_start), 'rtorrent_cap': rtorrent_cap}
add_history(profile_id, 'force_check' if force else 'auto_check', stopped_by_queue, started_by_queue, len(torrents), {**details, 'stopped': stopped_by_queue, 'started': started_by_queue}, user_id) add_history(profile_id, 'force_check' if force else 'auto_check', stopped_by_queue, started_by_queue, len(torrents), {**details, 'stopped': stopped_by_queue, 'started': started_by_queue}, user_id)
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, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': max(0, over_limit - len(to_stop)), 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': 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, 'waiting_labeled': len(to_label_waiting), 'stalled_labeled': stalled_labeled, 'excluded_stalled': len(stalled_label_hashes), 'labels_restored': restored, 'labels_failed': label_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(excluded), 'settings': settings}