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:
+210
-17
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user