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
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+1
View File
@@ -1,3 +1,4 @@
2026-08-25 00:20 UTC — Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New
2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen
2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load)
+29 -8
View File
@@ -101,7 +101,11 @@ silently stay blank instead of erroring.
completion % at a glance
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
`poster` image if a course has one, otherwise auto-generates one via
`ffmpeg` from a frame of the course's first video
`ffmpeg` - samples a few candidate frames from the first several seconds
of the course's first video (where an intro title card typically lives)
and keeps the one that compresses to the largest JPEG, a cheap proxy for
"has the most going on" that favors a title card/logo over a blank
fade-in or a plain frame of the presenter
- **File Management** ([/unsorted](templates/unsorted.html), its own item
("Files") in the bottom tab bar alongside Home/Notes/Help/Settings): a
dedicated page for everything that touches files on disk, since it needs
@@ -181,13 +185,20 @@ silently stay blank instead of erroring.
difference doesn't hide a real duplicate. Groups by similarity (union of
any two courses over the threshold, transitively) rather than showing
raw pairs; each course in a group gets a one-click Hide, or a **Delete**
that permanently removes it from disk (the one destructive action in the
whole app - confirmed with the full path before it runs). A group can
also be marked **"Not a duplicate"** if the match is wrong, which
excludes that specific pair from future scans without touching anything
else that happens to match one of those courses; ignored pairs are
listed (and reversible) under "Ignored matches," and persist through
backup/restore alongside hidden paths and Next Up.
that permanently removes it from disk (confirmed with the full path
before it runs). A group can also be marked **"Not a duplicate"** if the
match is wrong, which excludes that specific pair from future scans
without touching anything else that happens to match one of those
courses; ignored pairs are listed (and reversible) under "Ignored
matches," and persist through backup/restore alongside hidden paths and
Next Up.
- *Duplicate Lesson Files*: the same idea one level down - media files
sitting in the same folder that look like the same lesson downloaded
twice (e.g. a re-download that landed alongside the original instead of
replacing it). Compares only within a folder, at a higher match
threshold than Duplicate Courses, so two different lessons on a similar
topic don't get flagged; shares its ignore list with Duplicate Courses.
Delete here removes a single file, not a whole course.
- *Clean Up Stale References*: finds entries in the hidden-paths list,
Next Up queue, Favorites, or Recently Viewed history that point at a path
no longer on disk - normally from renaming/moving/deleting a course
@@ -210,10 +221,15 @@ silently stay blank instead of erroring.
- Recently Added / Recently Viewed
- Surprise Me — dice icon in the header, random pick weighted toward
incomplete courses
- Grid view shows a small progress ring in the corner of each course's
thumbnail (a checkmark once complete) instead of a separate bar, so
completion reads at a glance without switching to list view
**Playback & progress**
- Video/audio player with resize, playback-speed presets, and resume-from-
last-position
- Auto-play next lesson when one ends, with a cancelable few-second
countdown - on by default, toggle it off in Settings → Video Player
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next
lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the
@@ -226,6 +242,11 @@ silently stay blank instead of erroring.
- Settings → "Precompute Lengths & Cover Art" walks the whole library in
the background to populate durations and thumbnails up front, with live
progress
- Settings → "Regenerate Thumbnails" deletes and re-runs auto-generated
cover art for every course that has one (manually-placed covers are
never touched) - the only way to pick up an improvement to how
thumbnails are picked on courses whose thumbnail was already cached
under the old logic, since it's otherwise served from disk forever
**Notes**
- Timestamped notes per lesson, capturable via a keyboard shortcut without
+1 -1
View File
@@ -1 +1 @@
2026-08-24 23:22 UTC — add Favorites, storage drill-down, shortcuts, and What's New
2026-08-25 00:20 UTC — autoplay, progress rings, duplicate lessons, title-card thumbnails
+210 -17
View File
@@ -164,6 +164,7 @@ DEFAULT_SETTINGS = {
'video_width': '', # '' = responsive full-width; else last dragged size, in px
'video_height': '',
'playback_speed': '1', # video/audio playback rate, as a string (see SETTINGS_CHOICES)
'autoplay_next': True, # advance to the next lesson automatically when one ends
}
# Bounds for the persisted video player size, to reject garbage values
@@ -551,6 +552,8 @@ def load_settings() -> Dict[str, Any]:
elif key in VIDEO_SIZE_BOUNDS:
if value == '' or (isinstance(value, int) and not isinstance(value, bool)):
settings[key] = value
elif key == 'autoplay_next' and isinstance(value, bool):
settings[key] = value
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
settings[key] = value
except (json.JSONDecodeError, OSError) as e:
@@ -588,6 +591,8 @@ def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]:
if not (lo <= num <= hi):
raise ValueError(f"{key} must be between {lo} and {hi}")
current[key] = num
elif key == 'autoplay_next' and isinstance(value, bool):
current[key] = value
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
current[key] = value
os.makedirs(DATA_DIR, exist_ok=True)
@@ -913,13 +918,23 @@ def find_course_thumbnail(course_path: str) -> Optional[str]:
def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
"""
Grab a single frame from the course's first video as stand-in cover
art, via ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever
runs once per course - the next call finds that file directly through
the fast path above instead of hitting this function again. Returns
None (nothing cached to disk) if the course has no video at all, so a
video added later is still picked up on the next request past the
short in-memory cache above.
Grab a frame from the course's first video as stand-in cover art, via
ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever runs once
per course - the next call finds that file directly through the fast
path above instead of hitting this function again. Returns None
(nothing cached to disk) if the course has no video at all, so a video
added later is still picked up on the next request past the short
in-memory cache above.
Course intros typically show a title card (course/lesson name, maybe
a logo) for the first several seconds before cutting to the
presenter - a single frame at a fixed offset can easily land past
that cut and grab someone mid-sentence instead. Rather than guess one
offset, this samples a handful of candidates in the first ~8 seconds
and keeps the one with the largest resulting JPEG: a title card (text,
graphics, a logo) compresses to a noticeably bigger file than a blank
fade-in or a plain talking-head frame, which is a cheap enough proxy
for "has more going on" without any real image analysis.
"""
video_files = sorted(
f for f in course_dir.rglob('*')
@@ -930,19 +945,43 @@ def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
source = video_files[0]
duration = _probe_media_duration_seconds(source) or 0
offset = min(30.0, duration * 0.1) if duration else 5.0
max_offset = min(8.0, duration * 0.5) if duration else 6.0
candidate_offsets = sorted({o for o in (1.0, 2.5, 4.0, 6.0) if o <= max_offset}) or [1.0]
output_path = course_dir / AUTO_THUMBNAIL_FILENAME
best_candidate = None
best_size = -1
candidate_paths = []
try:
result = subprocess.run(
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(output_path)],
capture_output=True, timeout=20
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0 or not output_path.exists():
return None
for i, offset in enumerate(candidate_offsets):
candidate_path = course_dir / f'.offlineu_thumbnail_candidate_{i}.jpg'
candidate_paths.append(candidate_path)
try:
result = subprocess.run(
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(candidate_path)],
capture_output=True, timeout=20
)
except (OSError, subprocess.SubprocessError):
continue
if result.returncode != 0 or not candidate_path.exists():
continue
size = candidate_path.stat().st_size
if size > best_size:
best_size = size
best_candidate = candidate_path
if best_candidate is None:
return None
shutil.move(str(best_candidate), str(output_path))
finally:
for candidate_path in candidate_paths:
if candidate_path.exists():
try:
candidate_path.unlink()
except OSError:
pass
return str(output_path)
@@ -1814,6 +1853,64 @@ def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]
return groups
def _find_duplicate_lesson_files(min_similarity: float = 0.75) -> List[Dict[str, Any]]:
"""
Within each course, media files sitting in the same folder whose
names look like the same lesson downloaded twice - e.g. "03 Setting
Up Your Environment.mp4" and "03 Setting Up Your Environment (1).mp4"
left behind by a re-download that landed in the same section. Scoped
to siblings in the same folder, unlike Duplicate Courses' whole-library
comparison: two genuinely different lessons can share a lot of
vocabulary ("Part 1"/"Part 2" of a topic) without being duplicates, so
same-folder pairing plus a higher similarity threshold keeps this
conservative. Shares Duplicate Courses' ignore list (get_ignored_
duplicate_pairs) - a pair is just a pair of paths there, whether
they're course folders or files. Read-only.
"""
ignored_pairs = get_ignored_duplicate_pairs()
groups = []
for course_dir in get_all_course_dirs():
by_folder: Dict[Path, List[Path]] = {}
for f in course_dir.rglob('*'):
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
by_folder.setdefault(f.parent, []).append(f)
for files in by_folder.values():
if len(files) < 2:
continue
entries = [{'path': f, 'tokens': _tokenize(f.stem)} for f in files]
n = len(entries)
for i in range(n):
for j in range(i + 1, n):
path_i, path_j = str(entries[i]['path']), str(entries[j]['path'])
if tuple(sorted((path_i, path_j))) in ignored_pairs:
continue
a, b = entries[i]['tokens'], entries[j]['tokens']
union_tokens = a | b
if not union_tokens:
continue
similarity = len(a & b) / len(union_tokens)
if similarity < min_similarity:
continue
try:
size_i = entries[i]['path'].stat().st_size
size_j = entries[j]['path'].stat().st_size
except OSError:
continue
groups.append({
'course_name': course_dir.name,
'course_path': str(course_dir),
'files': sorted([
{'path': path_i, 'name': entries[i]['path'].name, 'bytes': size_i, 'human': _format_bytes(size_i)},
{'path': path_j, 'name': entries[j]['path'].name, 'bytes': size_j, 'human': _format_bytes(size_j)},
], key=lambda f: f['name'].lower()),
'similarity': round(similarity, 2),
})
groups.sort(key=lambda g: g['similarity'], reverse=True)
return groups
def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
"""
Entries in hidden_paths.json / next_up.json / recent_views.json that
@@ -3652,6 +3749,47 @@ def undo_last_action_api():
})
@app.route('/api/library/duplicate-lessons')
def duplicate_lesson_files_api():
"""Scan for lesson files within the same course/folder that look like
the same lesson downloaded twice (see _find_duplicate_lesson_files).
Manual trigger, same reasoning as Duplicate Courses."""
groups = _find_duplicate_lesson_files()
return jsonify({'groups': groups})
@app.route('/api/library/delete-file', methods=['POST'])
def delete_library_file_api():
"""
Permanently delete a single media file from disk - the file-level
counterpart to /api/library/delete, used by Duplicate Lesson Files.
Scoped to files (not directories) inside the library root with a
recognized media extension, so it can't be pointed at something else.
"""
data = request.json or {}
path = (data.get('path') or '').strip()
if not path:
return jsonify({'success': False, 'error': 'Missing path'}), 400
library_root = os.path.abspath(get_library_root())
target_abs = os.path.abspath(path)
if not target_abs.startswith(library_root + os.sep):
return jsonify({'success': False, 'error': 'Path is outside the library'}), 403
if not os.path.isfile(target_abs):
return jsonify({'success': False, 'error': 'No longer exists - already deleted?'}), 404
if Path(target_abs).suffix.lower() not in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
return jsonify({'success': False, 'error': 'Not a media file'}), 400
try:
os.remove(target_abs)
except OSError as e:
return jsonify({'success': False, 'error': str(e)}), 500
invalidate_cache()
return jsonify({'success': True})
@app.route('/api/library/duplicates')
def duplicate_courses_api():
"""Scan for courses that look like copies of each other by name (see
@@ -3849,6 +3987,61 @@ def prewarm_durations_status_api():
return jsonify(_duration_prewarm_state)
# Same single-process-thread pattern as the duration prewarm above.
_thumbnail_regen_state: Dict[str, Any] = {
'running': False,
'total': 0,
'done': 0,
'error': None,
}
def _run_thumbnail_regen():
"""
Delete and regenerate every course's auto-generated thumbnail
(.offlineu_thumbnail.jpg) using _generate_course_thumbnail's current
logic - the only way to pick up an improved generation heuristic (e.g.
the title-card sampling added after thumbnails were already cached)
on courses whose thumbnail was generated before that change, since
find_course_thumbnail's normal fast path just keeps serving whatever
is already cached on disk forever. Only touches courses currently
using an auto-generated thumbnail - a manually-placed cover/folder/
thumbnail/thumb/poster file always wins and is never removed.
"""
global _thumbnail_regen_state
to_regen = [c for c in get_all_course_dirs() if (c / AUTO_THUMBNAIL_FILENAME).exists()]
_thumbnail_regen_state.update({
'running': True, 'total': len(to_regen), 'done': 0, 'error': None,
})
try:
for course_dir in to_regen:
try:
(course_dir / AUTO_THUMBNAIL_FILENAME).unlink()
except OSError:
pass
_generate_course_thumbnail(course_dir)
_thumbnail_regen_state['done'] += 1
except Exception as e:
_thumbnail_regen_state['error'] = str(e)
finally:
_thumbnail_regen_state['running'] = False
invalidate_cache()
@app.route('/api/library/regenerate-thumbnails', methods=['POST'])
def regenerate_thumbnails_api():
"""Kick off (or report on an already-running) background regeneration of every auto-generated course thumbnail."""
if not _thumbnail_regen_state['running']:
threading.Thread(target=_run_thumbnail_regen, daemon=True).start()
return jsonify(_thumbnail_regen_state)
@app.route('/api/library/regenerate-thumbnails/status', methods=['GET'])
def regenerate_thumbnails_status_api():
"""Poll the current progress of a thumbnail regeneration run."""
return jsonify(_thumbnail_regen_state)
@app.route('/api/hidden-paths', methods=['GET'])
def get_hidden_paths_api():
"""List currently-hidden course/directory paths, with display names."""
+29 -13
View File
@@ -782,6 +782,7 @@
transform: translateY(-2px);
}
.grid-card-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius);
@@ -822,15 +823,32 @@
font-size: 0.75em;
color: var(--text-muted);
}
.grid-card-progress-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
.grid-card-progress-ring {
position: absolute;
bottom: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: conic-gradient(var(--accent) calc(var(--pct) * 1%), rgba(0, 0, 0, 0.4) 0);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.grid-card-progress-fill {
height: 100%;
background: var(--accent);
.grid-card-progress-ring::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--bg-secondary);
}
.grid-card-progress-ring-check {
position: relative;
color: var(--accent);
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.transcript-search-toggle {
@@ -1704,19 +1722,17 @@
: courseInitialHtml(item.name);
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
const pct = item.completion_percentage;
const progressBar = pct
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
const ringHtml = pct
? `<div class="grid-card-progress-ring" style="--pct: ${pct};" title="${pct}% complete">${pct >= 100 ? '<span class="grid-card-progress-ring-check"></span>' : ''}</div>`
: '';
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
if (item.duration_display) metaBits.push(item.duration_display);
if (pct) metaBits.push(`${pct}% done`);
return `
<div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}</div>
<div class="grid-card-thumb-wrap">${iconHtml}${ringHtml}</div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
${progressBar}
</div>
`;
}
+60 -1
View File
@@ -164,6 +164,17 @@
color: var(--text-muted);
margin-top: 6px;
}
.autoplay-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 10px;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: var(--radius);
font-size: 0.9em;
}
.speed-controls {
display: flex;
align-items: center;
@@ -539,6 +550,13 @@
<p class="resize-hint">↘ Drag the bottom-right corner to resize the player</p>
{% endif %}
{% if next_lesson %}
<div class="autoplay-banner" id="autoplay-banner" style="display: none;">
<span>Playing next lesson in <span id="autoplay-countdown">5</span>s…</span>
<button type="button" class="btn btn-secondary" onclick="cancelAutoplay()">Cancel</button>
</div>
{% endif %}
{% if lesson.audio_file %}
<h3>Audio</h3>
<audio controls preload="metadata" id="audio-player">
@@ -800,13 +818,16 @@
// Restore the persisted playback speed and highlight the active
// button; clicking a button applies it immediately and persists
// it via /api/settings, mirroring the video-size pattern above.
let autoplayNextEnabled = true;
if (activeMedia) {
fetch('/api/settings')
.then(r => r.json())
.then(data => {
const speed = (data.settings || {}).playback_speed || '1';
const settings = data.settings || {};
const speed = settings.playback_speed || '1';
activeMedia.playbackRate = parseFloat(speed);
highlightSpeedButton(speed);
autoplayNextEnabled = settings.autoplay_next !== false;
})
.catch(() => {});
}
@@ -827,6 +848,8 @@
}).catch(() => {});
}
const NEXT_LESSON_URL = {{ ('/lesson/' ~ next_lesson)|tojson if next_lesson else 'null' }};
// ---- Outline topic chooser + push-on-leave ----
const LESSON_PATH = {{ lesson_path|tojson }};
const LESSON_TITLE = {{ lesson.title|tojson }};
@@ -1210,7 +1233,43 @@
activeMedia.addEventListener('ended', function() {
saveProgress(activeMedia.currentTime, true);
markAsCompleted();
if (NEXT_LESSON_URL && autoplayNextEnabled) startAutoplayCountdown();
});
// Replaying the just-ended lesson (rather than letting it sit)
// is a clear enough signal the user doesn't want to leave yet.
activeMedia.addEventListener('play', cancelAutoplay);
}
let autoplayTimer = null;
let autoplaySecondsLeft = 5;
function startAutoplayCountdown() {
const banner = document.getElementById('autoplay-banner');
const countdownEl = document.getElementById('autoplay-countdown');
if (!banner || !countdownEl) return;
autoplaySecondsLeft = 5;
countdownEl.textContent = autoplaySecondsLeft;
banner.style.display = 'flex';
autoplayTimer = setInterval(function() {
autoplaySecondsLeft -= 1;
if (autoplaySecondsLeft <= 0) {
clearInterval(autoplayTimer);
autoplayTimer = null;
window.location.href = NEXT_LESSON_URL;
return;
}
countdownEl.textContent = autoplaySecondsLeft;
}, 1000);
}
function cancelAutoplay() {
if (autoplayTimer) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
const banner = document.getElementById('autoplay-banner');
if (banner) banner.style.display = 'none';
}
function saveProgress(progressSeconds, completed = false) {
+80
View File
@@ -359,6 +359,12 @@
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Reads every video/audio file's length and generates cover art for any course without one, up front - so course cards in the Library show their runtime and artwork immediately instead of computing it the first time each course is viewed.</span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="regen-thumbnails-btn" onclick="startThumbnailRegen()">{{ icons.icon('refresh', 14) }} Regenerate Thumbnails</button>
<span id="regen-thumbnails-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Re-runs auto-generated cover art from scratch for every course that has one (not manually-placed covers) - useful after an improvement to how thumbnails are picked, since existing ones are otherwise cached forever.</span>
</div>
</div>
@@ -412,6 +418,12 @@
{% endfor %}
</select>
</div>
<div class="setting-row">
<label for="autoplay_next">Auto-play next lesson
<span class="setting-desc">When a lesson finishes, automatically move on to the next one after a few seconds (cancelable)</span>
</label>
<input type="checkbox" id="autoplay_next" data-setting="autoplay_next" {% if settings.autoplay_next %}checked{% endif %}>
</div>
</div>
<div class="card">
@@ -541,6 +553,10 @@
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value));
});
document.querySelectorAll('input[type="checkbox"][data-setting]').forEach(el => {
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.checked));
});
const accentPicker = document.getElementById('accent-picker');
const accentHex = document.getElementById('accent-hex');
@@ -648,6 +664,70 @@
.catch(() => {});
})();
function updateThumbnailRegenStatus(data) {
const btn = document.getElementById('regen-thumbnails-btn');
const status = document.getElementById('regen-thumbnails-status');
if (data.running) {
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = `Regenerating… ${data.done}/${data.total}`;
return;
}
btn.disabled = false;
if (data.error) {
status.style.color = 'var(--error)';
status.textContent = `Error: ${data.error}`;
} else if (data.total) {
status.style.color = 'var(--success)';
status.innerHTML = `${ICON_SVGS.check} Regenerated ${data.total} thumbnail${data.total === 1 ? '' : 's'}`;
setTimeout(() => { status.textContent = ''; }, 5000);
} else {
status.style.color = 'var(--text-muted)';
status.textContent = 'No auto-generated thumbnails to regenerate';
setTimeout(() => { status.textContent = ''; }, 5000);
}
}
function pollThumbnailRegenStatus() {
fetch('/api/library/regenerate-thumbnails/status')
.then(r => r.json())
.then(data => {
updateThumbnailRegenStatus(data);
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
})
.catch(() => {});
}
function startThumbnailRegen() {
const status = document.getElementById('regen-thumbnails-status');
document.getElementById('regen-thumbnails-btn').disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Starting…';
fetch('/api/library/regenerate-thumbnails', { method: 'POST' })
.then(r => r.json())
.then(data => {
updateThumbnailRegenStatus(data);
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
document.getElementById('regen-thumbnails-btn').disabled = false;
});
}
(function resumeThumbnailRegenStatusIfRunning() {
fetch('/api/library/regenerate-thumbnails/status')
.then(r => r.json())
.then(data => {
if (data.running) {
updateThumbnailRegenStatus(data);
setTimeout(pollThumbnailRegenStatus, 1500);
}
})
.catch(() => {});
})();
function handleRestoreFileChosen(input) {
const file = input.files && input.files[0];
input.value = ''; // allow re-choosing the same file later
+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 = [];