Add duration prewarm button; show video lengths in the loaded-course view

Settings gets a "Precompute Video Lengths" button that walks the whole
library in a background thread, populating every course's persistent
ffprobe duration cache up front instead of paying that cost the first
time each course card is viewed. Progress polls live while it runs and
picks back up correctly if you navigate away mid-scan.

Separately, the loaded-course lesson tree only ever showed a lesson's
duration once you'd actually played it (from progress.json) - a freshly
opened course had no time info anywhere, including the course header's
"~X remaining" line, which existed but was always empty as a result.
apply_progress_to_tree now falls back to the same ffprobe cache for any
lesson without a known duration yet, so per-lesson lengths and the
remaining-time estimate work from the very first visit. Refactored the
duration-cache read/write into a shared helper so the library browser,
the prewarm button, and this tree view all go through one path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 22:20:10 -04:00
co-authored by Claude Sonnet 5
parent 8f7808ab46
commit 24a07dd3e6
3 changed files with 194 additions and 23 deletions
+116 -23
View File
@@ -13,6 +13,7 @@ import sys
import argparse
import io
import subprocess
import threading
import time
import uuid
import zipfile
@@ -489,9 +490,10 @@ class DynamicCourseParser:
"""Calculate completion statistics for a directory node"""
total_lessons = 0
completed_lessons = 0
# Only lessons that have actually been played report a duration, so
# "remaining time" is only ever an estimate over what's known so far
# - there's no way to know the length of a lesson nobody's opened yet.
# duration_seconds comes from actual playback once a lesson's been
# watched, falling back to the ffprobe-derived cache otherwise (see
# ProgressTracker.apply_progress_to_tree) - so "remaining time"
# reflects the whole course, not just what's been played so far.
total_duration_seconds = 0
watched_seconds = 0
@@ -909,18 +911,17 @@ def _probe_media_duration_seconds(file_path: Path) -> Optional[float]:
DURATION_CACHE_FILENAME = '.offlineu_duration_cache.json'
def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) -> Optional[float]:
def _ensure_media_durations_cached(course_dir: Path, media_files: List[Path]) -> Dict[str, Any]:
"""
Total runtime across a course's video/audio files, for the library
browser's "how long is this" display. ffprobe only ever runs once per
file - results are cached to a small per-course JSON file keyed by
(relative path, size, mtime), so a container restart or the library
scan's 5-minute cache expiring never re-probes a file that hasn't
changed on disk; only a genuinely new or replaced file pays the cost.
Make sure every given media file has a known duration in the
persistent per-course cache file, keyed by (relative path, size,
mtime) - ffprobe only ever runs for a file that's new or has changed
since it was last cached, so a container restart or the library scan's
5-minute in-memory cache expiring never re-probes an unchanged file.
Returns the up-to-date {relative_path: {size, mtime, duration_seconds}}
cache, so callers needing a single lesson's duration (not just the
course total) don't have to re-read the cache file themselves.
"""
if not media_files:
return None
cache_file = course_dir / DURATION_CACHE_FILENAME
try:
with open(cache_file, 'r') as f:
@@ -928,8 +929,6 @@ def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) ->
except (FileNotFoundError, json.JSONDecodeError, OSError):
cache = {}
total = 0.0
have_any = False
changed = False
seen_keys = set()
@@ -942,17 +941,11 @@ def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) ->
seen_keys.add(rel_key)
cached = cache.get(rel_key)
if cached and cached.get('size') == stat.st_size and cached.get('mtime') == stat.st_mtime:
duration = cached.get('duration_seconds')
else:
if not (cached and cached.get('size') == stat.st_size and cached.get('mtime') == stat.st_mtime):
duration = _probe_media_duration_seconds(media_path)
cache[rel_key] = {'size': stat.st_size, 'mtime': stat.st_mtime, 'duration_seconds': duration}
changed = True
if duration:
total += duration
have_any = True
# Drop entries for files that no longer exist, so a renamed/deleted
# course's cache doesn't grow stale entries forever.
stale_keys = set(cache.keys()) - seen_keys
@@ -968,6 +961,27 @@ def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) ->
except OSError as e:
print(f"Could not save duration cache: {e}")
return cache
def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) -> Optional[float]:
"""Total runtime across a course's video/audio files, for the library browser's "how long is this" display."""
if not media_files:
return None
cache = _ensure_media_durations_cached(course_dir, media_files)
total = 0.0
have_any = False
for media_path in media_files:
try:
rel_key = str(media_path.relative_to(course_dir))
except ValueError:
continue
duration = cache.get(rel_key, {}).get('duration_seconds')
if duration:
total += duration
have_any = True
return total if have_any else None
@@ -1781,7 +1795,20 @@ class ProgressTracker:
def apply_progress_to_tree(course: Course):
"""Apply saved progress to the course tree"""
progress = ProgressTracker.load_progress(course)
# A lesson's duration is normally only known once it's been played
# (the player reports it back from the <video>/<audio> element) -
# for anything not yet watched, fall back to the ffprobe-derived
# duration cache so per-lesson lengths and the course's "~X
# remaining" estimate are meaningful from the very first visit,
# not just after you've started watching.
course_dir = Path(course.path)
media_files = [
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]
duration_cache = _ensure_media_durations_cached(course_dir, media_files) if media_files else {}
def apply_to_node(node: DirectoryNode):
# Apply progress to lessons in this node
for lesson in node.lessons:
@@ -1793,6 +1820,12 @@ class ProgressTracker:
lesson.progress_seconds = entry.get('progress_seconds', 0)
lesson.duration_seconds = entry.get('duration_seconds', 0)
if not lesson.duration_seconds:
media_key = lesson.video_file or lesson.audio_file
cached_duration = duration_cache.get(media_key, {}).get('duration_seconds') if media_key else None
if cached_duration:
lesson.duration_seconds = int(cached_duration)
# Recursively apply to children
for child in node.children.values():
apply_to_node(child)
@@ -1916,6 +1949,9 @@ def format_duration(seconds: int) -> str:
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
app.jinja_env.filters['format_duration'] = format_duration
@app.route('/')
def index():
"""Main dashboard"""
@@ -2129,6 +2165,63 @@ def refresh_library_api():
return jsonify({'success': True})
# Single-process personal app, so a plain dict + background thread is
# enough state for the duration prewarm - no job queue needed. Guarded by
# 'running' so a second click while one's in flight just reports progress
# on the existing run instead of starting a duplicate.
_duration_prewarm_state: Dict[str, Any] = {
'running': False,
'total': 0,
'done': 0,
'error': None,
}
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.
"""
global _duration_prewarm_state
course_dirs = get_all_course_dirs()
_duration_prewarm_state.update({
'running': True, 'total': len(course_dirs), 'done': 0, 'error': None,
})
try:
for course_dir in course_dirs:
media_files = [
f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]
_course_total_duration_seconds(course_dir, media_files)
_duration_prewarm_state['done'] += 1
except Exception as e:
_duration_prewarm_state['error'] = str(e)
finally:
_duration_prewarm_state['running'] = False
# Durations just landed in the persistent per-course cache files,
# but _course_summary results computed before this run may still be
# sitting in the in-memory cache without them - drop everything so
# the next library view picks the fresh durations up immediately.
invalidate_cache()
@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."""
if not _duration_prewarm_state['running']:
threading.Thread(target=_run_duration_prewarm, daemon=True).start()
return jsonify(_duration_prewarm_state)
@app.route('/api/library/prewarm-durations/status', methods=['GET'])
def prewarm_durations_status_api():
"""Poll the current progress of a duration prewarm run."""
return jsonify(_duration_prewarm_state)
@app.route('/api/hidden-paths', methods=['GET'])
def get_hidden_paths_api():
"""List currently-hidden course/directory paths, with display names."""