diff --git a/Dockerfile b/Dockerfile index cb70a92..069aa8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,11 @@ FROM python:3.13.5-slim-bookworm # set the working directory in the container WORKDIR /app +# ffprobe (from ffmpeg) reads each video's duration for the library +# browser's "how long is this course" display +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + # copy the dependencies file to the working directory COPY requirements.txt . diff --git a/offlineu_core.py b/offlineu_core.py index edfdbb0..c1851aa 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -12,6 +12,7 @@ import re import sys import argparse import io +import subprocess import time import uuid import zipfile @@ -886,21 +887,109 @@ def get_all_course_dirs() -> List[Path]: return cache_get_or_compute('course_dirs', lambda: list(iter_all_courses(get_library_root()))) +def _probe_media_duration_seconds(file_path: Path) -> Optional[float]: + """Read a single media file's duration via ffprobe. None if ffprobe is + missing, the file isn't readable, or the output can't be parsed.""" + try: + result = subprocess.run( + ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', str(file_path)], + capture_output=True, text=True, timeout=15 + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + try: + return float(result.stdout.strip()) + except ValueError: + return None + + +DURATION_CACHE_FILENAME = '.offlineu_duration_cache.json' + + +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. 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. + """ + if not media_files: + return None + + cache_file = course_dir / DURATION_CACHE_FILENAME + try: + with open(cache_file, 'r') as f: + cache = json.load(f) + except (FileNotFoundError, json.JSONDecodeError, OSError): + cache = {} + + total = 0.0 + have_any = False + changed = False + seen_keys = set() + + for media_path in media_files: + try: + rel_key = str(media_path.relative_to(course_dir)) + stat = media_path.stat() + except OSError: + continue + 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: + 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 + if stale_keys: + for key in stale_keys: + del cache[key] + changed = True + + if changed: + try: + with open(cache_file, 'w') as f: + json.dump(cache, f, indent=2) + except OSError as e: + print(f"Could not save duration cache: {e}") + + return total if have_any else None + + def _course_summary(course_dir: Path) -> Dict[str, Any]: - """Build the {type, name, path, media_files, hidden, has_thumbnail} shape - shared by the Library browser, search results, and Recently Added.""" + """Build the {type, name, path, media_files, hidden, has_thumbnail, + duration_display} shape shared by the Library browser, search results, + and Recently Added.""" def compute(): - media_count = len([ + media_files = [ f for f in course_dir.rglob('*') if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS - ]) + ] + total_duration = _course_total_duration_seconds(course_dir, media_files) return { 'type': 'course', 'name': course_dir.name, 'path': str(course_dir), - 'media_files': media_count, + 'media_files': len(media_files), 'hidden': False, - 'has_thumbnail': find_course_thumbnail(str(course_dir)) is not None + 'has_thumbnail': find_course_thumbnail(str(course_dir)) is not None, + # under a minute isn't a meaningful "how long will this take" signal + 'duration_display': format_duration(total_duration) if total_duration and total_duration >= 60 else None, } return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute)) diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 25add05..4da1335 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -1644,12 +1644,15 @@ const progressBar = pct ? `
` : ''; + const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`]; + if (item.duration_display) metaBits.push(item.duration_display); + if (pct) metaBits.push(`${pct}% done`); return `
${selectableAttrs(item)}
${iconHtml}
${item.name}
-
${item.media_files} media file${item.media_files === 1 ? '' : 's'}${pct ? ` · ${pct}% done` : ''}
+
${metaBits.join(' · ')}
${progressBar}
`; @@ -1688,7 +1691,7 @@
${progressBadge} - ${item.media_files} media file${item.media_files === 1 ? '' : 's'} + ${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}
${progressBar}