This commit is contained in:
Mateusz Gruszczyński
2026-05-08 09:13:30 +02:00
parent b5f1c26a83
commit f445d25c5d
4 changed files with 165 additions and 60 deletions

View File

@@ -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: