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:
2026-08-23 22:20:10 -04:00
co-authored by Claude Sonnet 5
parent 8f7808ab46
commit 24a07dd3e6
3 changed files with 194 additions and 23 deletions
+69
View File
@@ -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');