Remove unreliable duplicate-lesson-file matching

Filename-similarity matching within a folder can't distinguish a real
re-download duplicate from a course that splits one topic across
several numbered files ("...Part 1"/"...Part 2", "-1"/"-2"/"-3") -
both look like near-100% matches by name alone, and the latter is a
common, completely normal pattern. This produced hundreds of false
positives in practice. Removes Duplicate Lesson Files entirely
(detection function, its two API routes, and the File Management UI
section); whole-course-folder matching (Duplicate Courses) is
unaffected and stays the only duplicate scan in the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 08:25:37 -04:00
co-authored by Claude Sonnet 5
parent 8220bb542a
commit 72c7f67c3d
5 changed files with 13 additions and 233 deletions
-119
View File
@@ -687,16 +687,6 @@
<div id="dup-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Lesson Files</h2>
<p class="section-desc">Scan for lesson files sitting in the same folder that look like the same lesson downloaded twice - a re-download that landed alongside the original instead of replacing it. Compares files within each folder only, with a higher match threshold than Duplicate Courses, so two genuinely different lessons on a similar topic aren't flagged.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="dup-lessons-scan-btn" onclick="scanDuplicateLessons()">{{ icons.icon('refresh', 14) }} Scan for Duplicate Lessons</button>
<span id="dup-lessons-status"></span>
</div>
<div id="dup-lessons-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
<div class="card">
@@ -1943,115 +1933,6 @@
.catch(() => { btnEl.disabled = false; });
}
// ---- Duplicate Lesson Files ----
function scanDuplicateLessons() {
const btn = document.getElementById('dup-lessons-scan-btn');
const status = document.getElementById('dup-lessons-status');
const results = document.getElementById('dup-lessons-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning…';
results.innerHTML = '';
fetch('/api/library/duplicate-lessons')
.then(r => r.json())
.then(data => {
btn.disabled = false;
const groups = data.groups || [];
if (!groups.length) {
status.style.color = 'var(--success)';
status.textContent = 'No likely duplicate lesson files found.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = `${groups.length} possible duplicate group${groups.length === 1 ? '' : 's'}.`;
results.innerHTML = groups.map(group => `
<div class="dup-group">
<div class="dup-group-header">
<span>${escapeHtml(group.course_name)}${Math.round(group.similarity * 100)}% similar</span>
<button class="btn btn-secondary btn-sm" onclick="ignoreDuplicateLessonGroup(this)">Not a duplicate - ignore</button>
</div>
${group.files.map(f => `
<div class="dup-course-row" data-path="${escapeAttr(f.path)}">
<span class="dup-course-name">${escapeHtml(f.name)}</span>
<span class="dup-course-path">${escapeHtml(f.human)}</span>
<button class="btn btn-danger btn-sm" onclick="deleteDuplicateLessonFile(this, '${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')">Delete</button>
</div>
`).join('')}
</div>
`).join('');
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function deleteDuplicateLessonFile(btnEl, path, name) {
if (!confirm(`Permanently delete "${name}"?\n\n${path}\n\nThis cannot be undone.`)) return;
btnEl.disabled = true;
fetch('/api/library/delete-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
btnEl.disabled = false;
alert(data.error || 'Delete failed');
return;
}
const row = btnEl.closest('.dup-course-row');
const group = row ? row.closest('.dup-group') : null;
if (row) row.remove();
if (group && group.querySelectorAll('.dup-course-row').length < 2) {
group.remove();
}
refreshDupLessonsStatusText();
})
.catch(() => {
btnEl.disabled = false;
alert('Could not reach the server.');
});
}
function refreshDupLessonsStatusText() {
const status = document.getElementById('dup-lessons-status');
const remaining = document.querySelectorAll('#dup-lessons-results .dup-group').length;
if (remaining === 0) {
status.style.color = 'var(--success)';
status.textContent = 'No possible duplicates remaining.';
} else {
status.style.color = 'var(--text-muted)';
status.textContent = `${remaining} possible duplicate group${remaining === 1 ? '' : 's'} remaining.`;
}
}
function ignoreDuplicateLessonGroup(btnEl) {
const group = btnEl.closest('.dup-group');
if (!group) return;
const paths = Array.from(group.querySelectorAll('.dup-course-row')).map(el => el.dataset.path);
if (paths.length < 2) return;
if (!confirm('Mark these as NOT duplicates of each other? They won\'t be flagged together again (reversible from "Ignored matches" under Duplicate Courses).')) return;
btnEl.disabled = true;
fetch('/api/library/duplicates/ignore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths })
})
.then(r => r.json())
.then(data => {
if (data.success) {
group.remove();
refreshDupLessonsStatusText();
} else {
btnEl.disabled = false;
}
})
.catch(() => { btnEl.disabled = false; });
}
// ---- Stale References ----
let lastStaleItems = [];