Add auto-thumbnails, library-wide remaining time, backup restore

Auto-generated course thumbnails: find_course_thumbnail() falls back to
grabbing a frame from a course's first video via ffmpeg when there's no
manual cover image, cached to .offlineu_thumbnail.jpg so it only ever
runs once. Folded into the "Precompute" prewarm button, now "Precompute
Lengths & Cover Art".

Library-wide "Remaining" stat: sums every course's already-cached
duration (no ffprobe, just a JSON read) into the dashboard's Library
Stats card, alongside the existing "Watched" figure.

Backup restore: new /api/backup/restore endpoint and a "Choose Backup
File..." control in Settings, with zip-slip and missing-course guards.
Also fixed a gap in the export itself - next_up.json and
outline_config.json weren't being backed up before, so restore wouldn't
have been a true round trip.

Move Surprise Me from a full-width button above the course list to a
dice-icon button in the dashboard header, swapping with the course-name
badge depending on whether a course is loaded.

Update README to cover all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 22:50:48 -04:00
co-authored by Claude Sonnet 5
parent 01a965c0f1
commit acabb89981
5 changed files with 261 additions and 49 deletions
+162 -17
View File
@@ -36,6 +36,7 @@ TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm', '.pdf', '.docx', '.doc', '.rt
QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment', 'homework'}
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
THUMBNAIL_BASENAMES = ('cover', 'folder', 'thumbnail', 'thumb', 'poster')
AUTO_THUMBNAIL_FILENAME = '.offlineu_thumbnail.jpg'
# Base directory the "Library" browser scans for courses, so users don't have
# to type a full filesystem path. Matches the ./courses volume mount in
@@ -560,20 +561,73 @@ def _has_direct_media(directory: Path) -> bool:
def find_course_thumbnail(course_path: str) -> Optional[str]:
"""
Look for a cover image directly inside a course folder (not recursive -
this only needs to catch the common 'cover.jpg next to the sections'
layout, not go hunting through every subfolder).
A course's cover image: a manually-placed one directly inside the
folder (cover/folder/thumbnail/thumb/poster.*, checked non-recursively
- just the common 'cover.jpg next to the sections' layout, not every
subfolder) if there is one, otherwise an auto-generated frame grabbed
from the course's first video. Almost none of this library's courses
ship with real cover art, so without the fallback the grid view would
be mostly bare initials.
"""
course_dir = Path(course_path)
try:
names = {f.name.lower(): f for f in Path(course_path).iterdir() if f.is_file()}
names = {f.name.lower(): f for f in course_dir.iterdir() if f.is_file()}
except (PermissionError, OSError):
return None
for base in THUMBNAIL_BASENAMES:
for ext in IMAGE_EXTENSIONS:
match = names.get(f'{base}{ext}')
if match:
return str(match)
return None
auto_thumb = names.get(AUTO_THUMBNAIL_FILENAME)
if auto_thumb:
return str(auto_thumb)
# Generating (or determining there's nothing to generate from) is the
# one part of this that isn't a cheap directory listing - cache the
# outcome briefly so repeat image requests for a doc-only course don't
# re-walk its files on every load.
return cache_get_or_compute(
f'auto_thumbnail:{course_dir}',
lambda: _generate_course_thumbnail(course_dir)
)
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.
"""
video_files = sorted(
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS
)
if not video_files:
return None
source = video_files[0]
duration = _probe_media_duration_seconds(source) or 0
offset = min(30.0, duration * 0.1) if duration else 5.0
output_path = course_dir / AUTO_THUMBNAIL_FILENAME
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
return str(output_path)
_SECTION_NAME_RE = re.compile(
@@ -985,6 +1039,24 @@ def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) ->
return total if have_any else None
def _cached_course_duration_seconds(course_dir: Path) -> float:
"""
Sum of whatever's already in a course's persistent duration cache file,
without walking its files or ever touching ffprobe - for the
library-wide "time remaining" stat, which needs to stay cheap enough to
compute on every dashboard load (it runs inside the same per-course
loop as _scan_library_activity). A course that's never been viewed,
searched, or prewarmed yet just contributes 0 here; run "Precompute
Lengths & Cover Art" in Settings for a complete total.
"""
try:
with open(course_dir / DURATION_CACHE_FILENAME, 'r') as f:
cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return 0.0
return sum(entry.get('duration_seconds') or 0 for entry in cache.values())
def _course_summary(course_dir: Path) -> Dict[str, Any]:
"""Build the {type, name, path, media_files, hidden, has_thumbnail,
duration_display} shape shared by the Library browser, search results,
@@ -1185,6 +1257,7 @@ def _scan_library_activity() -> Dict[str, Any]:
lessons_tracked = 0
completed_lessons = 0
watched_seconds = 0
total_duration_seconds = 0.0
active_dates = set()
activity_by_date: Dict[str, int] = {}
courses: List[Dict[str, Any]] = []
@@ -1192,6 +1265,7 @@ def _scan_library_activity() -> Dict[str, Any]:
for course_dir in get_all_course_dirs():
total_courses += 1
total_duration_seconds += _cached_course_duration_seconds(course_dir)
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
@@ -1248,6 +1322,7 @@ def _scan_library_activity() -> Dict[str, Any]:
'lessons_tracked': lessons_tracked,
'completed_lessons': completed_lessons,
'watched_seconds': watched_seconds,
'total_duration_seconds': total_duration_seconds,
'streak_days': streak_days,
'activity_by_date': activity_by_date,
'courses': courses,
@@ -1269,11 +1344,17 @@ def get_random_incomplete_course() -> Optional[Path]:
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
remaining_seconds = max(0, scan['total_duration_seconds'] - scan['watched_seconds'])
return {
'total_courses': scan['total_courses'],
'lessons_tracked': scan['lessons_tracked'],
'completed_lessons': scan['completed_lessons'],
'watched_display': format_duration(scan['watched_seconds']),
# None until at least some course's duration cache has been
# populated (via viewing/searching the library or running
# "Precompute Lengths & Cover Art") - a remaining estimate from a
# library that's mostly uncached would just be misleadingly low.
'remaining_display': format_duration(remaining_seconds) if scan['total_duration_seconds'] else None,
'streak_days': scan['streak_days']
}
@@ -2180,9 +2261,10 @@ _duration_prewarm_state: Dict[str, Any] = {
def _run_duration_prewarm():
"""
Walk every course in the library and populate its persistent duration
cache (see _course_total_duration_seconds) up front, so opening a
course for the first time doesn't pay the ffprobe cost right then -
it was already paid here, once, in the background.
cache (see _course_total_duration_seconds) and cover-art thumbnail
(see _generate_course_thumbnail) up front, so opening a course or
browsing the library for the first time doesn't pay either cost right
then - both were already paid here, once, in the background.
"""
global _duration_prewarm_state
course_dirs = get_all_course_dirs()
@@ -2196,6 +2278,7 @@ def _run_duration_prewarm():
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]
_course_total_duration_seconds(course_dir, media_files)
find_course_thumbnail(str(course_dir))
_duration_prewarm_state['done'] += 1
except Exception as e:
_duration_prewarm_state['error'] = str(e)
@@ -2210,7 +2293,7 @@ def _run_duration_prewarm():
@app.route('/api/library/prewarm-durations', methods=['POST'])
def prewarm_durations_api():
"""Kick off (or report on an already-running) background scan of every course's video/audio duration."""
"""Kick off (or report on an already-running) background scan of every course's video/audio duration and cover art."""
if not _duration_prewarm_state['running']:
threading.Thread(target=_run_duration_prewarm, daemon=True).start()
return jsonify(_duration_prewarm_state)
@@ -2489,21 +2572,30 @@ def reset_settings_api():
})
# Shared between download_backup and restore_backup so the two can never
# drift out of sync with each other about what belongs at the archive root.
BACKUP_ROOT_FILES = {
'settings.json': SETTINGS_FILE,
'hidden_paths.json': HIDDEN_PATHS_FILE,
'next_up.json': NEXT_UP_FILE,
'recent_views.json': RECENT_VIEWS_FILE,
'outline_config.json': OUTLINE_CONFIG_FILE,
}
@app.route('/api/backup')
def download_backup():
"""
Bundle everything that isn't recoverable from the course files
themselves - settings, hidden-path curation, recently-viewed history,
and every course's progress/notes file - into a single downloadable
zip. Cheap insurance before a NAS migration or a docker volume mistake.
themselves - settings, hidden-path curation, the Next Up queue,
recently-viewed history, Outline config, and every course's
progress/notes file - into a single downloadable zip. Cheap insurance
before a NAS migration or a docker volume mistake; see restore_backup
for the other half of this round trip.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, path in (
('settings.json', SETTINGS_FILE),
('hidden_paths.json', HIDDEN_PATHS_FILE),
('recent_views.json', RECENT_VIEWS_FILE),
):
for name, path in BACKUP_ROOT_FILES.items():
if os.path.exists(path):
zf.write(path, name)
@@ -2519,6 +2611,59 @@ def download_backup():
return send_file(buffer, mimetype='application/zip', as_attachment=True, download_name=filename)
@app.route('/api/backup/restore', methods=['POST'])
def restore_backup():
"""
Restore a zip produced by download_backup: the root-level app files in
BACKUP_ROOT_FILES, plus each course's progress file from
progress/<relative-course-path>/.offlineu_progress.json. Overwrites
whatever's currently there - this is a deliberate "put my data back"
action, not a merge.
"""
uploaded = request.files.get('backup')
if not uploaded or not uploaded.filename:
return jsonify({'success': False, 'error': 'No backup file selected'}), 400
try:
zf = zipfile.ZipFile(io.BytesIO(uploaded.read()))
except zipfile.BadZipFile:
return jsonify({'success': False, 'error': 'That file is not a valid zip archive'}), 400
os.makedirs(DATA_DIR, exist_ok=True)
names = set(zf.namelist())
restored = []
skipped = []
for archive_name, dest_path in BACKUP_ROOT_FILES.items():
if archive_name not in names:
continue
with zf.open(archive_name) as src, open(dest_path, 'wb') as dst:
dst.write(src.read())
restored.append(archive_name)
library_root = os.path.abspath(get_library_root())
for name in names:
if not (name.startswith('progress/') and name.endswith('/.offlineu_progress.json')):
continue
relative = name[len('progress/'):-len('/.offlineu_progress.json')]
dest_dir = os.path.abspath(os.path.join(library_root, relative))
# Guard against a zip entry trying to write outside the library
# root (zip-slip) - restore is a trusted, deliberate admin action,
# but the archive's paths themselves shouldn't be trusted blindly.
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
skipped.append(relative)
continue
if not os.path.isdir(dest_dir):
skipped.append(relative)
continue
with zf.open(name) as src, open(os.path.join(dest_dir, '.offlineu_progress.json'), 'wb') as dst:
dst.write(src.read())
restored.append(name)
invalidate_cache()
return jsonify({'success': True, 'restored': len(restored), 'skipped': skipped})
@app.route('/load_course', methods=['POST'])
def load_course():
"""Load course from selected directory"""