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
+25 -14
View File
@@ -85,17 +85,21 @@ silently stay blank instead of erroring.
opt-in transcript search across subtitle files opt-in transcript search across subtitle files
- Course cards show media file count, total video/audio runtime, and - Course cards show media file count, total video/audio runtime, and
completion % at a glance 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
- Hide courses/folders from the browser without touching anything on disk; - Hide courses/folders from the browser without touching anything on disk;
bulk-select to hide or queue several at once bulk-select to hide or queue several at once
- Bulk rename across course/folder names (find & replace) - Bulk rename across course/folder names (find & replace)
**Dashboard** **Dashboard**
- Library-wide stats (courses, lessons completed, time watched, day streak) - Library-wide stats (courses, lessons completed, time watched, time
and a 90-day activity heatmap remaining, day streak) and a 90-day activity heatmap
- Next Up queue (manually curated, reorderable) - Next Up queue (manually curated, reorderable)
- Pick Back Up (courses with progress that have gone stale) - Pick Back Up (courses with progress that have gone stale)
- Recently Added / Recently Viewed - Recently Added / Recently Viewed
- Surprise Me — random pick, weighted toward incomplete courses - Surprise Me — dice icon in the header, random pick weighted toward
incomplete courses
**Playback & progress** **Playback & progress**
- Video/audio player with resize, playback-speed presets, and resume-from- - Video/audio player with resize, playback-speed presets, and resume-from-
@@ -103,9 +107,11 @@ silently stay blank instead of erroring.
- Auto-tracks watch progress and completion per lesson - Auto-tracks watch progress and completion per lesson
- Video/audio durations read via `ffprobe` and cached persistently per - Video/audio durations read via `ffprobe` and cached persistently per
course (`.offlineu_duration_cache.json`), so total runtime and per-lesson course (`.offlineu_duration_cache.json`), so total runtime and per-lesson
length show up before you've ever pressed play — not just after length show up before you've ever pressed play — not just after; the
- Settings → "Precompute Video Lengths" walks the whole library in the same cache backs the library-wide "time remaining" stat
background to populate that cache up front, with live progress - Settings → "Precompute Lengths & Cover Art" walks the whole library in
the background to populate durations and thumbnails up front, with live
progress
**Notes** **Notes**
- Timestamped notes per lesson, capturable via a keyboard shortcut without - Timestamped notes per lesson, capturable via a keyboard shortcut without
@@ -120,9 +126,12 @@ silently stay blank instead of erroring.
5-minute filesystem-scan cache) 5-minute filesystem-scan cache)
**Backup & integrations** **Backup & integrations**
- One-click backup export: settings, hidden-path choices, recent-view - One-click backup export and restore: settings, hidden-path choices, the
history, and every course's progress/notes as a zip (not the course files Next Up queue, recent-view history, Outline config, and every course's
themselves) progress/notes as a zip (not the course files themselves). Restore
overwrites current data and needs a matching course folder to already
exist for each course's progress to land - it's a "put my data back"
action, not a merge
- Optional [Outline](https://www.getoutline.com/) integration — push a - Optional [Outline](https://www.getoutline.com/) integration — push a
lesson's notes to an Outline document/collection lesson's notes to an Outline document/collection
@@ -142,10 +151,11 @@ Everything under `OFFLINEU_DATA_DIR` (app-wide, not tied to a course):
Per-course, written inside the course's own folder on the library volume: Per-course, written inside the course's own folder on the library volume:
| File | Contents | | File | Contents |
| ----------------------------------- | ---------------------------------------------------- | | ----------------------------------- | -------------------------------------------------------- |
| `.offlineu_progress.json` | Per-lesson completed/progress/duration/notes | | `.offlineu_progress.json` | Per-lesson completed/progress/duration/notes |
| `.offlineu_duration_cache.json` | ffprobe duration cache, keyed by (path, size, mtime) | | `.offlineu_duration_cache.json` | ffprobe duration cache, keyed by (path, size, mtime) |
| `.offlineu_thumbnail.jpg` | Auto-generated cover art (only if no manual cover exists) |
--- ---
@@ -162,7 +172,8 @@ MyCourse/
│ └── resources/ │ └── resources/
│ └── extras.md │ └── extras.md
├── .offlineu_progress.json ← created automatically ├── .offlineu_progress.json ← created automatically
── .offlineu_duration_cache.json ← created automatically ── .offlineu_duration_cache.json ← created automatically
└── .offlineu_thumbnail.jpg ← created automatically, only if no manual cover exists
``` ```
No metadata files needed — course/section/lesson names come straight from No metadata files needed — course/section/lesson names come straight from
+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'} QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment', 'homework'}
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'} IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
THUMBNAIL_BASENAMES = ('cover', 'folder', 'thumbnail', 'thumb', 'poster') 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 # 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 # 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]: def find_course_thumbnail(course_path: str) -> Optional[str]:
""" """
Look for a cover image directly inside a course folder (not recursive - A course's cover image: a manually-placed one directly inside the
this only needs to catch the common 'cover.jpg next to the sections' folder (cover/folder/thumbnail/thumb/poster.*, checked non-recursively
layout, not go hunting through every subfolder). - 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: 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): except (PermissionError, OSError):
return None return None
for base in THUMBNAIL_BASENAMES: for base in THUMBNAIL_BASENAMES:
for ext in IMAGE_EXTENSIONS: for ext in IMAGE_EXTENSIONS:
match = names.get(f'{base}{ext}') match = names.get(f'{base}{ext}')
if match: if match:
return str(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( _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 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]: def _course_summary(course_dir: Path) -> Dict[str, Any]:
"""Build the {type, name, path, media_files, hidden, has_thumbnail, """Build the {type, name, path, media_files, hidden, has_thumbnail,
duration_display} shape shared by the Library browser, search results, duration_display} shape shared by the Library browser, search results,
@@ -1185,6 +1257,7 @@ def _scan_library_activity() -> Dict[str, Any]:
lessons_tracked = 0 lessons_tracked = 0
completed_lessons = 0 completed_lessons = 0
watched_seconds = 0 watched_seconds = 0
total_duration_seconds = 0.0
active_dates = set() active_dates = set()
activity_by_date: Dict[str, int] = {} activity_by_date: Dict[str, int] = {}
courses: List[Dict[str, Any]] = [] courses: List[Dict[str, Any]] = []
@@ -1192,6 +1265,7 @@ def _scan_library_activity() -> Dict[str, Any]:
for course_dir in get_all_course_dirs(): for course_dir in get_all_course_dirs():
total_courses += 1 total_courses += 1
total_duration_seconds += _cached_course_duration_seconds(course_dir)
try: try:
with open(course_dir / '.offlineu_progress.json', 'r') as f: with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f) progress = json.load(f)
@@ -1248,6 +1322,7 @@ def _scan_library_activity() -> Dict[str, Any]:
'lessons_tracked': lessons_tracked, 'lessons_tracked': lessons_tracked,
'completed_lessons': completed_lessons, 'completed_lessons': completed_lessons,
'watched_seconds': watched_seconds, 'watched_seconds': watched_seconds,
'total_duration_seconds': total_duration_seconds,
'streak_days': streak_days, 'streak_days': streak_days,
'activity_by_date': activity_by_date, 'activity_by_date': activity_by_date,
'courses': courses, 'courses': courses,
@@ -1269,11 +1344,17 @@ def get_random_incomplete_course() -> Optional[Path]:
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]: 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.""" """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 { return {
'total_courses': scan['total_courses'], 'total_courses': scan['total_courses'],
'lessons_tracked': scan['lessons_tracked'], 'lessons_tracked': scan['lessons_tracked'],
'completed_lessons': scan['completed_lessons'], 'completed_lessons': scan['completed_lessons'],
'watched_display': format_duration(scan['watched_seconds']), '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'] 'streak_days': scan['streak_days']
} }
@@ -2180,9 +2261,10 @@ _duration_prewarm_state: Dict[str, Any] = {
def _run_duration_prewarm(): def _run_duration_prewarm():
""" """
Walk every course in the library and populate its persistent duration Walk every course in the library and populate its persistent duration
cache (see _course_total_duration_seconds) up front, so opening a cache (see _course_total_duration_seconds) and cover-art thumbnail
course for the first time doesn't pay the ffprobe cost right then - (see _generate_course_thumbnail) up front, so opening a course or
it was already paid here, once, in the background. 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 global _duration_prewarm_state
course_dirs = get_all_course_dirs() 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 if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
] ]
_course_total_duration_seconds(course_dir, media_files) _course_total_duration_seconds(course_dir, media_files)
find_course_thumbnail(str(course_dir))
_duration_prewarm_state['done'] += 1 _duration_prewarm_state['done'] += 1
except Exception as e: except Exception as e:
_duration_prewarm_state['error'] = str(e) _duration_prewarm_state['error'] = str(e)
@@ -2210,7 +2293,7 @@ def _run_duration_prewarm():
@app.route('/api/library/prewarm-durations', methods=['POST']) @app.route('/api/library/prewarm-durations', methods=['POST'])
def prewarm_durations_api(): 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']: if not _duration_prewarm_state['running']:
threading.Thread(target=_run_duration_prewarm, daemon=True).start() threading.Thread(target=_run_duration_prewarm, daemon=True).start()
return jsonify(_duration_prewarm_state) 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') @app.route('/api/backup')
def download_backup(): def download_backup():
""" """
Bundle everything that isn't recoverable from the course files Bundle everything that isn't recoverable from the course files
themselves - settings, hidden-path curation, recently-viewed history, themselves - settings, hidden-path curation, the Next Up queue,
and every course's progress/notes file - into a single downloadable recently-viewed history, Outline config, and every course's
zip. Cheap insurance before a NAS migration or a docker volume mistake. 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() buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
for name, path in ( for name, path in BACKUP_ROOT_FILES.items():
('settings.json', SETTINGS_FILE),
('hidden_paths.json', HIDDEN_PATHS_FILE),
('recent_views.json', RECENT_VIEWS_FILE),
):
if os.path.exists(path): if os.path.exists(path):
zf.write(path, name) zf.write(path, name)
@@ -2519,6 +2611,59 @@ def download_backup():
return send_file(buffer, mimetype='application/zip', as_attachment=True, download_name=filename) 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']) @app.route('/load_course', methods=['POST'])
def load_course(): def load_course():
"""Load course from selected directory""" """Load course from selected directory"""
+27 -14
View File
@@ -136,6 +136,25 @@
white-space: nowrap; white-space: nowrap;
} }
.header-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 50%;
background: var(--bg-primary);
border: 1px solid var(--accent);
color: var(--accent);
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.header-icon-btn:hover {
background: var(--accent);
color: white;
}
.course-name-heading { .course-name-heading {
/* course.name is the raw folder name, unlike lesson titles /* course.name is the raw folder name, unlike lesson titles
(cleaned up via _clean_lesson_name) - real course folders are (cleaned up via _clean_lesson_name) - real course folders are
@@ -288,14 +307,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
.surprise-me-btn {
display: block;
width: 100%;
text-align: center;
font-size: 1.05em;
font-weight: 600;
box-sizing: border-box;
}
.progress-bar { .progress-bar {
background: #444; background: #444;
@@ -982,6 +993,8 @@
</a> </a>
{% if course %} {% if course %}
<div class="header-course-badge">{{ course.name }}</div> <div class="header-course-badge">{{ course.name }}</div>
{% elif library_stats and library_stats.total_courses %}
<a href="/random-pick" class="header-icon-btn" title="Surprise Me - random incomplete course">{{ icons.icon('dice', 20) }}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -1153,12 +1166,6 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% if library_stats and library_stats.total_courses %}
<div class="container">
<a class="btn surprise-me-btn" href="/random-pick">{{ icons.icon('dice', 16) }} Surprise Me</a>
</div>
{% endif %}
<div class="container"> <div class="container">
<div class="card" id="library-card"> <div class="card" id="library-card">
<h2><span class="card-header-label">{{ icons.icon('grid') }}Your Courses</span></h2> <h2><span class="card-header-label">{{ icons.icon('grid') }}Your Courses</span></h2>
@@ -1212,6 +1219,12 @@
<div class="stats-tile-value">{{ library_stats.watched_display }}</div> <div class="stats-tile-value">{{ library_stats.watched_display }}</div>
<div class="stats-tile-label">Watched</div> <div class="stats-tile-label">Watched</div>
</div> </div>
{% if library_stats.remaining_display %}
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.remaining_display }}</div>
<div class="stats-tile-label">Remaining</div>
</div>
{% endif %}
<div class="stats-tile"> <div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.streak_days }}</div> <div class="stats-tile-value">{{ library_stats.streak_days }}</div>
<div class="stats-tile-label">Day streak</div> <div class="stats-tile-label">Day streak</div>
+1 -1
View File
@@ -163,7 +163,7 @@
<li><strong>Browse your library</strong> from the main page, or enter a path manually in Settings</li> <li><strong>Browse your library</strong> from the main page, or enter a path manually in Settings</li>
<li><strong>Click a course</strong> to load it - folders drill down, courses open directly</li> <li><strong>Click a course</strong> to load it - folders drill down, courses open directly</li>
<li><strong>Start learning!</strong> Progress, notes, and completion are all saved automatically</li> <li><strong>Start learning!</strong> Progress, notes, and completion are all saved automatically</li>
<li>Not sure what to watch? <strong>{{ icons.icon('dice', 14) }} Surprise Me</strong> at the top of the dashboard picks a random course you haven't finished</li> <li>Not sure what to watch? The dice icon {{ icons.icon('dice', 14) }} in the top-right of the dashboard header picks a random course you haven't finished</li>
</ul> </ul>
</div> </div>
+46 -3
View File
@@ -451,10 +451,10 @@
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span> <span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;"> <div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Video Lengths</button> <button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Lengths &amp; Cover Art</button>
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span> <span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div> </div>
<span class="setting-desc">Reads every video/audio file's length up front and caches it, so course cards in the Library show their total runtime immediately instead of computing it the first time each course is viewed.</span> <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> </div>
</div> </div>
@@ -540,10 +540,18 @@
<h2>Backup &amp; Export</h2> <h2>Backup &amp; Export</h2>
<div class="setting-row"> <div class="setting-row">
<label>Download a backup <label>Download a backup
<span class="setting-desc">Settings, hidden-path curation, recently-viewed history, and every course's progress/notes - not the course files themselves.</span> <span class="setting-desc">Settings, hidden-path curation, the Next Up queue, recently-viewed history, Outline config, and every course's progress/notes - not the course files themselves.</span>
</label> </label>
<a class="btn" href="/api/backup">Download Backup</a> <a class="btn" href="/api/backup">Download Backup</a>
</div> </div>
<div class="setting-row">
<label for="restore-backup-input">Restore from a backup
<span class="setting-desc">Overwrites current settings and progress with what's in the zip. Course files on disk aren't touched.</span>
</label>
<input type="file" id="restore-backup-input" accept=".zip" style="display: none;" onchange="handleRestoreFileChosen(this)">
<button class="btn btn-secondary" onclick="document.getElementById('restore-backup-input').click()">Choose Backup File…</button>
</div>
<span id="restore-backup-status" style="font-size: 0.85em; min-height: 1.2em; display: block;"></span>
</div> </div>
<div class="card"> <div class="card">
@@ -1068,6 +1076,41 @@
.catch(() => {}); .catch(() => {});
})(); })();
function handleRestoreFileChosen(input) {
const file = input.files && input.files[0];
input.value = ''; // allow re-choosing the same file later
if (!file) return;
if (!confirm(`Restore from "${file.name}"? This overwrites your current settings and every course's progress/notes with what's in the backup.`)) {
return;
}
const status = document.getElementById('restore-backup-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Restoring…';
const formData = new FormData();
formData.append('backup', file);
fetch('/api/backup/restore', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = 'var(--success)';
const skippedNote = data.skipped && data.skipped.length
? ` (${data.skipped.length} course${data.skipped.length === 1 ? '' : 's'} skipped - not found in the current library)`
: '';
status.innerHTML = `${ICON_SVGS.check} Restored ${data.restored} file${data.restored === 1 ? '' : 's'}${skippedNote} - reload to see it take effect`;
} else {
status.style.color = 'var(--error)';
status.textContent = data.error || 'Restore failed';
}
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
function loadCourseFromPath() { function loadCourseFromPath() {
const input = document.getElementById('manual-course-path'); const input = document.getElementById('manual-course-path');
const status = document.getElementById('manual-course-status'); const status = document.getElementById('manual-course-status');