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=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } + function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.map(c=>``).join('')}`).join('')}
${esc(h)}
${c}
`; } + function responsiveTable(headers,rows,extraClass=''){ return `
${table(headers,rows,extraClass)}
`; } function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); } function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>` ${esc(l)}`).join(' '):''; $('detailPane').innerHTML=t?`
Name${esc(t.name)}
Hash${esc(t.hash)}
Path${esc(t.path)}
Size${esc(t.size_h)}
Progress${esc(t.progress)}%
Ratio${esc(t.ratio)}
Downloaded${esc(t.down_total_h)}
Uploaded${esc(t.up_total_h)}
Labels${labels||'-'}
Ratio group${esc(t.ratio_group||'')}
`:'Select a torrent.'; } const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"}; @@ -377,7 +378,7 @@ if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; - box.innerHTML=table( + box.innerHTML=responsiveTable( ['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'], rows.map(r=>[ `${esc(r.status)}`, @@ -390,9 +391,9 @@ humanDateCell(r.finished_at), compactCell(r.error||'',140), jobActions(r), - ]) + ]), + 'jobs-table' ); - box.querySelector('table')?.classList.add('jobs-table'); renderJobsPager(); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} ยท ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } @@ -417,7 +418,34 @@ function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } } function smartQueueToastMessage(r){ const noEffect=r.start_no_effect?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const failTail=stopFailed?`, stop failed ${stopFailed}`:''; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${failTail}${cap}`; } - async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``])):'
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Active','Limit','Over','Stopped','Started','Stop failed'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??'-'),esc(d.max_active_downloads??'-'),esc(d.over_limit??0),esc(h.paused_count||0),esc(h.resumed_count||0),esc((d.stop_failed||[]).length||0)]; })):'
No Smart Queue operations yet.
'; const canToggle=totalHistory>10; const toggle=canToggle?``:''; $('smartHistory').innerHTML=`${body}${toggle}`; } } + async function loadSmartQueue(){ + if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); + if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); + const historyLimit=smartHistoryExpanded?100:10; + const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); + if(!j.ok) return; + const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; + const totalHistory=Number(j.history_total ?? hist.length); + if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; + if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; + if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; + if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); + if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; + if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; + if($('smartManager')){ + $('smartManager').innerHTML=ex.length + ? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),``]),'smart-exclusions-table') + : '
No Smart Queue exceptions. Select torrents and use Exclude selected to keep them outside the queue.
'; + } + if($('smartHistory')){ + const body=hist.length + ? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Started','Stop failed'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??'-'),esc(d.max_active_downloads??'-'),esc(d.over_limit??0),esc(h.paused_count||0),esc(h.resumed_count||0),esc((d.stop_failed||[]).length||0)]; }),'smart-history-table') + : '
No Smart Queue operations yet.
'; + const canToggle=totalHistory>10; + const toggle=canToggle?``:''; + $('smartHistory').innerHTML=`${body}${toggle}`; + } + } async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} } async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); } @@ -674,8 +702,8 @@ if(!$('automationHistory')) return; const toolbar='
'; const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]); - // Note: Automation history time is now human-readable and wider, while the table still wraps on small screens. - const body=hist.length?`
${table(['Time','Rule','Torrent / batch','Actions'],rows).replace('detail-table','detail-table automation-history-table')}
`:'
No automation history yet.
'; + // 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'):'
No automation history yet.
'; $('automationHistory').innerHTML=toolbar+body; } diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 2fb5889..ddb022d 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -126,6 +126,11 @@ body { color: var(--bs-body-color); font-weight: 700; } +.mobile-speed-stats span { + display: inline-flex; + align-items: center; + gap: 0.18rem; +} .topbar .form-control, .topbar .form-select { height: 32px; @@ -379,6 +384,28 @@ body { .detail-table { white-space: nowrap; } +.responsive-table-wrap { + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.6rem; + -webkit-overflow-scrolling: touch; +} +.responsive-table-wrap .detail-table { + margin-bottom: 0; +} +.smart-exclusions-table { + min-width: 680px; +} +.smart-history-table { + min-width: 760px; + table-layout: fixed; +} +.smart-history-table th, +.smart-history-table td { + overflow-wrap: anywhere; + white-space: normal; +} .general-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -625,18 +652,6 @@ body { :root { --topbar: 132px; } - .toolbar-right { - width: 100%; - justify-content: flex-start; - flex-wrap: nowrap; - gap: 0.35rem; - } - .search { - flex: 1 1 0; - width: auto; - min-width: 0; - max-width: none; - } .preset-grid { grid-template-columns: 1fr 1fr; } @@ -1138,19 +1153,12 @@ body.mobile-mode .mobile-card { } } @media (max-width: 640px) { - .toolbar-right { - flex-wrap: nowrap !important; - gap: 0.3rem !important; - } - .search { - min-width: 0 !important; - width: auto !important; - flex: 1 1 0 !important; - max-width: none !important; - } .mobile-speed-stats { - gap: 0.25rem; + align-items: flex-start; + flex-direction: column; + gap: 0.08rem; font-size: 0.66rem; + line-height: 1.05; } } @@ -1346,6 +1354,7 @@ body.mobile-mode .mobile-card { /* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */ .automation-history-table { width: 100%; + min-width: 760px; table-layout: fixed; white-space: normal; } @@ -2336,7 +2345,3 @@ body.mobile-mode .mobile-filter-bar { white-space: normal; } -.automation-history-scroll { - width: 100%; - overflow-x: auto; -}