Add duration prewarm button; show video lengths in the loaded-course view
Settings gets a "Precompute Video Lengths" button that walks the whole library in a background thread, populating every course's persistent ffprobe duration cache up front instead of paying that cost the first time each course card is viewed. Progress polls live while it runs and picks back up correctly if you navigate away mid-scan. Separately, the loaded-course lesson tree only ever showed a lesson's duration once you'd actually played it (from progress.json) - a freshly opened course had no time info anywhere, including the course header's "~X remaining" line, which existed but was always empty as a result. apply_progress_to_tree now falls back to the same ffprobe cache for any lesson without a known duration yet, so per-lesson lengths and the remaining-time estimate work from the very first visit. Refactored the duration-cache read/write into a shared helper so the library browser, the prewarm button, and this tree view all go through one path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+116
-23
@@ -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 <video>/<audio> element) -
|
||||
# for anything not yet watched, fall back to the ffprobe-derived
|
||||
# duration cache so per-lesson lengths and the course's "~X
|
||||
# remaining" estimate are meaningful from the very first visit,
|
||||
# not just after you've started watching.
|
||||
course_dir = Path(course.path)
|
||||
media_files = [
|
||||
f for f in course_dir.rglob('*')
|
||||
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||
]
|
||||
duration_cache = _ensure_media_durations_cached(course_dir, media_files) if media_files else {}
|
||||
|
||||
def apply_to_node(node: DirectoryNode):
|
||||
# Apply progress to lessons in this node
|
||||
for lesson in node.lessons:
|
||||
@@ -1793,6 +1820,12 @@ class ProgressTracker:
|
||||
lesson.progress_seconds = entry.get('progress_seconds', 0)
|
||||
lesson.duration_seconds = entry.get('duration_seconds', 0)
|
||||
|
||||
if not lesson.duration_seconds:
|
||||
media_key = lesson.video_file or lesson.audio_file
|
||||
cached_duration = duration_cache.get(media_key, {}).get('duration_seconds') if media_key else None
|
||||
if cached_duration:
|
||||
lesson.duration_seconds = int(cached_duration)
|
||||
|
||||
# Recursively apply to children
|
||||
for child in node.children.values():
|
||||
apply_to_node(child)
|
||||
@@ -1916,6 +1949,9 @@ def format_duration(seconds: int) -> str:
|
||||
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||
|
||||
|
||||
app.jinja_env.filters['format_duration'] = format_duration
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Main dashboard"""
|
||||
@@ -2129,6 +2165,63 @@ def refresh_library_api():
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# Single-process personal app, so a plain dict + background thread is
|
||||
# enough state for the duration prewarm - no job queue needed. Guarded by
|
||||
# 'running' so a second click while one's in flight just reports progress
|
||||
# on the existing run instead of starting a duplicate.
|
||||
_duration_prewarm_state: Dict[str, Any] = {
|
||||
'running': False,
|
||||
'total': 0,
|
||||
'done': 0,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
|
||||
def _run_duration_prewarm():
|
||||
"""
|
||||
Walk every course in the library and populate its persistent duration
|
||||
cache (see _course_total_duration_seconds) up front, so opening a
|
||||
course for the first time doesn't pay the ffprobe cost right then -
|
||||
it was already paid here, once, in the background.
|
||||
"""
|
||||
global _duration_prewarm_state
|
||||
course_dirs = get_all_course_dirs()
|
||||
_duration_prewarm_state.update({
|
||||
'running': True, 'total': len(course_dirs), 'done': 0, 'error': None,
|
||||
})
|
||||
try:
|
||||
for course_dir in course_dirs:
|
||||
media_files = [
|
||||
f for f in course_dir.rglob('*')
|
||||
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||
]
|
||||
_course_total_duration_seconds(course_dir, media_files)
|
||||
_duration_prewarm_state['done'] += 1
|
||||
except Exception as e:
|
||||
_duration_prewarm_state['error'] = str(e)
|
||||
finally:
|
||||
_duration_prewarm_state['running'] = False
|
||||
# Durations just landed in the persistent per-course cache files,
|
||||
# but _course_summary results computed before this run may still be
|
||||
# sitting in the in-memory cache without them - drop everything so
|
||||
# the next library view picks the fresh durations up immediately.
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
@app.route('/api/library/prewarm-durations', methods=['POST'])
|
||||
def prewarm_durations_api():
|
||||
"""Kick off (or report on an already-running) background scan of every course's video/audio duration."""
|
||||
if not _duration_prewarm_state['running']:
|
||||
threading.Thread(target=_run_duration_prewarm, daemon=True).start()
|
||||
return jsonify(_duration_prewarm_state)
|
||||
|
||||
|
||||
@app.route('/api/library/prewarm-durations/status', methods=['GET'])
|
||||
def prewarm_durations_status_api():
|
||||
"""Poll the current progress of a duration prewarm run."""
|
||||
return jsonify(_duration_prewarm_state)
|
||||
|
||||
|
||||
@app.route('/api/hidden-paths', methods=['GET'])
|
||||
def get_hidden_paths_api():
|
||||
"""List currently-hidden course/directory paths, with display names."""
|
||||
|
||||
@@ -896,6 +896,12 @@
|
||||
.lesson-type.quiz { background: #f39c12; color: white; }
|
||||
.lesson-type.mixed { background: #16a085; color: white; }
|
||||
|
||||
.lesson-duration {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
@@ -1081,6 +1087,9 @@
|
||||
</div>
|
||||
<div class="lesson-meta">
|
||||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||||
{% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
|
||||
<span class="lesson-duration">{{ lesson.duration_seconds|format_duration }}</span>
|
||||
{% endif %}
|
||||
{% if lesson.completed %}
|
||||
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
|
||||
{% elif percent_watched %}
|
||||
|
||||
@@ -449,6 +449,12 @@
|
||||
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||
</div>
|
||||
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Video Lengths</button>
|
||||
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||
</div>
|
||||
<span class="setting-desc">Reads every video/audio file's length up front and caches it, so course cards in the Library show their total runtime immediately instead of computing it the first time each course is viewed.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -999,6 +1005,69 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updatePrewarmStatus(data) {
|
||||
const btn = document.getElementById('prewarm-durations-btn');
|
||||
const status = document.getElementById('prewarm-durations-status');
|
||||
if (data.running) {
|
||||
btn.disabled = true;
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = `Scanning… ${data.done}/${data.total} courses`;
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
if (data.error) {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = `Error: ${data.error}`;
|
||||
} else if (data.total) {
|
||||
status.style.color = 'var(--success)';
|
||||
status.innerHTML = `${ICON_SVGS.check} Scanned ${data.total} course${data.total === 1 ? '' : 's'}`;
|
||||
setTimeout(() => { status.textContent = ''; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function pollPrewarmStatus() {
|
||||
fetch('/api/library/prewarm-durations/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
updatePrewarmStatus(data);
|
||||
if (data.running) setTimeout(pollPrewarmStatus, 1500);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function startDurationPrewarm() {
|
||||
const status = document.getElementById('prewarm-durations-status');
|
||||
document.getElementById('prewarm-durations-btn').disabled = true;
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Starting…';
|
||||
fetch('/api/library/prewarm-durations', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
updatePrewarmStatus(data);
|
||||
if (data.running) setTimeout(pollPrewarmStatus, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = 'Could not reach the server';
|
||||
document.getElementById('prewarm-durations-btn').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Pick back up a prewarm that's already running from a previous
|
||||
// page load, rather than leaving the button looking idle while
|
||||
// work is actually happening in the background.
|
||||
(function resumePrewarmStatusIfRunning() {
|
||||
fetch('/api/library/prewarm-durations/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running) {
|
||||
updatePrewarmStatus(data);
|
||||
setTimeout(pollPrewarmStatus, 1500);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
|
||||
function loadCourseFromPath() {
|
||||
const input = document.getElementById('manual-course-path');
|
||||
const status = document.getElementById('manual-course-status');
|
||||
|
||||
Reference in New Issue
Block a user