queue_stopped #3

Merged
gru merged 33 commits from queue_stopped into master 2026-05-08 23:45:33 +02:00
4 changed files with 165 additions and 60 deletions
Showing only changes of commit f445d25c5d - Show all commits

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_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]: 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()]) 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: def _row_progress_complete(row: dict) -> bool:
size = int(row.get("size") or 0) size = int(row.get("size") or 0)
completed = int(row.get("completed_bytes") 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 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 "")) labels = _label_names(str(row.get("label") or ""))
if POST_CHECK_DOWNLOAD_LABEL not in labels: if POST_CHECK_DOWNLOAD_LABEL not in labels:
return False return False
status = str(row.get("status") or "").lower() 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 return False
labels = [label for label in labels if label != POST_CHECK_DOWNLOAD_LABEL] # Note: Keep the post-check label while the torrent is stopped; remove it once it is started for download/seeding.
value = _label_value(labels) clear_post_check_download_label(c, str(row.get("hash") or ""), str(row.get("label") or ""))
# Note: Clean the temporary label after reaching 100% or entering seeding, even when the state no longer comes directly from recheck. row["label"] = _without_post_check_download_label(str(row.get("label") or ""))
c.call("d.custom1.set", str(row.get("hash") or ""), value)
row["label"] = value
return True return True
def apply_post_check_policy(profile: dict, rows: list[dict], previous_rows: dict[str, dict] | None = None) -> list[dict]: 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 {} previous_rows = previous_rows or {}
profile_id = int(profile.get("id") or 0)
c = client_for(profile) c = client_for(profile)
changes: list[dict] = [] changes: list[dict] = []
for row in rows: for row in rows:
h = str(row.get("hash") or "") h = str(row.get("hash") or "")
prev = previous_rows.get(h) or {} prev = previous_rows.get(h) or {}
try: try:
if h and _remove_post_check_label_if_finished(c, row): if h and _cleanup_post_check_label_if_ready(c, row):
changes.append({"hash": h, "action": "remove_post_check_label", "complete": True}) changes.append({"hash": h, "action": "remove_post_check_label"})
except Exception as exc: except Exception as exc:
changes.append({"hash": h, "action": "remove_post_check_label_failed", "error": str(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 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 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 continue
complete = _row_progress_complete(row) complete = _row_progress_complete(row)
try: try:
if complete: if complete:
# Note: After a completed check, a complete torrent is started automatically so it can seed immediately. # Note: A fully checked torrent is started with the same helper as the manual Start action so it seeds immediately.
c.call("d.start", h) start_result = start_or_resume_hash(c, h)
labels = [label for label in _label_names(str(row.get("label") or "")) if label != POST_CHECK_DOWNLOAD_LABEL] clear_post_check_download_label(c, h, str(row.get("label") or ""))
if _label_value(labels) != 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 ""))})
c.call("d.custom1.set", h, _label_value(labels)) changes.append({"hash": h, "action": "start_seed_after_check", "complete": True, "result": start_result})
row["label"] = _label_value(labels)
row.update({"state": 1, "active": 1, "paused": False, "status": "Seeding"})
changes.append({"hash": h, "action": "start", "complete": True})
else: 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 "")) labels = _label_names(str(row.get("label") or ""))
if POST_CHECK_DOWNLOAD_LABEL not in labels: if POST_CHECK_DOWNLOAD_LABEL not in labels:
labels.append(POST_CHECK_DOWNLOAD_LABEL) labels.append(POST_CHECK_DOWNLOAD_LABEL)
c.call("d.custom1.set", h, _label_value(labels)) label_value = _label_value(labels)
row.update({"state": 1, "active": 0, "paused": True, "status": "Paused", "label": _label_value(labels)}) # Note: Incomplete torrents are left stopped after check so Smart Queue can start them later within the global limit.
changes.append({"hash": h, "action": "pause_and_label", "complete": False, "label": POST_CHECK_DOWNLOAD_LABEL}) 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: except Exception as exc:
changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)}) changes.append({"hash": h, "action": "post_check_policy_failed", "error": str(exc)})
return changes 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) is_active = int(row[21] or 0) if len(row) > 21 else int(row[2] or 0)
state = int(row[2] or 0) state = int(row[2] or 0)
complete = int(row[3] 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 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" status = "Checking" if is_checking else "Paused" if is_paused else "Seeding" if complete and state else "Downloading" if state else "Stopped"
return { return {
@@ -1398,6 +1459,9 @@ def action(profile: dict, torrent_hashes: list[str], name: str, payload: dict |
if remove_data: if remove_data:
results.append(_remove_torrent_data(c, h)) results.append(_remove_torrent_data(c, h))
c.call(method, 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} 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: def add_magnet(profile: dict, uri: str, start: bool = True, directory: str = "", label: str = "") -> dict:

View File

@@ -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.""" """Return True for stopped torrents Smart Queue may start later."""
if int(t.get('complete') or 0): if int(t.get('complete') or 0):
return False 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 '')): if _has_stalled_label(str(t.get('label') or '')):
return False return False
if _is_user_paused(t): 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) active_verified, start_no_effect = _verify_started_downloads(c, start_requested)
for h in active_verified: for h in active_verified:
_restore_auto_label(c, profile_id, h, None) _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. # Note: History shows only torrents actually started, not just the number of sent commands.
started_by_queue = list(active_verified) started_by_queue = list(active_verified)
keep_labels = ( keep_labels = (

View File

@@ -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);} } 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?`<span class="fi fi-${esc(code)}"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; } function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class="fi fi-${esc(code)}"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }
function table(headers,rows){ return `<table class="table table-sm detail-table"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; } function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `<table class="table table-sm detail-table${cls}"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
function responsiveTable(headers,rows,extraClass=''){ return `<div class="responsive-table-wrap">${table(headers,rows,extraClass)}</div>`; }
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 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=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '):''; $('detailPane').innerHTML=t?`<div class="general-grid"><div><b>Name</b><span>${esc(t.name)}</span></div><div><b>Hash</b><span>${esc(t.hash)}</span></div><div><b>Path</b><span>${esc(t.path)}</span></div><div><b>Size</b><span>${esc(t.size_h)}</span></div><div><b>Progress</b><span>${esc(t.progress)}%</span></div><div><b>Ratio</b><span>${esc(t.ratio)}</span></div><div><b>Downloaded</b><span>${esc(t.down_total_h)}</span></div><div><b>Uploaded</b><span>${esc(t.up_total_h)}</span></div><div><b>Labels</b><span>${labels||'<span class="text-muted">-</span>'}</span></div><div><b>Ratio group</b><span>${esc(t.ratio_group||'')}</span></div></div>`:'Select a torrent.'; } function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '):''; $('detailPane').innerHTML=t?`<div class="general-grid"><div><b>Name</b><span>${esc(t.name)}</span></div><div><b>Hash</b><span>${esc(t.hash)}</span></div><div><b>Path</b><span>${esc(t.path)}</span></div><div><b>Size</b><span>${esc(t.size_h)}</span></div><div><b>Progress</b><span>${esc(t.progress)}%</span></div><div><b>Ratio</b><span>${esc(t.ratio)}</span></div><div><b>Downloaded</b><span>${esc(t.down_total_h)}</span></div><div><b>Uploaded</b><span>${esc(t.up_total_h)}</span></div><div><b>Labels</b><span>${labels||'<span class="text-muted">-</span>'}</span></div><div><b>Ratio group</b><span>${esc(t.ratio_group||'')}</span></div></div>`:'Select a torrent.'; }
const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"}; const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"};
@@ -377,7 +378,7 @@
if(r.summary) bits.push(esc(r.summary)); if(r.summary) bits.push(esc(r.summary));
return bits.join('<br>') || '-'; return bits.join('<br>') || '-';
}; };
box.innerHTML=table( box.innerHTML=responsiveTable(
['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'], ['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],
rows.map(r=>[ rows.map(r=>[
`<span class="badge text-bg-${jobStatusBadgeClass(r.status)}">${esc(r.status)}</span>`, `<span class="badge text-bg-${jobStatusBadgeClass(r.status)}">${esc(r.status)}</span>`,
@@ -390,9 +391,9 @@
humanDateCell(r.finished_at), humanDateCell(r.finished_at),
compactCell(r.error||'',140), compactCell(r.error||'',140),
jobActions(r), jobActions(r),
]) ]),
'jobs-table'
); );
box.querySelector('table')?.classList.add('jobs-table');
renderJobsPager(); renderJobsPager();
} }
function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`<div class="d-flex align-items-center gap-2 flex-wrap"><button class="btn btn-sm btn-outline-secondary" id="jobsPrev" ${jobsPage<=0?'disabled':''}><i class="fa-solid fa-chevron-left"></i> Prev</button><span class="small text-muted">Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs</span><button class="btn btn-sm btn-outline-secondary" id="jobsNext" ${jobsPage>=pages-1?'disabled':''}>Next <i class="fa-solid fa-chevron-right"></i></button></div>`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); } function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`<div class="d-flex align-items-center gap-2 flex-wrap"><button class="btn btn-sm btn-outline-secondary" id="jobsPrev" ${jobsPage<=0?'disabled':''}><i class="fa-solid fa-chevron-left"></i> Prev</button><span class="small text-muted">Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs</span><button class="btn btn-sm btn-outline-secondary" id="jobsNext" ${jobsPage>=pages-1?'disabled':''}>Next <i class="fa-solid fa-chevron-right"></i></button></div>`; $('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 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}`; } 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),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`])):'<div class="empty-mini"><i class="fa-solid fa-circle-info"></i> No Smart Queue exceptions. Select torrents and use <b>Exclude selected</b> to keep them outside the queue.</div>'; 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)]; })):'<div class="empty-mini">No Smart Queue operations yet.</div>'; const canToggle=totalHistory>10; const toggle=canToggle?`<button id="smartHistoryToggle" class="btn btn-xs btn-outline-secondary mt-2">${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)})</button>`:''; $('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),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`]),'smart-exclusions-table')
: '<div class="empty-mini"><i class="fa-solid fa-circle-info"></i> No Smart Queue exceptions. Select torrents and use <b>Exclude selected</b> to keep them outside the queue.</div>';
}
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')
: '<div class="empty-mini">No Smart Queue operations yet.</div>';
const canToggle=totalHistory>10;
const toggle=canToggle?`<button id="smartHistoryToggle" class="btn btn-xs btn-outline-secondary mt-2">${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)})</button>`:'';
$('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 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(); } 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; if(!$('automationHistory')) return;
const toolbar='<div class="automation-history-toolbar"><button id="automationClearHistoryBtn" class="btn btn-xs btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear history</button></div>'; const toolbar='<div class="automation-history-toolbar"><button id="automationClearHistoryBtn" class="btn btn-xs btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear history</button></div>';
const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]); 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. // Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.
const body=hist.length?`<div class="automation-history-scroll">${table(['Time','Rule','Torrent / batch','Actions'],rows).replace('detail-table','detail-table automation-history-table')}</div>`:'<div class="empty-mini">No automation history yet.</div>'; const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'<div class="empty-mini">No automation history yet.</div>';
$('automationHistory').innerHTML=toolbar+body; $('automationHistory').innerHTML=toolbar+body;
} }

View File

@@ -126,6 +126,11 @@ body {
color: var(--bs-body-color); color: var(--bs-body-color);
font-weight: 700; font-weight: 700;
} }
.mobile-speed-stats span {
display: inline-flex;
align-items: center;
gap: 0.18rem;
}
.topbar .form-control, .topbar .form-control,
.topbar .form-select { .topbar .form-select {
height: 32px; height: 32px;
@@ -379,6 +384,28 @@ body {
.detail-table { .detail-table {
white-space: nowrap; 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 { .general-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -625,18 +652,6 @@ body {
:root { :root {
--topbar: 132px; --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 { .preset-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
@@ -1138,19 +1153,12 @@ body.mobile-mode .mobile-card {
} }
} }
@media (max-width: 640px) { @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 { .mobile-speed-stats {
gap: 0.25rem; align-items: flex-start;
flex-direction: column;
gap: 0.08rem;
font-size: 0.66rem; 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. */ /* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */
.automation-history-table { .automation-history-table {
width: 100%; width: 100%;
min-width: 760px;
table-layout: fixed; table-layout: fixed;
white-space: normal; white-space: normal;
} }
@@ -2336,7 +2345,3 @@ body.mobile-mode .mobile-filter-bar {
white-space: normal; white-space: normal;
} }
.automation-history-scroll {
width: 100%;
overflow-x: auto;
}