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:
@@ -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 = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user