Show course runtime on library cards via ffprobe

Add ffmpeg to the Docker image so ffprobe can read each video/audio
file's duration server-side. Results are cached to a small persistent
per-course JSON file keyed by (relative path, size, mtime), so a file
only gets probed once - a container restart or the library scan's
in-memory cache expiring never re-runs ffprobe on unchanged files.

Course cards in the library browser (grid, list, and search) now show
total runtime alongside the media-file count, so you know the time
commitment before opening a course. Durations under a minute are
suppressed rather than showing a noisy "0m".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 22:03:04 -04:00
co-authored by Claude Sonnet 5
parent eae6b09ecd
commit 8f7808ab46
3 changed files with 105 additions and 8 deletions
+5
View File
@@ -4,6 +4,11 @@ FROM python:3.13.5-slim-bookworm
# set the working directory in the container # set the working directory in the container
WORKDIR /app 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 the dependencies file to the working directory
COPY requirements.txt . COPY requirements.txt .
+95 -6
View File
@@ -12,6 +12,7 @@ import re
import sys import sys
import argparse import argparse
import io import io
import subprocess
import time import time
import uuid import uuid
import zipfile 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()))) 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]: def _course_summary(course_dir: Path) -> Dict[str, Any]:
"""Build the {type, name, path, media_files, hidden, has_thumbnail} shape """Build the {type, name, path, media_files, hidden, has_thumbnail,
shared by the Library browser, search results, and Recently Added.""" duration_display} shape shared by the Library browser, search results,
and Recently Added."""
def compute(): def compute():
media_count = len([ media_files = [
f for f in course_dir.rglob('*') f for f in course_dir.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
]) ]
total_duration = _course_total_duration_seconds(course_dir, media_files)
return { return {
'type': 'course', 'type': 'course',
'name': course_dir.name, 'name': course_dir.name,
'path': str(course_dir), 'path': str(course_dir),
'media_files': media_count, 'media_files': len(media_files),
'hidden': False, '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)) return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute))
+5 -2
View File
@@ -1644,12 +1644,15 @@
const progressBar = pct const progressBar = pct
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>` ? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
: ''; : '';
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 ` return `
<div class="library-grid-card" onclick="${clickHandler}"> <div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)} ${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}</div> <div class="grid-card-thumb-wrap">${iconHtml}</div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div> <div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
<div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}${pct ? ` · ${pct}% done` : ''}</div> <div class="grid-card-meta">${metaBits.join(' · ')}</div>
${progressBar} ${progressBar}
</div> </div>
`; `;
@@ -1688,7 +1691,7 @@
</div> </div>
<div class="lesson-meta"> <div class="lesson-meta">
${progressBadge} ${progressBadge}
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span> <span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button> <button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
</div> </div>
${progressBar} ${progressBar}