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:
@@ -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 .
|
||||
|
||||
|
||||
+95
-6
@@ -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))
|
||||
|
||||
|
||||
@@ -1644,12 +1644,15 @@
|
||||
const progressBar = pct
|
||||
? `<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 `
|
||||
<div class="library-grid-card" onclick="${clickHandler}">
|
||||
${selectableAttrs(item)}
|
||||
<div class="grid-card-thumb-wrap">${iconHtml}</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}
|
||||
</div>
|
||||
`;
|
||||
@@ -1688,7 +1691,7 @@
|
||||
</div>
|
||||
<div class="lesson-meta">
|
||||
${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>
|
||||
</div>
|
||||
${progressBar}
|
||||
|
||||
Reference in New Issue
Block a user