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