Perceptual-hash dedup: web UI + background job (3.7, part 2)

Wires the fingerprint engine into the web Maintenance view.

- web.rs: a DedupState job (one at a time) on its own thread. POST
  /api/maintenance/dedup/scan snapshots the library, mtime-gates against
  stored fingerprints, fingerprints the new/changed videos in parallel
  (progress counter), prunes vanished entries, groups by visual
  similarity, and builds review rows (title/channel/size/files + a
  recommended-keep = largest copy). GET /api/maintenance/dedup/status
  polls progress + results. Deletion reuses /api/maintenance/remove.
- index.html: a "Similar content (perceptual)" section in the Maintenance
  modal — Scan button, live progress bar, grouped results with
  checkboxes (recommended-keep pre-unchecked), and a re-scan that drops
  deleted copies. Poller self-cancels when the modal closes.

Integration test (ffmpeg-gated): generates orig + CRF-38 downscaled
re-encode + an unrelated clip, runs the real scan end-to-end, and asserts
the first two group while the third stays out. 118 tests pass.

Desktop Maintenance UI next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-07 06:17:58 -07:00
parent 6d5c1cae33
commit f30e32707e
3 changed files with 293 additions and 1 deletions

View file

@ -2162,7 +2162,86 @@ function renderMaintenance(r){
</div>`;
}
}
h+=`<h3 style="font-size:13px;margin:16px 0 8px">Similar content (perceptual)</h3>
<div class="settings-hint" style="margin-bottom:8px">Finds the same video re-uploaded under a different ID — mirrors, re-encodes, resolution changes — by comparing sampled frames. The first scan fingerprints your library (a few minutes); after that it's cached, so re-scans are quick.</div>
<div id="dedup-area"></div>`;
body.innerHTML=h;
initDedupArea();
}
/* ── Perceptual dedup (similar content) ─────────────────────────── */
let dedupTimer=null;
async function initDedupArea(){
if(dedupTimer){clearInterval(dedupTimer);dedupTimer=null}
try{
const s=await(await api('/api/maintenance/dedup/status')).json();
if(s.running){renderDedupProgress(s);startDedupPoll();}
else if(s.groups){renderSimilarGroups(s.groups);}
else dedupIdle(s.error);
}catch(e){const a=document.getElementById('dedup-area');if(a)a.innerHTML=`<div style="color:#f87171;font-size:12px">${esc(e.message)}</div>`}
}
function dedupIdle(err){
const a=document.getElementById('dedup-area');if(!a)return;
a.innerHTML=`${err?`<div style="color:#f87171;font-size:12px;margin-bottom:6px">Last scan error: ${esc(err)}</div>`:''}
<button class="primary" onclick="startDedup(this)">🔍 Scan for similar content</button>`;
}
async function startDedup(btn){
if(btn)btn.disabled=true;
try{await api('/api/maintenance/dedup/scan',{method:'POST'});startDedupPoll();}
catch(e){setStatus('Error: '+e.message);if(btn)btn.disabled=false}
}
function startDedupPoll(){
if(dedupTimer)clearInterval(dedupTimer);
dedupTimer=setInterval(async()=>{
if(!document.getElementById('dedup-area')){clearInterval(dedupTimer);dedupTimer=null;return} // modal closed
let s;try{s=await(await api('/api/maintenance/dedup/status')).json()}catch{return}
if(s.running){renderDedupProgress(s);return}
clearInterval(dedupTimer);dedupTimer=null;
if(s.error)dedupIdle(s.error); else renderSimilarGroups(s.groups||[]);
},800);
}
function renderDedupProgress(s){
const a=document.getElementById('dedup-area');if(!a)return;
const pct=s.total?Math.round(100*s.done/s.total):0;
a.innerHTML=`<div style="font-size:12px;color:var(--muted);margin-bottom:6px">Fingerprinting ${s.done} / ${s.total||'…'} new videos…</div>
<div style="height:6px;background:var(--bg);border-radius:3px;overflow:hidden"><div style="height:100%;width:${pct}%;background:var(--accent);transition:width .3s"></div></div>`;
}
function renderSimilarGroups(groups){
const a=document.getElementById('dedup-area');if(!a)return;
let h=`<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<span style="font-size:12px;color:var(--muted)">${groups.length} similar group${groups.length===1?'':'s'}</span>
<button onclick="startDedup(this)">⟳ Re-scan</button></div>`;
if(!groups.length){h+='<div style="color:var(--muted);font-size:12px">No visually-similar duplicates found across different IDs. 🎉</div>';a.innerHTML=h;return}
groups.forEach((g,gi)=>{
h+=`<div style="border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:8px">
<div style="font-weight:600;font-size:12px;margin-bottom:6px">Group ${gi+1} — ${g.videos.length} copies</div>`;
g.videos.forEach(v=>{
const tag=v.recommended_keep?'<span style="color:#4ade80">keep</span>':'<span style="color:#f87171">remove</span>';
const size=v.file_size?fmtSize(v.file_size):'no video';
h+=`<label style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
<input type="checkbox" class="sim-chk" data-files='${esc(JSON.stringify(v.files))}' ${v.recommended_keep?'':'checked'}>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.location)}">${esc(v.title)} <span style="color:var(--muted)">· ${esc(v.channel)} · ${size} · [${esc(v.video_id)}]</span></span>
${tag}
</label>`;
});
h+=`</div>`;
});
h+=`<button class="primary" onclick="removeSimilar(this)">🗑 Remove checked copies</button>`;
a.innerHTML=h;
}
async function removeSimilar(btn){
const chks=[...document.querySelectorAll('.sim-chk:checked')];
let paths=[];
for(const c of chks){try{paths=paths.concat(JSON.parse(c.dataset.files))}catch{}}
if(!paths.length){setStatus('Nothing selected.');return}
if(!confirm(`Delete ${paths.length} file(s) across the checked copies? This cannot be undone.`))return;
btn.disabled=true;
try{
const r=await(await api('/api/maintenance/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths})})).json();
setStatus(`Removed ${r.removed} file(s)`+(r.errors&&r.errors.length?`, ${r.errors.length} error(s)`:''));
await loadLibrary();
startDedup(); // re-scan (fingerprints cached) so deleted copies drop out
}catch(e){setStatus('Error: '+e.message);btn.disabled=false}
}
async function removeDuplicates(btn){