Smart auto-tagging: suggest folder groups for unfiled channels (3.4)
New autotag.rs classifies each unfiled channel from already-scanned metadata — source platform + median video duration + upload cadence — into a suggested folder group (Music / Shorts / Long-form & Podcasts / Streams & VODs), with a confidence and a human-readable reason. Mid-length YouTube is left unsuggested rather than guessed at; channels already in a folder are skipped. Pure arithmetic over the in-memory library, computed on demand (no background job). Surfaced in both Maintenance views: - web: GET /api/autotag/suggest + POST /api/autotag/apply (create/reuse the named folder and assign), rendered with per-channel checkboxes and an "Apply -> group" button that re-analyzes after applying. - desktop: a section listing each group with a "Move all -> group" button. Apply mirrors post_assign_folder: DB write + in-memory library update + ETag bump. Reuses an existing folder of the same name (case-insensitive). Unit tests cover the classifier; verified the apply/revert round-trip live. ROADMAP 3.4 marked DONE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
aed577ea2f
commit
ec8cf6f934
7 changed files with 658 additions and 4 deletions
|
|
@ -2200,8 +2200,63 @@ function renderMaintenance(r){
|
|||
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>`;
|
||||
h+=`<h3 style="font-size:13px;margin:16px 0 8px">🏷 Auto-tag suggestions</h3>
|
||||
<div class="settings-hint" style="margin-bottom:8px">Groups your <b>unfiled</b> channels by source platform and typical video length. Review a group and apply it to drop those channels into a folder — nothing moves until you click Apply.</div>
|
||||
<div id="autotag-area"><em style="color:var(--muted);font-size:12px">Analyzing…</em></div>`;
|
||||
body.innerHTML=h;
|
||||
initDedupArea();
|
||||
initAutotagArea();
|
||||
}
|
||||
|
||||
/* ── Smart auto-tagging (suggested folder groups) ───────────────── */
|
||||
async function initAutotagArea(){
|
||||
const area=document.getElementById('autotag-area');if(!area)return;
|
||||
let groups;
|
||||
try{groups=await(await api('/api/autotag/suggest')).json();}
|
||||
catch(e){area.innerHTML=`<div style="color:#f87171;font-size:12px">Could not analyze: ${esc(e.message)}</div>`;return}
|
||||
renderAutotag(groups);
|
||||
}
|
||||
function renderAutotag(groups){
|
||||
const area=document.getElementById('autotag-area');if(!area)return;
|
||||
if(!groups||!groups.length){area.innerHTML='<div style="color:var(--muted);font-size:12px">No grouping suggestions — every channel is already filed or too ambiguous to call.</div>';return}
|
||||
// Confidence → dot colour: strong (green) ≥ .85, medium (amber) ≥ .7, else grey.
|
||||
const dot=c=>{const col=c>=0.85?'#4ade80':c>=0.7?'#facc15':'#9ca3af';return `<span title="confidence ${(c*100).toFixed(0)}%" style="flex-shrink:0;width:8px;height:8px;border-radius:50%;background:${col};display:inline-block"></span>`};
|
||||
let h='';
|
||||
groups.forEach((g,gi)=>{
|
||||
h+=`<div style="border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:8px" data-grp="${gi}">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="font-weight:600;font-size:12px;flex:1">📁 ${esc(g.group)} <span style="color:var(--muted)">(${g.channels.length})</span></span>
|
||||
<button class="primary" onclick="applyAutotag(${gi},this)">Apply → ${esc(g.group)}</button>
|
||||
</div>`;
|
||||
g.channels.forEach((c,ci)=>{
|
||||
h+=`<label style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
|
||||
<input type="checkbox" class="at-chk" data-grp="${gi}" data-ci="${ci}" checked>
|
||||
${dot(c.confidence)}
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.display_name)} <span style="color:var(--muted)">· ${esc(c.platform_label)} · ${esc(c.reason)}</span></span>
|
||||
</label>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
});
|
||||
area.innerHTML=h;
|
||||
// Stash the data so applyAutotag can read the channel keys back.
|
||||
area._groups=groups;
|
||||
}
|
||||
async function applyAutotag(gi,btn){
|
||||
const area=document.getElementById('autotag-area');
|
||||
const groups=area&&area._groups;if(!groups||!groups[gi])return;
|
||||
const g=groups[gi];
|
||||
const picked=[...area.querySelectorAll(`.at-chk[data-grp="${gi}"]`)]
|
||||
.filter(cb=>cb.checked)
|
||||
.map(cb=>g.channels[+cb.dataset.ci])
|
||||
.map(c=>({platform:c.platform,handle:c.handle}));
|
||||
if(!picked.length){setStatus('No channels selected');return}
|
||||
btn.disabled=true;btn.textContent='Applying…';
|
||||
try{
|
||||
const r=await(await api('/api/autotag/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({group:g.group,channels:picked})})).json();
|
||||
setStatus(`Moved ${r.applied} channel${r.applied===1?'':'s'} → ${g.group}`);
|
||||
await loadLibrary();renderSidebar();
|
||||
initAutotagArea(); // re-analyze: applied channels drop out (now filed)
|
||||
}catch(e){setStatus('Apply failed: '+e.message);btn.disabled=false;btn.textContent='Apply → '+g.group;}
|
||||
}
|
||||
|
||||
/* ── Perceptual dedup (similar content) ─────────────────────────── */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue