From 24a07dd3e65f9d7f8c5f6bb64006ff30ce77d717 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Sun, 23 Aug 2026 22:20:10 -0400 Subject: [PATCH] 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 --- offlineu_core.py | 139 ++++++++++++++++++++++++++------ templates/course_dashboard.html | 9 +++ templates/settings.html | 69 ++++++++++++++++ 3 files changed, 194 insertions(+), 23 deletions(-) diff --git a/offlineu_core.py b/offlineu_core.py index c1851aa..a279211 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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