commit5
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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?`<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 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"};
|
||||
@@ -377,7 +378,7 @@
|
||||
if(r.summary) bits.push(esc(r.summary));
|
||||
return bits.join('<br>') || '-';
|
||||
};
|
||||
box.innerHTML=table(
|
||||
box.innerHTML=responsiveTable(
|
||||
['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],
|
||||
rows.map(r=>[
|
||||
`<span class="badge text-bg-${jobStatusBadgeClass(r.status)}">${esc(r.status)}</span>`,
|
||||
@@ -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=`<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 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 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='<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||'')]);
|
||||
// Note: Automation history time is now human-readable and wider, while the table still wraps on small screens.
|
||||
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>';
|
||||
// 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'):'<div class="empty-mini">No automation history yet.</div>';
|
||||
$('automationHistory').innerHTML=toolbar+body;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user