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
|
# 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
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user