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