Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails

- Auto-play next lesson on end, with a cancelable countdown and a
  Settings toggle (default on).
- Grid-view course cards show a small progress ring (checkmark at
  100%) instead of a separate bar.
- Duplicate Lesson Files: scans within each course/folder for media
  files that look like the same lesson downloaded twice, with per-file
  delete and a shared ignore list with Duplicate Courses.
- Auto-generated cover art now samples a few early candidate frames
  and keeps the largest JPEG, favoring an intro title card over a
  blank fade-in or a plain presenter frame.
- Settings -> "Regenerate Thumbnails" re-runs that logic for every
  course with an auto-generated thumbnail (never touches manual
  covers), so already-cached thumbnails can pick up the improvement.
- Add .gitignore for __pycache__/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 20:20:36 -04:00
co-authored by Claude Sonnet 5
parent fc83876abe
commit 36d9e7f89b
9 changed files with 531 additions and 40 deletions
+119
View File
@@ -687,6 +687,16 @@
<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">
@@ -1933,6 +1943,115 @@
.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 = [];