queue_stopped #3

Merged
gru merged 33 commits from queue_stopped into master 2026-05-08 23:45:33 +02:00
2 changed files with 52 additions and 37 deletions
Showing only changes of commit b5f1c26a83 - Show all commits

View File

@@ -298,6 +298,8 @@ def _start_download(client: Any, torrent: dict[str, Any]) -> dict[str, Any]:
if _is_user_paused(torrent): if _is_user_paused(torrent):
# Note: Smart Queue never unpauses user-paused torrents; it manages only stopped items. # Note: Smart Queue never unpauses user-paused torrents; it manages only stopped items.
return {'hash': h, 'ok': False, 'skipped': 'user_paused'} return {'hash': h, 'ok': False, 'skipped': 'user_paused'}
# Note: This is the same helper used by the manual Start action, so queue starts follow the UI path.
# Note: Smart Queue uses the same helper as the manual Start action, so start behavior stays identical.
return rtorrent.start_or_resume_hash(client, h) return rtorrent.start_or_resume_hash(client, h)
@@ -379,9 +381,23 @@ def _mark_auto_stopped(client: Any, profile_id: int, torrent: dict[str, Any]) ->
return _set_smart_queue_label(client, torrent_hash, previous) return _set_smart_queue_label(client, torrent_hash, previous)
def _is_started_download_slot(torrent: dict[str, Any] | None) -> bool:
"""Return True for incomplete torrents already started in rTorrent, including manual starts."""
if not torrent or int(torrent.get('complete') or 0):
return False
status = str(torrent.get('status') or '').lower()
if status == 'checking':
return False
# Note: Manual Start changes d.state first; d.is_active may stay 0 while rTorrent is queued or idle.
return bool(int(torrent.get('state') or 0) or int(torrent.get('active') or 0))
def _is_smart_queue_hold(torrent: dict[str, Any] | None, manage_stopped: bool = True) -> bool: 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): if not torrent or int(torrent.get('complete') or 0):
return False return False
if _is_started_download_slot(torrent):
# Note: A manual start can leave the Smart Queue label behind; started items are active slots, not holds.
return False
if _has_stalled_label(str(torrent.get('label') or '')): if _has_stalled_label(str(torrent.get('label') or '')):
return False return False
if _is_user_paused(torrent): if _is_user_paused(torrent):
@@ -434,16 +450,9 @@ 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: The queue limit follows the same state that a manual Start changes: d.state=1. # Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
# d.is_active only means currently active in the engine and misses started-but-idle torrents. # and those torrents still must count toward the global Smart Queue limit.
if int(t.get('complete') or 0): return _is_started_download_slot(t)
return False
if _has_smart_queue_label(str(t.get('label') or '')) or _has_stalled_label(str(t.get('label') or '')):
return False
status = str(t.get('status') or '').lower()
if status == 'checking':
return False
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:
@@ -489,27 +498,29 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'} return {'ok': True, 'enabled': False, 'paused': [], 'resumed': [], 'stopped': [], 'started': [], 'labels_restored': restored, 'message': 'Smart Queue disabled'}
torrents = rtorrent.list_torrents(profile) torrents = rtorrent.list_torrents(profile)
# Note: Torrents marked as Stalled are treated as queue-blocked even when there are no other pending downloads. # Note: Stalled labels block automatic starting only; a manually started Stalled item still counts as a running slot.
stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')} stalled_label_hashes = {str(t.get('hash') or '') for t in torrents if _has_stalled_label(str(t.get('label') or '')) and t.get('hash')}
excluded = _excluded_hashes(profile_id, user_id) | stalled_label_hashes user_excluded = _excluded_hashes(profile_id, user_id)
manage_stopped = True manage_stopped = True
def is_managed_hold(t: dict[str, Any]) -> bool:
return _has_smart_queue_label(str(t.get('label') or ''))
# Note: Count Smart Queue slots by d.state because this is what manual Start changes in rTorrent. # Note: Count every started incomplete torrent, including items started manually and items with old Smart Queue labels.
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 str(t.get('hash') or '') not in user_excluded
and t.get('hash') not in excluded
] ]
# Note: Waiting candidates are only stopped items; already-started/manual-started items are handled above. # Note: Waiting candidates are stopped queue holds only; Stalled labels are not auto-started again.
stopped = [ stopped = [
t for t in torrents t for t in torrents
if t.get('hash') not in excluded if str(t.get('hash') or '') not in user_excluded
and str(t.get('hash') or '') not in stalled_label_hashes
and _is_waiting_download_candidate(t, manage_stopped) and _is_waiting_download_candidate(t, manage_stopped)
and not _is_running_download_slot(t) and not _is_running_download_slot(t)
] ]
manual_labeled_running = [
str(t.get('hash') or '') for t in downloading
if str(t.get('hash') or '') and _has_smart_queue_label(str(t.get('label') or ''))
]
min_speed = int(settings.get('min_speed_bytes') or 0) min_speed = int(settings.get('min_speed_bytes') or 0)
min_seeds = int(settings.get('min_seeds') or 0) min_seeds = int(settings.get('min_seeds') or 0)
min_peers = int(settings.get('min_peers') or 0) min_peers = int(settings.get('min_peers') or 0)
@@ -575,30 +586,24 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
to_stop.append(t) to_stop.append(t)
stop_hashes.add(h) stop_hashes.add(h)
active_after_stop = max(0, len(downloading) - len(to_stop))
# Note: After hard-cap enforcement, new starts use only real free slots.
available_slots = max(0, max_active - active_after_stop)
to_start = candidates[:available_slots]
# Note: Items outside the current start batch are explicitly marked as pending Smart Queue items.
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)
stopped_by_queue: list[str] = [] stopped_by_queue: list[str] = []
started_by_queue: list[str] = [] started_by_queue: list[str] = []
label_failed: list[str] = [] label_failed: list[str] = []
stalled_labeled: list[str] = [] stalled_labeled: list[str] = []
stop_failed: list[dict[str, str]] = []
start_failed: list[dict[str, str]] = [] start_failed: list[dict[str, str]] = []
start_no_effect: list[dict[str, Any]] = [] start_no_effect: list[dict[str, Any]] = []
start_requested: list[str] = [] start_requested: list[str] = []
start_results: list[dict[str, Any]] = [] start_results: list[dict[str, Any]] = []
for t in to_stop: for t in to_stop:
h = str(t.get('hash') or '')
try: try:
h = str(t.get('hash') or '') # Note: Smart Queue stops with the same low-level d.stop command used by the manual Stop action.
stop_result = rtorrent.stop_hash(c, h) # This avoids extra pre-check RPCs and keeps large queues from failing after only a few items.
if not stop_result.get('ok'): c.call('d.stop', h)
raise RuntimeError(stop_result.get('error') or 'stop failed')
if h in stalled_hashes: if h in stalled_hashes:
if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))): if _ensure_stalled_label(c, h, _read_label(c, h, str(t.get('label') or ''))):
stalled_labeled.append(h) stalled_labeled.append(h)
@@ -607,8 +612,16 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
elif not _mark_auto_stopped(c, profile_id, t): elif not _mark_auto_stopped(c, profile_id, t):
label_failed.append(h) label_failed.append(h)
stopped_by_queue.append(h) stopped_by_queue.append(h)
except Exception: except Exception as exc:
pass # Note: Stop failures are stored in history instead of being swallowed, so queue drift is visible.
stop_failed.append({'hash': h, 'error': str(exc)})
active_after_stop = max(0, len(downloading) - len(stopped_by_queue))
# Note: Starts are planned only after confirmed stops, so failed stops cannot push the queue above the cap.
available_slots = max(0, max_active - active_after_stop)
to_start = candidates[:available_slots]
# Note: Items outside the current start batch are explicitly marked as pending Smart Queue items.
to_label_waiting = candidates[available_slots:]
for t in to_label_waiting: for t in to_label_waiting:
h = str(t.get('hash') or '') h = str(t.get('hash') or '')
@@ -644,6 +657,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': 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} details = {'excluded': len(user_excluded), 'excluded_stalled': len(stalled_label_hashes), 'manual_labeled_running': len(manual_labeled_running), 'manual_labeled_running_hashes': manual_labeled_running[:100], '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, 'stop_failed': stop_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_stop': active_after_stop, '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': 0, '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), 'manual_labeled_running': len(manual_labeled_running), 'labels_restored': restored, 'labels_failed': label_failed, 'stop_failed': stop_failed, 'start_failed': start_failed, 'start_no_effect': start_no_effect, 'active_verified': active_verified, 'active_before': len(downloading), 'active_after_stop': active_after_stop, 'over_limit': over_limit, 'stop_eligible': len(stop_eligible), 'healthy_active_protected': 0, 'rtorrent_cap': rtorrent_cap, 'checked': len(torrents), 'excluded': len(user_excluded), 'settings': settings}

File diff suppressed because one or more lines are too long