diff --git a/pytorrent/services/rtorrent.py b/pytorrent/services/rtorrent.py
index 142920e..e390cc3 100644
--- a/pytorrent/services/rtorrent.py
+++ b/pytorrent/services/rtorrent.py
@@ -372,6 +372,37 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
POST_CHECK_DOWNLOAD_LABEL = "To download after check"
+_POST_CHECK_WATCH_TTL_SECONDS = 48 * 60 * 60
+_POST_CHECK_WATCH_MIN_SECONDS = 2.0
+_POST_CHECK_WATCH: dict[int, dict[str, float]] = {}
+
+
+def _mark_post_check_watch(profile_id: int, torrent_hash: str) -> None:
+ if not torrent_hash:
+ return
+ _POST_CHECK_WATCH.setdefault(int(profile_id), {})[str(torrent_hash)] = time.time()
+
+
+def _clear_post_check_watch(profile_id: int, torrent_hash: str) -> None:
+ profile_watch = _POST_CHECK_WATCH.get(int(profile_id))
+ if not profile_watch:
+ return
+ profile_watch.pop(str(torrent_hash), None)
+ if not profile_watch:
+ _POST_CHECK_WATCH.pop(int(profile_id), None)
+
+
+def _is_post_check_watched(profile_id: int, torrent_hash: str) -> bool:
+ profile_watch = _POST_CHECK_WATCH.get(int(profile_id)) or {}
+ started_at = profile_watch.get(str(torrent_hash))
+ if not started_at:
+ return False
+ age = time.time() - started_at
+ if age > _POST_CHECK_WATCH_TTL_SECONDS:
+ _clear_post_check_watch(profile_id, torrent_hash)
+ return False
+ # Note: A short grace period prevents labeling a recheck that was queued but has not visibly entered hashing yet.
+ return age >= _POST_CHECK_WATCH_MIN_SECONDS
def _label_names(value: str) -> list[str]:
@@ -387,65 +418,94 @@ def _label_value(labels: list[str]) -> str:
return ", ".join([label for label in labels if str(label or "").strip()])
+def _without_post_check_download_label(value: str | None) -> str:
+ return _label_value([label for label in _label_names(str(value or "")) if label != POST_CHECK_DOWNLOAD_LABEL])
+
+
+def clear_post_check_download_label(c: ScgiRtorrentClient, torrent_hash: str, current_label: str | None = None) -> bool:
+ label_source = current_label
+ if label_source is None:
+ try:
+ label_source = str(c.call("d.custom1", str(torrent_hash or "")) or "")
+ except Exception:
+ label_source = ""
+ labels = _label_names(str(label_source or ""))
+ if POST_CHECK_DOWNLOAD_LABEL not in labels:
+ return False
+ # Note: The temporary post-check label is removed only after the torrent leaves the stopped waiting queue.
+ c.call("d.custom1.set", str(torrent_hash or ""), _label_value([label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]))
+ return True
+
+
+def _message_indicates_active_check(message: str) -> bool:
+ msg = str(message or "").lower()
+ if not msg:
+ return False
+ finished_markers = ("complete", "completed", "finished", "success", "succeeded", "failed", "done")
+ if any(marker in msg for marker in finished_markers):
+ return False
+ active_markers = ("checking", "hashing", "hash check queued", "hash check scheduled", "check hash queued", "recheck queued", "rechecking")
+ return any(marker in msg for marker in active_markers)
+
+
def _row_progress_complete(row: dict) -> bool:
size = int(row.get("size") or 0)
completed = int(row.get("completed_bytes") or 0)
return bool(row.get("complete")) or (size > 0 and completed >= size) or float(row.get("progress") or 0) >= 100.0
-def _remove_post_check_label_if_finished(c: ScgiRtorrentClient, row: dict) -> bool:
+def _cleanup_post_check_label_if_ready(c: ScgiRtorrentClient, row: dict) -> bool:
labels = _label_names(str(row.get("label") or ""))
if POST_CHECK_DOWNLOAD_LABEL not in labels:
return False
status = str(row.get("status") or "").lower()
- if not (_row_progress_complete(row) or status == "seeding"):
+ started_after_wait = bool(int(row.get("state") or 0)) and status != "checking"
+ if not (_row_progress_complete(row) or status == "seeding" or started_after_wait):
return False
- labels = [label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL]
- value = _label_value(labels)
- # Note: Clean the temporary label after reaching 100% or entering seeding, even when the state no longer comes directly from recheck.
- c.call("d.custom1.set", str(row.get("hash") or ""), value)
- row["label"] = value
+ # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
+ clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
+ row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
return True
def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict[str, dict] | None = None) -> list[dict]:
- """Start complete torrents after check; pause and label incomplete ones."""
+ """Start complete torrents after check; stop and label incomplete ones for Smart Queue."""
previous_rows = previous_rows or {}
+ profile_id = int(profile.get("id") or 0)
c = client_for(profile)
changes: list[dict] = []
for row in rows:
h = str(row.get("hash") or "")
prev = previous_rows.get(h) or {}
try:
- if h and _remove_post_check_label_if_finished(c, row):
- changes.append({"hash": h, "action": "remove_post_check_label", "complete": True})
+ if h and _cleanup_post_check_label_if_ready(c, row):
+ changes.append({"hash": h, "action": "remove_post_check_label"})
except Exception as exc:
changes.append({"hash": h, "action": "remove_post_check_label_failed", "error": str(exc)})
was_checking = str(prev.get("status") or "") == "Checking" or int(prev.get("hashing") or 0) > 0
+ watched_recheck = _is_post_check_watched(profile_id, h)
is_checking = str(row.get("status") or "") == "Checking" or int(row.get("hashing") or 0) > 0
- if not h or not was_checking or is_checking:
+ if not h or not (was_checking or watched_recheck) or is_checking:
continue
complete = _row_progress_complete(row)
try:
if complete:
- # Note: After a completed check, a complete torrent is started automatically so it can seed immediately.
- c.call("d.start", h)
- labels = [label for label in _label_names(str(row.get("label") or "")) if label != POST_CHECK_DOWNLOAD_LABEL]
- if _label_value(labels) != str(row.get("label") or ""):
- c.call("d.custom1.set", h, _label_value(labels))
- row["label"] = _label_value(labels)
- row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding"})
- changes.append({"hash": h, "action": "start", "complete": True})
+ # Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
+ start_result = start_or_resume_hash(c, h)
+ clear_post_check_download_label(c, h, str(row.get("label") or ""))
+ row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding", "label": _without_post_check_download_label(str(row.get("label") or ""))})
+ changes.append({"hash": h, "action": "start_seed_after_check", "complete": True, "result": start_result})
else:
- # Note: After check, an incomplete torrent is paused and labeled to show that it needs more downloading.
- c.call("d.start", h)
- c.call("d.pause", h)
labels = _label_names(str(row.get("label") or ""))
if POST_CHECK_DOWNLOAD_LABEL not in labels:
labels.append(POST_CHECK_DOWNLOAD_LABEL)
- c.call("d.custom1.set", h, _label_value(labels))
- row.update({"state": 1, "active": 0, "paused": True, "status": "Paused", "label": _label_value(labels)})
- changes.append({"hash": h, "action": "pause_and_label", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
+ label_value = _label_value(labels)
+ # Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
+ c.call("d.stop", h)
+ c.call("d.custom1.set", h, label_value)
+ row.update({"state": 0, "active": 0, "paused": False, "status": "Stopped", "label": label_value})
+ changes.append({"hash": h, "action": "stop_and_label_after_check", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL})
+ _clear_post_check_watch(profile_id, h)
except Exception as exc:
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
return changes
@@ -489,7 +549,8 @@ def normalize_row(row: list) -> dict:
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)
- is_checking = bool(hashing) or ("hash" in msg_l and ("check" in msg_l or "checking" in msg_l)) or "recheck" in msg_l
+ # 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)
is_paused = bool(state) and not bool(is_active) and not is_checking
status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
return {
@@ -1398,6 +1459,9 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
if remove_data:
results.append(_remove_torrent_data(c, h))
c.call(method, h)
+ if name == "recheck":
+ # Note: Recheck is tracked so even very fast checks still receive the after-check start/stop policy.
+ _mark_post_check_watch(int(profile.get("id") or 0), h)
return {"ok": True, "count": len(torrent_hashes), "remove_data": remove_data, "results": results}
def add_magnet(profile: dict, uri: str, start: bool = True, directory: str = "", label: str = "") -> dict:
diff --git a/pytorrent/services/smart_queue.py b/pytorrent/services/smart_queue.py
index a639988..d0a96d7 100644
--- a/pytorrent/services/smart_queue.py
+++ b/pytorrent/services/smart_queue.py
@@ -468,6 +468,9 @@ def _is_waiting_download_candidate(t: dict[str, Any], manage_stopped: bool) -> b
"""Return True for stopped torrents Smart Queue may start later."""
if int(t.get('complete') or 0):
return False
+ if str(t.get('status') or '').lower() == 'checking':
+ # Note: Torrents still being checked must finish post-check handling before Smart Queue may start them.
+ return False
if _has_stalled_label(str(t.get('label') or '')):
return False
if _is_user_paused(t):
@@ -649,6 +652,11 @@ def check(profile: dict | None = None, user_id: int | None = None, force: bool =
active_verified, start_no_effect = _verify_started_downloads(c, start_requested)
for h in active_verified:
_restore_auto_label(c, profile_id, h, None)
+ try:
+ # Note: Once Smart Queue starts a post-check torrent, its temporary download-after-check label is no longer needed.
+ rtorrent.clear_post_check_download_label(c, h, None)
+ except Exception:
+ label_failed.append(h)
# Note: History shows only torrents actually started, not just the number of sent commands.
started_by_queue = list(active_verified)
keep_labels = (
diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js
index 6985fd8..de4f1fc 100644
--- a/pytorrent/static/app.js
+++ b/pytorrent/static/app.js
@@ -261,7 +261,8 @@
async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
function flag(iso){ const code=String(iso||'').toLowerCase(); return code?` ${esc(code.toUpperCase())}`:'-'; }
- function table(headers,rows){ return `
${headers.map(h=>`
${esc(h)}
`).join('')}
${rows.map(r=>`
${r.map(c=>`
${c}
`).join('')}
`).join('')}
`; }
+ function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `
${headers.map(h=>`
${esc(h)}
`).join('')}
${rows.map(r=>`
${r.map(c=>`
${c}
`).join('')}
`).join('')}
`; }
+ function responsiveTable(headers,rows,extraClass=''){ return `
';
+ // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.
+ const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'