From 36d9e7f89ba95dd9d34439fa430eb9a66ca5cb01 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Mon, 24 Aug 2026 20:20:36 -0400 Subject: [PATCH] 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 --- .gitignore | 2 + CHANGELOG.md | 1 + README.md | 37 ++++-- VERSION | 2 +- offlineu_core.py | 227 +++++++++++++++++++++++++++++--- templates/course_dashboard.html | 42 ++++-- templates/lesson_view.html | 61 ++++++++- templates/settings.html | 80 +++++++++++ templates/unsorted.html | 119 +++++++++++++++++ 9 files changed, 531 insertions(+), 40 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f81cc4..b6c395c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 9f25281..a117652 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 2384778..64f37da 100644 --- a/VERSION +++ b/VERSION @@ -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 diff --git a/offlineu_core.py b/offlineu_core.py index 1ab105e..d4b5fa3 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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.""" diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 01f8f7c..57feeb3 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -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 - ? `
` + const ringHtml = pct + ? `
${pct >= 100 ? '' : ''}
` : ''; 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 `
${selectableAttrs(item)} -
${iconHtml}
+
${iconHtml}${ringHtml}
${item.name}
${metaBits.join(' · ')}
- ${progressBar}
`; } diff --git a/templates/lesson_view.html b/templates/lesson_view.html index 5ba6e4c..f05b5eb 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -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 @@

↘ Drag the bottom-right corner to resize the player

{% endif %} + {% if next_lesson %} + + {% endif %} + {% if lesson.audio_file %}

Audio