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:
+115
-22
@@ -13,6 +13,7 @@ import sys
|
|||||||
import argparse
|
import argparse
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -489,9 +490,10 @@ class DynamicCourseParser:
|
|||||||
"""Calculate completion statistics for a directory node"""
|
"""Calculate completion statistics for a directory node"""
|
||||||
total_lessons = 0
|
total_lessons = 0
|
||||||
completed_lessons = 0
|
completed_lessons = 0
|
||||||
# Only lessons that have actually been played report a duration, so
|
# duration_seconds comes from actual playback once a lesson's been
|
||||||
# "remaining time" is only ever an estimate over what's known so far
|
# watched, falling back to the ffprobe-derived cache otherwise (see
|
||||||
# - there's no way to know the length of a lesson nobody's opened yet.
|
# ProgressTracker.apply_progress_to_tree) - so "remaining time"
|
||||||
|
# reflects the whole course, not just what's been played so far.
|
||||||
total_duration_seconds = 0
|
total_duration_seconds = 0
|
||||||
watched_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'
|
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
|
Make sure every given media file has a known duration in the
|
||||||
browser's "how long is this" display. ffprobe only ever runs once per
|
persistent per-course cache file, keyed by (relative path, size,
|
||||||
file - results are cached to a small per-course JSON file keyed by
|
mtime) - ffprobe only ever runs for a file that's new or has changed
|
||||||
(relative path, size, mtime), so a container restart or the library
|
since it was last cached, so a container restart or the library scan's
|
||||||
scan's 5-minute cache expiring never re-probes a file that hasn't
|
5-minute in-memory cache expiring never re-probes an unchanged file.
|
||||||
changed on disk; only a genuinely new or replaced file pays the cost.
|
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
|
cache_file = course_dir / DURATION_CACHE_FILENAME
|
||||||
try:
|
try:
|
||||||
with open(cache_file, 'r') as f:
|
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):
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
cache = {}
|
cache = {}
|
||||||
|
|
||||||
total = 0.0
|
|
||||||
have_any = False
|
|
||||||
changed = False
|
changed = False
|
||||||
seen_keys = set()
|
seen_keys = set()
|
||||||
|
|
||||||
@@ -942,17 +941,11 @@ def _course_total_duration_seconds(course_dir: Path, media_files: List[Path]) ->
|
|||||||
seen_keys.add(rel_key)
|
seen_keys.add(rel_key)
|
||||||
|
|
||||||
cached = cache.get(rel_key)
|
cached = cache.get(rel_key)
|
||||||
if cached and cached.get('size') == stat.st_size and cached.get('mtime') == stat.st_mtime:
|
if not (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)
|
duration = _probe_media_duration_seconds(media_path)
|
||||||
cache[rel_key] = {'size': stat.st_size, 'mtime': stat.st_mtime, 'duration_seconds': duration}
|
cache[rel_key] = {'size': stat.st_size, 'mtime': stat.st_mtime, 'duration_seconds': duration}
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if duration:
|
|
||||||
total += duration
|
|
||||||
have_any = True
|
|
||||||
|
|
||||||
# Drop entries for files that no longer exist, so a renamed/deleted
|
# Drop entries for files that no longer exist, so a renamed/deleted
|
||||||
# course's cache doesn't grow stale entries forever.
|
# course's cache doesn't grow stale entries forever.
|
||||||
stale_keys = set(cache.keys()) - seen_keys
|
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:
|
except OSError as e:
|
||||||
print(f"Could not save duration cache: {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
|
return total if have_any else None
|
||||||
|
|
||||||
|
|
||||||
@@ -1782,6 +1796,19 @@ class ProgressTracker:
|
|||||||
"""Apply saved progress to the course tree"""
|
"""Apply saved progress to the course tree"""
|
||||||
progress = ProgressTracker.load_progress(course)
|
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):
|
def apply_to_node(node: DirectoryNode):
|
||||||
# Apply progress to lessons in this node
|
# Apply progress to lessons in this node
|
||||||
for lesson in node.lessons:
|
for lesson in node.lessons:
|
||||||
@@ -1793,6 +1820,12 @@ class ProgressTracker:
|
|||||||
lesson.progress_seconds = entry.get('progress_seconds', 0)
|
lesson.progress_seconds = entry.get('progress_seconds', 0)
|
||||||
lesson.duration_seconds = entry.get('duration_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
|
# Recursively apply to children
|
||||||
for child in node.children.values():
|
for child in node.children.values():
|
||||||
apply_to_node(child)
|
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"
|
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
app.jinja_env.filters['format_duration'] = format_duration
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Main dashboard"""
|
"""Main dashboard"""
|
||||||
@@ -2129,6 +2165,63 @@ def refresh_library_api():
|
|||||||
return jsonify({'success': True})
|
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'])
|
@app.route('/api/hidden-paths', methods=['GET'])
|
||||||
def get_hidden_paths_api():
|
def get_hidden_paths_api():
|
||||||
"""List currently-hidden course/directory paths, with display names."""
|
"""List currently-hidden course/directory paths, with display names."""
|
||||||
|
|||||||
@@ -896,6 +896,12 @@
|
|||||||
.lesson-type.quiz { background: #f39c12; color: white; }
|
.lesson-type.quiz { background: #f39c12; color: white; }
|
||||||
.lesson-type.mixed { background: #16a085; color: white; }
|
.lesson-type.mixed { background: #16a085; color: white; }
|
||||||
|
|
||||||
|
.lesson-duration {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -1081,6 +1087,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lesson-meta">
|
<div class="lesson-meta">
|
||||||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
<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 %}
|
{% if lesson.completed %}
|
||||||
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
|
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
|
||||||
{% elif percent_watched %}
|
{% elif percent_watched %}
|
||||||
|
|||||||
@@ -449,6 +449,12 @@
|
|||||||
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
</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() {
|
function loadCourseFromPath() {
|
||||||
const input = document.getElementById('manual-course-path');
|
const input = document.getElementById('manual-course-path');
|
||||||
const status = document.getElementById('manual-course-status');
|
const status = document.getElementById('manual-course-status');
|
||||||
|
|||||||
Reference in New Issue
Block a user