Download manager: cancel/retry/queue controls + dedup off-switch
Download management UI (web + desktop):
- Cancel a running job (SIGKILL, marked "cancelled" so it isn't auto-retried
and shows a distinct state rather than a misleading error class)
- Cancel a still-queued job before it starts
- Manually retry a failed/cancelled job (fresh auto-retry budget)
- Live speed · ETA · % on running jobs, parsed from the yt-dlp progress line
- Expandable full per-job log fetched on demand
New endpoints: POST /api/jobs/:idx/{cancel,retry}, GET /api/jobs/:idx/log,
DELETE /api/queued/:idx.
Perceptual-dedup off-switch:
- backup.dedup_enabled (default true) hard-disables the "similar content"
scan in both UIs; the web scan endpoint returns {disabled:true} and the
desktop scan no-ops with a note. Wired through all five settings touchpoints.
Docs: README seeking note + ROADMAP recently-shipped updates for the prior
seekable-playback / federation / auto-tagging work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c3ff9121f6
commit
9ed62935f5
7 changed files with 305 additions and 33 deletions
|
|
@ -142,7 +142,10 @@
|
|||
.job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;flex-wrap:wrap;min-width:0}
|
||||
.job-row .job{border-bottom:none}
|
||||
.badge{font-weight:700;min-width:48px;flex-shrink:0}
|
||||
.badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171}
|
||||
.badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171}.badge.cancelled{color:var(--muted)}.badge.queued{color:var(--muted)}
|
||||
.job-meta{font-size:11px;color:var(--muted);flex-shrink:0;font-variant-numeric:tabular-nums}
|
||||
.job-act{font-size:11px;padding:1px 7px;flex-shrink:0}
|
||||
.job-log{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;white-space:pre-wrap;word-break:break-word;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin:0 14px 8px;padding:8px;max-height:220px;overflow:auto;color:var(--muted)}
|
||||
/* Error-class badge sits next to the state badge. Subtle so the actual
|
||||
hint below carries the user's attention. */
|
||||
.err-class{font-size:10px;background:#7f1d1d;color:#fecaca;border-radius:3px;padding:1px 6px;text-transform:uppercase;letter-spacing:.4px;flex-shrink:0}
|
||||
|
|
@ -1911,6 +1914,11 @@ async function openSettings(){
|
|||
<input type="checkbox" id="cf-comments" ${cur.fetch_comments?'checked':''}>
|
||||
</div>
|
||||
<div class="settings-hint" style="margin-bottom:6px">Adds <code>--write-comments</code> to every download — fetches each video's comment tree into its info.json so the player's Comments tab works. Slow on popular videos. Per-channel overrides live in each channel's options.</div>
|
||||
<div class="settings-row" style="margin-top:8px">
|
||||
<label for="cf-dedup">Similar-content scan</label>
|
||||
<input type="checkbox" id="cf-dedup" ${cur.dedup_enabled!==false?'checked':''}>
|
||||
</div>
|
||||
<div class="settings-hint" style="margin-bottom:6px">Enables the Maintenance “Scan for similar content” tool, which fingerprints your videos (an ffmpeg keyframe pass) to find visual duplicates. Turn off on low-powered machines to skip that work.</div>
|
||||
<hr style="border-color:var(--border);margin:12px 0">
|
||||
<div style="font-weight:700;margin-bottom:8px">Subtitles (global defaults)</div>
|
||||
<div class="settings-hint" style="margin-bottom:6px">Applied to every download. Individual channels can override these in their Channel options dialog.</div>
|
||||
|
|
@ -2154,12 +2162,13 @@ async function saveSettings(btn){
|
|||
const playerClients=document.getElementById('cf-player-clients')?.value||'';
|
||||
const sponsorblock=document.getElementById('cf-sponsorblock')?.value||'mark';
|
||||
const fetchComments=document.getElementById('cf-comments')?.checked||false;
|
||||
const dedupEnabled=document.getElementById('cf-dedup')?.checked!==false;
|
||||
const convMode=document.getElementById('cf-convert-mode')?.value||'';
|
||||
const convCrf=parseInt(document.getElementById('cf-convert-crf')?.value||'23',10);
|
||||
const convPreset=document.getElementById('cf-convert-preset')?.value||'';
|
||||
const convAudio=document.getElementById('cf-convert-audio')?.value||'';
|
||||
const convKeep=document.getElementById('cf-convert-keep')?.checked||false;
|
||||
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients,sponsorblock_mode:sponsorblock,fetch_comments:fetchComments,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep};
|
||||
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider,subtitles_enabled:subsEnabled,subtitles_auto:subsAuto,subtitles_embed:subsEmbed,subtitle_langs:subsLangs,subtitle_format:subsFormat,youtube_player_clients:playerClients,sponsorblock_mode:sponsorblock,fetch_comments:fetchComments,dedup_enabled:dedupEnabled,convert_mode:convMode,convert_crf:convCrf,convert_preset:convPreset,convert_audio_format:convAudio,convert_keep_original:convKeep};
|
||||
if(bindMode)payload.bind_mode=bindMode;
|
||||
if(plexPath!==undefined)payload.plex_library_path=plexPath;
|
||||
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
|
||||
|
|
@ -2398,7 +2407,15 @@ function dedupIdle(err){
|
|||
}
|
||||
async function startDedup(btn){
|
||||
if(btn)btn.disabled=true;
|
||||
try{await api('/api/maintenance/dedup/scan',{method:'POST'});startDedupPoll();}
|
||||
try{
|
||||
const r=await(await api('/api/maintenance/dedup/scan',{method:'POST'})).json();
|
||||
if(r&&r.disabled){
|
||||
const a=document.getElementById('dedup-area');
|
||||
if(a)a.innerHTML=`<div style="color:var(--muted);font-size:12px">Similar-content scan is disabled in Settings.</div>`;
|
||||
return;
|
||||
}
|
||||
startDedupPoll();
|
||||
}
|
||||
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||||
}
|
||||
function startDedupPoll(){
|
||||
|
|
@ -2509,13 +2526,16 @@ function renderJobs(jobs,queued,maxConcurrent){
|
|||
}
|
||||
const fin=jobs.some(j=>j.state!=='running');
|
||||
const hdr=fin?`<div style="padding:4px 14px;display:flex;justify-content:flex-end;border-bottom:1px solid var(--border)"><button onclick="clearFinishedJobs()" style="font-size:11px;padding:2px 8px">✕ Clear finished</button></div>`:'';
|
||||
const queuedHtml=queued.map(q=>`<div class="job">
|
||||
<span class="badge" style="color:var(--muted)">queued</span>
|
||||
// Queued jobs map 1:1 to the server's pending-queue index, so the cancel
|
||||
// button can DELETE /api/queued/<i>.
|
||||
const queuedHtml=queued.map((q,i)=>`<div class="job">
|
||||
<span class="badge queued">queued</span>
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)">${esc(q.label)} — ${esc(q.url)}</span>
|
||||
<button class="job-act" onclick="cancelQueued(${i})" title="Remove from queue">✕</button>
|
||||
</div>`).join('');
|
||||
const limitNote=maxConcurrent>0?`<div style="font-size:11px;color:var(--muted);padding:6px 14px">max ${maxConcurrent} concurrent</div>`:'';
|
||||
body.innerHTML=hdr+jobs.map((j,i)=>{
|
||||
const dismiss=j.state!=='running'?`<button onclick="removeJob(${i})" style="font-size:11px;padding:1px 6px">✕</button>`:'';
|
||||
const running=j.state==='running';
|
||||
// Failed-job hint: when the classifier matched a known pattern, show
|
||||
// the suggested action below the row in a subtle box. Unclassified
|
||||
// failures (error_class === undefined) fall back to just the raw
|
||||
|
|
@ -2524,19 +2544,71 @@ function renderJobs(jobs,queued,maxConcurrent){
|
|||
? `<span class="err-class" title="${esc(j.error_hint||'')}">${esc(j.error_class)}</span>` : '';
|
||||
const errHint=j.state==='failed'&&j.error_hint
|
||||
? `<div class="job-hint">${esc(j.error_hint)}</div>` : '';
|
||||
return `<div class="job-row">
|
||||
// Live speed/ETA parsed from the yt-dlp progress line (running only);
|
||||
// otherwise show the raw last log line.
|
||||
const meta=running?jobMeta(j.last_line):'';
|
||||
const pct=running?` ${Math.round((j.progress||0)*100)}%`:'';
|
||||
// Per-row actions: cancel (running), retry (finished + retryable),
|
||||
// log toggle (always), dismiss (finished).
|
||||
const acts=[];
|
||||
if(running)acts.push(`<button class="job-act" onclick="cancelJob(${i},this)" title="Stop this download">⛔ Cancel</button>`);
|
||||
if(!running&&j.can_retry)acts.push(`<button class="job-act" onclick="retryJob(${i},this)" title="Re-run this download">↻ Retry</button>`);
|
||||
acts.push(`<button class="job-act" onclick="toggleJobLog(${i},this)" title="Show full log">📄 Log</button>`);
|
||||
if(!running)acts.push(`<button class="job-act" onclick="removeJob(${i})" title="Dismiss">✕</button>`);
|
||||
return `<div class="job-row" data-job="${i}">
|
||||
<div class="job">
|
||||
<span class="badge ${j.state}">${j.state}</span>
|
||||
${errBadge}
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
||||
${j.state==='running'?`<progress value="${j.progress}" max="1"></progress>`:''}
|
||||
<span class="last-line" style="font-size:11px;color:var(--muted);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>
|
||||
${dismiss}
|
||||
${running?`<progress value="${j.progress}" max="1"></progress><span class="job-meta">${esc(meta)}${pct}</span>`:`<span class="last-line" style="font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.last_line)}</span>`}
|
||||
${acts.join('')}
|
||||
</div>
|
||||
${errHint}
|
||||
</div>`;
|
||||
}).join('')+queuedHtml+limitNote;
|
||||
}
|
||||
// Parse a yt-dlp "[download] 42.7% of 100MiB at 5MiB/s ETA 00:10" line into a
|
||||
// compact "5MiB/s · ETA 00:10" meta string. Empty when the line isn't a
|
||||
// recognizable download-progress line (post-processing, fragments, etc.).
|
||||
function jobMeta(line){
|
||||
if(!line)return '';
|
||||
const sp=line.match(/at\s+([\d.]+\s*[KMGT]?i?B\/s)/i);
|
||||
const eta=line.match(/ETA\s+([\d:]+)/i);
|
||||
const parts=[];
|
||||
if(sp)parts.push(sp[1].replace(/\s+/g,''));
|
||||
if(eta)parts.push('ETA '+eta[1]);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
async function cancelJob(idx,btn){
|
||||
if(btn)btn.disabled=true;
|
||||
try{await api('/api/jobs/'+idx+'/cancel',{method:'POST'});await pollProgress();}
|
||||
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||||
}
|
||||
async function retryJob(idx,btn){
|
||||
if(btn)btn.disabled=true;
|
||||
try{await api('/api/jobs/'+idx+'/retry',{method:'POST'});await pollProgress();}
|
||||
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
|
||||
}
|
||||
async function cancelQueued(idx){
|
||||
try{await api('/api/queued/'+idx,{method:'DELETE'});await pollProgress();}
|
||||
catch(e){setStatus('Error: '+e.message)}
|
||||
}
|
||||
// Expand/collapse the full server-side log for one job, fetched on demand.
|
||||
async function toggleJobLog(idx,btn){
|
||||
const row=document.querySelector(`.job-row[data-job="${idx}"]`);
|
||||
if(!row)return;
|
||||
const existing=row.querySelector('.job-log');
|
||||
if(existing){existing.remove();if(btn)btn.classList.remove('primary');return}
|
||||
if(btn)btn.classList.add('primary');
|
||||
const pre=document.createElement('div');
|
||||
pre.className='job-log';pre.textContent='Loading…';
|
||||
row.appendChild(pre);
|
||||
try{
|
||||
const d=await(await api('/api/jobs/'+idx+'/log')).json();
|
||||
pre.textContent=(d.lines||[]).join('\n')||'(no log output)';
|
||||
pre.scrollTop=pre.scrollHeight;
|
||||
}catch(e){pre.textContent='Failed to load log: '+e.message}
|
||||
}
|
||||
// Open the Downloads modal. If it's already open, do nothing (clicking the
|
||||
// ⬇ button again would otherwise stack duplicate modals).
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue