diff --git a/pytorrent/services/rtorrent/torrents.py b/pytorrent/services/rtorrent/torrents.py index 1c8d5f9..a4604de 100644 --- a/pytorrent/services/rtorrent/torrents.py +++ b/pytorrent/services/rtorrent/torrents.py @@ -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_multi_file=", + "d.custom=py_ratio_group", "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.custom1=", + "d.is_open=", "d.custom1=", ] @@ -254,13 +254,17 @@ def normalize_row(row: list) -> dict: eta_seconds = int(remaining_bytes / down_rate) if down_rate > 0 and not int(row[3] or 0) else 0 directory = str(row[14] or "") base_path = str(row[15] or "") - is_multi_file = int(row[22] or 0) if len(row) > 22 else 0 + 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[23] or 0) if len(row) > 23 else 0 + 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[24] or 0) if len(row) > 24 else 0 + completed_at = int(row[25] or 0) if len(row) > 25 else 0 # Show the selected download location only. Hide the torrent root # directory for multi-file torrents and the filename for single-file @@ -278,13 +282,12 @@ def normalize_row(row: list) -> dict: msg = str(row[19] or "") msg_l = msg.lower() hashing = int(row[20] or 0) if len(row) > 20 else 0 - is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0) - state = int(row[2] or 0) - complete = int(row[3] or 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) - is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check + # 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" to_download_bytes = remaining_bytes if not complete else 0 @@ -294,6 +297,7 @@ def normalize_row(row: list) -> dict: "name": str(row[1] or ""), "state": state, "active": is_active, + "open": is_open, "paused": is_paused, "complete": complete, "size": size, @@ -344,10 +348,12 @@ def normalize_live_row(row: list) -> dict: msg = str(row[12] or "") hashing = int(row[13] or 0) is_active = int(row[14] or 0) - labels = str(row[15] or "") + 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 "" 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) - is_paused = bool(state) and not bool(is_active) and not is_checking and not post_check + # 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" 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 @@ -355,6 +361,7 @@ def normalize_live_row(row: list) -> dict: "hash": str(row[0] or ""), "state": state, "active": is_active, + "open": is_open, "paused": is_paused, "complete": complete, "completed_bytes": completed, @@ -692,7 +699,7 @@ 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; never convert it through stop/start.""" + """Resume only a paused rTorrent item using native rTorrent resume semantics.""" h = str(torrent_hash or '') if not h: return {'hash': h, 'ok': False, 'error': 'missing hash'} @@ -717,11 +724,11 @@ def resume_paused_hash(c: ScgiRtorrentClient, torrent_hash: str) -> dict: def start_or_resume_hash(c: ScgiRtorrentClient, torrent_hash: str, prefer_start: bool = False) -> dict: - """Start stopped torrents or resume real paused torrents. + """Start stopped torrents and recover open/inactive paused torrents. - Smart Queue passes prefer_start=True for candidates that were selected as stopped. - This avoids treating rTorrent's intermediate open/inactive state after a check as - a user pause and sending only d.resume, which can leave items pending forever. + rTorrent can expose a torrent as state=1, open=1 and active=0 while d.resume/d.start + alone does not wake it up. Manual Start uses the same recovery path users already + perform by hand: d.stop followed by d.open and d.start. """ h = str(torrent_hash or '') if not h: @@ -736,17 +743,9 @@ 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 and not before.get('post_check'): - # Note: Manual Start keeps the clean pause-to-resume path. Do not classify every - # state=1/active=0 item as paused; after auto-check this can be only a transient - # open/inactive rTorrent state and needs d.open + d.start. - resumed = resume_paused_hash(c, h) - resumed['mode'] = 'resume_paused' - return resumed - - if before.get('post_check'): + if (before.get('paused') and not prefer_start) or before.get('post_check'): try: - # Note: Post-check start first forces a clean stopped state, matching the manual Stop -> Start recovery path. + # Note: Start intentionally normalizes open/inactive torrents through Stop -> Start because d.resume can leave them stuck. c.call('d.stop', h) result['commands'].append('d.stop') except Exception as exc: @@ -890,7 +889,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | mark_done(h, item, results) return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results} if name in {"resume", "unpause"}: - # Note: Resume/Unpause uses only d.resume for Paused state. + # Note: Resume/Unpause keeps native rTorrent resume semantics; Start is the recovery action for stuck open/inactive torrents. results = previous_results for h in pending_hashes(): item = resume_paused_hash(c, h) @@ -898,7 +897,7 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict | mark_done(h, item, results) return {"ok": True, "count": len(torrent_hashes), "remove_data": False, "results": results} if name == "start": - # Note: Start separates Stopped from Paused; paused items go through d.resume, stopped items through d.start. + # Note: Start recovers stuck Paused/open-inactive rows with Stop -> Start while keeping normal stopped rows on d.start. results = previous_results for h in pending_hashes(): item = start_or_resume_hash(c, h) diff --git a/pytorrent/static/js/torrentRowRenderer.js b/pytorrent/static/js/torrentRowRenderer.js index 2a0bd72..17884be 100644 --- a/pytorrent/static/js/torrentRowRenderer.js +++ b/pytorrent/static/js/torrentRowRenderer.js @@ -1 +1 @@ -export const torrentRowRendererSource = " function statusMeta(t){\n const op=activeOperationFor(t);\n if(op) return {cls:'text-bg-info operation-status-badge', icon:actionIcon(op.action), color:'text-info', label:op.label};\n const status=String(t.status||'').toLowerCase();\n if(t.paused || status==='paused') return {cls:'text-bg-warning', icon:'fa-pause', color:'text-warning'};\n if(status==='checking' || Number(t.hashing||0)>0) return {cls:'text-bg-info', icon:'fa-rotate', color:'text-info'};\n if(status==='post-check' || t.post_check) return {cls:'text-bg-dark', icon:'fa-clipboard-check', color:'text-secondary', label:'Post-check'};\n if(status==='seeding') return {cls:'text-bg-success', icon:'fa-seedling', color:'text-success'};\n if(status==='downloading') return {cls:'text-bg-primary', icon:'fa-download', color:'text-primary'};\n if(status==='stopped') return {cls:'text-bg-secondary', icon:'fa-stop', color:'text-secondary'};\n return t.state ? {cls:'text-bg-success', icon:'fa-play', color:'text-success'} : {cls:'text-bg-secondary', icon:'fa-circle', color:'text-secondary'};\n }\n function statusBadge(t){ const m=statusMeta(t); return `${esc(m.label || t.status)}`; }\n function torrentErrorLog(t){\n // Note: The name-column status icon is useful only when the torrent has an error log to show in the tooltip.\n const status=String(t.status||'').trim().toLowerCase();\n const msg=String(t.message||'').trim();\n if(status==='error') return msg || 'Torrent reported an error.';\n if(!msg) return null;\n const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied'];\n return patterns.some(p=>msg.toLowerCase().includes(p)) ? msg : null;\n }\n function torrentNameStatusIcon(t){\n // Note: Non-error torrents keep the name cell clean; the Status column still shows their normal state.\n const errorLog=torrentErrorLog(t);\n return errorLog ? ` ` : '';\n }\n function boolCell(value){ return Number(value||0) ? 'yes' : 'no'; }\n function renderRow(t, rowOptions={}){\n const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' ');\n const errorLog=torrentErrorLog(t);\n const op=activeOperationFor(t);\n const rowStyle=rowOptions.style ? ` style=\"${esc(rowOptions.style)}\"` : '';\n const extraClass=rowOptions.className ? String(rowOptions.className) : '';\n const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', errorLog?'torrent-warning':'', extraClass].filter(Boolean).join(' ');\n const title=[t.name,errorLog,op?op.label:''].filter(Boolean).join('\\n');\n return `
${esc(t.hash||'')}${esc(t.hash||'')}