status paused

This commit is contained in:
Mateusz Gruszczyński
2026-06-15 11:00:20 +02:00
parent f173cc0a62
commit 337259a099
15 changed files with 119 additions and 55 deletions
+1 -1
View File
@@ -354,7 +354,7 @@ def _active_downloading_hashes(profile: dict) -> list[str]:
for row in rows:
if int(row.get("complete") or 0):
continue
if int(row.get("state") or 0) and not row.get("paused"):
if int(row.get("state") or 0) and not row.get("paused") and str(row.get("status") or "") != "Queued":
h = str(row.get("hash") or "")
if h:
hashes.append(h)
+1 -1
View File
@@ -448,7 +448,7 @@ def save_profile_preferences(user_id: int, profile_id: int | None, data: dict) -
value = str(data.get("active_filter") or "all").strip()
if not value or len(value) > 180:
value = "all"
allowed_static_filters = {"all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
allowed_static_filters = {"all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped", "moving"}
if value not in allowed_static_filters and not value.startswith("label:") and not value.startswith("tracker:"):
value = "all"
updates["active_filter"] = value
+1
View File
@@ -95,6 +95,7 @@ _REMOTE_USAGE_CACHE: dict[int, tuple[float, dict]] = {}
_REMOTE_USAGE_TTL_SECONDS = 60.0
_REMOTE_PUBLIC_IP_CACHE: dict[int, tuple[float, str]] = {}
_REMOTE_PUBLIC_IP_TTL_SECONDS = 6 * 60 * 60.0
PY_MANUAL_PAUSE_FIELD = "py_manual_pause"
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
_POST_CHECK_WATCH_MIN_SECONDS = 2.0
+99 -41
View File
@@ -212,7 +212,7 @@ TORRENT_FIELDS = [
"d.hash=", "d.name=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=", "d.peers_connected=",
"d.peers_complete=", "d.priority=", "d.directory=", "d.base_path=", "d.creation_date=", "d.custom1=",
"d.custom=py_ratio_group", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
"d.custom=py_ratio_group", f"d.custom={PY_MANUAL_PAUSE_FIELD}", "d.message=", "d.hashing=", "d.is_active=", "d.is_open=", "d.is_multi_file=",
]
TORRENT_OPTIONAL_FIELDS = [
@@ -224,7 +224,7 @@ LIVE_TORRENT_FIELDS = [
"d.hash=", "d.state=", "d.complete=", "d.size_bytes=", "d.completed_bytes=",
"d.ratio=", "d.up.rate=", "d.down.rate=", "d.up.total=", "d.down.total=",
"d.peers_connected=", "d.peers_complete=", "d.message=", "d.hashing=", "d.is_active=",
"d.is_open=", "d.custom1=",
"d.is_open=", "d.custom1=", f"d.custom={PY_MANUAL_PAUSE_FIELD}",
]
@@ -256,15 +256,8 @@ def normalize_row(row: list) -> dict:
base_path = str(row[15] or "")
state = int(row[2] or 0)
complete = int(row[3] or 0)
is_active = int(row[21] or 0) if len(row) > 21 else int(state)
is_open = int(row[22] or 0) if len(row) > 22 else int(is_active or state)
is_multi_file = int(row[23] or 0) if len(row) > 23 else 0
# Note: Last activity is optional because older rTorrent builds may not expose this timestamp.
last_activity = int(row[24] or 0) if len(row) > 24 else 0
if not last_activity and (down_rate > 0 or up_rate > 0):
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
last_activity = int(time.time())
completed_at = int(row[25] or 0) if len(row) > 25 else 0
# Note: is_multi_file is needed before status calculation because the display path hides the torrent root for multi-file payloads.
is_multi_file = int(row[24] or 0) if len(row) > 24 else 0
# Show the selected download location only. Hide the torrent root
# directory for multi-file torrents and the filename for single-file
@@ -279,17 +272,26 @@ def normalize_row(row: list) -> dict:
display_path = directory.rstrip("/") + "/" if directory != "/" else directory
else:
display_path = ""
msg = str(row[19] or "")
manual_pause = str(row[19] or "").strip() == "1"
msg = str(row[20] or "")
msg_l = msg.lower()
hashing = int(row[20] or 0) if len(row) > 20 else 0
hashing = int(row[21] or 0) if len(row) > 21 else 0
is_active = int(row[22] or 0) if len(row) > 22 else int(state)
is_open = int(row[23] or 0) if len(row) > 23 else int(is_active or state)
last_activity = int(row[25] or 0) if len(row) > 25 else 0
if not last_activity and (down_rate > 0 or up_rate > 0):
# Note: rTorrent builds without d.timestamp.last_active still expose live rates, so active rows get a safe current timestamp.
last_activity = int(time.time())
completed_at = int(row[26] or 0) if len(row) > 26 else 0
# Note: d.hashing is authoritative; stale "hash check complete" messages must not keep the UI in Checking forever.
is_checking = bool(hashing) or _message_indicates_active_check(msg_l)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(str(row[17] or "")) and not is_checking and not bool(is_active)
# Note: rTorrent's visible pause shape is state=1, open=1 and active=0.
# Manual Start handles this shape with a stop/start cycle because d.resume may leave it stuck.
is_paused = bool(state) and bool(is_open) and not bool(is_active) and not is_checking and not post_check
# Note: Post-check is an application-level state that separates torrents waiting after a recheck from manually stopped torrents.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
# Note: rTorrent exposes queued/inactive torrents with the same runtime flags that older UI code called paused.
# The app marks only explicit user Pause requests with py_manual_pause so queued rows stay separate.
is_paused = manual_pause and not is_checking and not post_check
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
# Note: Post-check and Queued are application-level UI statuses; rTorrent itself mainly exposes flags.
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
to_download_bytes = remaining_bytes if not complete else 0
# Note: The To download column is only meaningful for incomplete torrents; complete rows expose an empty display value.
return {
@@ -299,6 +301,7 @@ def normalize_row(row: list) -> dict:
"active": is_active,
"open": is_open,
"paused": is_paused,
"queued": is_queued,
"complete": complete,
"size": size,
"size_h": human_size(size),
@@ -350,11 +353,13 @@ def normalize_live_row(row: list) -> dict:
is_active = int(row[14] or 0)
is_open = int(row[15] or 0) if len(row) > 15 else int(is_active or state)
labels = str(row[16] or "") if len(row) > 16 else ""
manual_pause = str(row[17] or "").strip() == "1" if len(row) > 17 else False
is_checking = bool(hashing) or _message_indicates_active_check(msg.lower())
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(labels) and not is_checking and not bool(is_active)
# Note: Live patches keep the same pause classification as the full torrent snapshot.
is_paused = bool(state) and bool(is_open) and not bool(is_active) and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
# Note: Live patches keep Queued separate from explicit user Paused using the same app marker as full snapshots.
is_paused = manual_pause and not is_checking and not post_check
is_queued = bool(state) and bool(is_open) and not bool(is_active) and not bool(complete) and not is_paused and not is_checking and not post_check
status = "Checking" if is_checking else "Post-check" if post_check else "Paused" if is_paused else "Queued" if is_queued else "Seeding" if complete and state else "Downloading" if state else "Stopped"
progress = 100.0 if size <= 0 and complete else round((completed / size) * 100, 2) if size else 0.0
to_download_bytes = remaining_bytes if not complete else 0
return {
@@ -363,6 +368,7 @@ def normalize_live_row(row: list) -> dict:
"active": is_active,
"open": is_open,
"paused": is_paused,
"queued": is_queued,
"complete": complete,
"completed_bytes": completed,
"progress": progress,
@@ -625,47 +631,77 @@ def _str_rpc(c: ScgiRtorrentClient, method: str, h: str, default: str = '') -> s
return default
def _set_manual_pause(c: ScgiRtorrentClient, torrent_hash: str, enabled: bool) -> None:
"""Persist the user Pause intent without touching the visible label field."""
# Note: rTorrent has no reliable queued-vs-user-paused flag, so pyTorrent stores that intent in d.custom.
c.call('d.custom.set', str(torrent_hash or ''), PY_MANUAL_PAUSE_FIELD, '1' if enabled else '')
def _manual_pause_enabled(c: ScgiRtorrentClient, torrent_hash: str) -> bool:
h = str(torrent_hash or '')
for method, args in (
(f'd.custom={PY_MANUAL_PAUSE_FIELD}', (h,)),
('d.custom', (h, PY_MANUAL_PAUSE_FIELD)),
):
try:
if str(c.call(method, *args) or '').strip() == '1':
return True
except Exception:
continue
return False
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: In rTorrent, pause does not change d.state. Paused means state=1, open=1, active=0.
label = _str_rpc(c, 'd.custom1', h)
manual_pause = _manual_pause_enabled(c, h)
post_check = POST_CHECK_DOWNLOAD_LABEL in _label_names(label) and not bool(active)
paused = bool(manual_pause and not post_check)
queued = bool(state and opened and not active and not paused and not post_check)
return {
'state': state,
'open': opened,
'active': active,
'paused': bool(state and opened and not active and not post_check),
'paused': paused,
'queued': queued,
'stopped': not bool(state),
'post_check': post_check,
'label': label,
'manual_pause': manual_pause,
'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."""
"""Mark a torrent as user-paused and ask rTorrent to pause it."""
h = str(torrent_hash or '')
if not h:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []}
try:
_set_manual_pause(c, h, True)
result['commands'].append('set_py_manual_pause')
if before.get('stopped'):
# Note: rTorrent does not turn a stopped item into a paused one with d.pause alone.
# First move it out of STOP, then pause it, which matches the expected START -> PAUSE flow.
# Note: A stopped torrent has no native paused flag; opening it first lets the UI and later Resume follow the same path.
try:
c.call('d.open', h)
result['commands'].append('d.open')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
c.call('d.start', h)
result['commands'].append('d.start')
# Note: Smart Queue frees a slot with d.pause, not d.stop, so later d.resume behaves like ruTorrent.
c.call('d.pause', h)
result['commands'].append('d.pause')
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('d.pause', h)
result['commands'].append('d.pause')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.pause: {exc}')
result['after'] = _download_runtime_state(c, h)
result['ok'] = True
except Exception as exc:
@@ -681,9 +717,16 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
before = _download_runtime_state(c, h)
result = {'hash': h, 'before': before, 'commands': []}
if before.get('stopped') and not before.get('post_check'):
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
before = _download_runtime_state(c, h)
result.update({'ok': True, 'skipped': 'already_stopped', 'after': before})
return result
try:
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
# Note: User Stop converts the app-level Post-check state into a regular stopped torrent.
if before.get('post_check'):
clear_post_check_download_label(c, h, before.get('label'))
@@ -699,23 +742,34 @@ def stop_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict:
"""Resume only a paused rTorrent item using native rTorrent resume semantics."""
"""Resume a user-paused torrent and clear pyTorrent's pause marker."""
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'):
if before.get('active') and not before.get('manual_pause'):
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
try:
# Note: ruTorrent unpauses with the equivalent of d.resume. Do not add d.start/d.open,
# because those commands belong to Stopped/Open state, not a clean Paused state.
c.call('d.resume', h)
result['commands'].append('d.resume')
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
try:
c.call('d.resume', h)
result['commands'].append('d.resume')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.resume: {exc}')
try:
c.call('d.open', h)
result['commands'].append('d.open')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.open: {exc}')
try:
c.call('d.start', h)
result['commands'].append('d.start')
except Exception as exc:
result.setdefault('ignored_errors', []).append(f'd.start: {exc}')
result['after'] = _download_runtime_state(c, h)
result['ok'] = True
except Exception as exc:
@@ -735,6 +789,10 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
return {'hash': h, 'ok': False, 'error': 'missing hash'}
before = _download_runtime_state(c, h)
result: dict = {'hash': h, 'before': before, 'commands': []}
if before.get('manual_pause'):
_set_manual_pause(c, h, False)
result['commands'].append('clear_py_manual_pause')
before = _download_runtime_state(c, h)
if before.get('active'):
if before.get('post_check'):
@@ -743,7 +801,7 @@ def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start:
result.update({'ok': True, 'skipped': 'already_active', 'after': before})
return result
if (before.get('paused') and not prefer_start) or before.get('post_check'):
if (before.get('paused') and not prefer_start) or before.get('queued') or before.get('post_check'):
try:
# Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck.
c.call('d.stop', h)
+1 -1
View File
@@ -834,7 +834,7 @@ def _is_running_download_slot(t: dict[str, Any]) -> bool:
"""Return True for incomplete torrents that already occupy a Smart Queue slot."""
# Note: Do not exclude Smart Queue/Stalled labels here. Manual Start can leave old labels,
# and those torrents still must count toward the global Smart Queue limit.
return _is_started_download_slot(t)
return _is_started_download_slot(t) and not _is_user_paused(t)
def _has_recent_transfer_activity(t: dict[str, Any], stalled_seconds: int) -> bool:
+4 -2
View File
@@ -19,7 +19,7 @@ _ERROR_PATTERNS = (
"unreachable",
"denied",
)
_SUMMARY_TYPES = ("all", "downloading", "seeding", "paused", "checking", "error", "post_check", "stopped")
_SUMMARY_TYPES = ("all", "downloading", "queued", "seeding", "paused", "checking", "error", "post_check", "stopped")
_summary_cache: dict[int, dict] = {}
_summary_lock = RLock()
@@ -46,7 +46,9 @@ def _matches(row: dict, summary_type: str) -> bool:
if summary_type == "all":
return True
if summary_type == "downloading":
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
return not checking and not bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused")) and str(row.get("status") or "") != "Queued"
if summary_type == "queued":
return not checking and (bool(row.get("queued")) or str(row.get("status") or "") == "Queued")
if summary_type == "seeding":
return not checking and bool(row.get("complete")) and bool(row.get("state")) and not bool(row.get("paused"))
if summary_type == "paused":