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:
@@ -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
@@ -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"""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 & 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 & Export</h2>
|
<h2>Backup & 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');
|
||||||
|
|||||||
Reference in New Issue
Block a user