Detect course roots per branch and add a category layer above them

The scanner no longer assumes courses sit at a fixed depth. It walks
each branch and marks a folder is_course when it holds media directly
or its children read as chapters (01_, 1. , Module 2, Week 3);
everything above that is a category, and every folder still gets a
course_id pointing at its nearest course for cheap rollups. An empty
.course/.category marker file overrides the heuristic per folder.

Also:
- Roll up progress for the whole library in two grouped queries
  instead of one prefix-matched query per course/folder card.
- Store folders/videos/quizzes paths as utf8mb4_bin so folders that
  differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a
  case-insensitive UNIQUE key.
- migrateLibrarySchema() brings an older install's tables up to date
  at scan time, so there's no separate migration step.
- Library UI gains a categories view alongside shelves/catalog; the
  continue-watching strip's play button now seeks to the saved
  position on open.

Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
This commit is contained in:
root
2026-09-08 18:51:04 +00:00
parent 6a4c972c47
commit 04314327db
7 changed files with 849 additions and 115 deletions
+166 -26
View File
@@ -20,6 +20,7 @@
csrf: null, // session token, echoed back on every POST
meta: null, // totals / shelves / last scan, for the chrome
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
playOnLoad: null, // video id a play button asked to start, not just open
scanning: false,
};
let cleanupView = null;
@@ -33,6 +34,8 @@
const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' };
const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' };
// What a folder is, for search results: the three now mean different things.
const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' };
const ICONS = {
home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>',
@@ -127,7 +130,7 @@
/* ---------- preferences ---------- */
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true };
const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true };
function pref(key) {
try {
@@ -352,6 +355,46 @@
</a>`;
}
// A category is a folder of courses. It gets the same card as a course so the
// library reads as one grid, with the course count where a course shows its
// videos, and no play affordance — there is nothing to play at this level.
function categoryCard(c) {
const hue = hueOf(c.id);
return `<a class="course-card" href="#/folder/${c.id}">
${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
<div class="course-card-body">
<div class="course-title">${esc(c.name)}</div>
<div class="course-meta">${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}</div>
<div class="bar-row">
${bar(c.progress.percent, hue)}
<span class="mono">${c.progress.percent}%</span>
</div>
</div>
</a>`;
}
function categoryRow(c) {
const hue = hueOf(c.id);
return `<a class="row" href="#/folder/${c.id}">
<span class="initial initial-sm" style="--c:${hue}">${esc(initialOf(c.name))}</span>
<div class="row-fixed">
<div class="row-title">${esc(c.name)}</div>
<div class="row-sub">${esc(c.path || '')}</div>
</div>
<span class="row-col">${c.courses} course${c.courses === 1 ? '' : 's'}</span>
<span class="row-num">${c.videos} vids</span>
<span class="row-num">${esc(fmtLong(c.duration))}</span>
<div class="row-bar">
${bar(c.progress.percent, hue)}
<span class="mono">${c.progress.percent}%</span>
</div>
</a>`;
}
const categoryList = (cats, mode) => mode === 'list'
? `<div class="rows">${cats.map(categoryRow).join('')}</div>`
: `<div class="grid">${cats.map(categoryCard).join('')}</div>`;
function courseRow(c) {
const hue = hueOf(c.id);
return `<div class="row">
@@ -527,7 +570,8 @@
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
const hue = hueOf(resume.course_id ?? resume.id);
return `<div class="hero">
<div class="hero-art">${thumb(hue, { play: true, percent: pct })}</div>
<a class="hero-art" href="#/watch/${resume.id}" data-play="${resume.id}"
aria-label="Resume ${esc(resume.name)}">${thumb(hue, { play: true, percent: pct })}</a>
<div class="hero-body">
<span class="hero-eyebrow">Continue watching</span>
<div class="hero-title">${esc(resume.name)}</div>
@@ -607,11 +651,38 @@
/* ---------- library ---------- */
/**
* Split courses onto the three progress shelves. The API sends one flat list
* and this partitions it — shipping the partition as well would put every
* course in the payload twice.
*/
const shelveCourses = (courses) =>
[['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']]
.map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) }))
.filter((s) => s.courses.length);
/**
* A shelf block: a heading, a count and the cards on it. Used by the library
* and by a category page, which shelves its own courses the same way.
*/
const shelfBlock = (shelf, mode) => `
<div style="margin-bottom:34px">
<div class="shelf-head">
<span class="swatch" style="--c:${SHELF_HUE[shelf.key]}"></span>
<h2>${esc(shelf.name)}</h2>
<span class="mono">${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'}</span>
<div class="rule"></div>
</div>
${courseList(shelf.courses, mode)}
</div>`;
async function renderLibrary(filter) {
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
spinner();
const data = await load('api/library.php?view=library');
// A shelf link from the sidebar is always a flat list of matching courses;
// otherwise the saved layout decides.
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
const mode = pref('view');
const active = filter || 'all';
@@ -629,17 +700,19 @@
<p>Add course folders to the videos directory, then scan.</p>
<button class="btn btn-accent" data-scan>Scan library</button>
</div>`;
} else if (layout === 'shelves') {
body = data.shelves.map((s) => `
<div style="margin-bottom:34px">
<div class="shelf-head">
<span class="swatch" style="--c:${SHELF_HUE[s.key]}"></span>
<h2>${esc(s.name)}</h2>
<span class="mono">${s.courses.length} course${s.courses.length === 1 ? '' : 's'}</span>
<div class="rule"></div>
</div>
${courseList(s.courses, mode)}
</div>`).join('');
} else if (layout === 'categories' && data.categories.length) {
// The folders above the courses are the library's own shape — a few dozen
// cards to browse instead of a wall of every course at once.
body = categoryList(data.categories, mode)
+ (data.top_courses.length ? `
<div class="shelf-head" style="margin-top:34px">
<h2>Courses</h2>
<span class="mono">${data.top_courses.length}</span>
<div class="rule"></div>
</div>
${courseList(data.top_courses, mode)}` : '');
} else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('');
} else {
body = `<div class="chips">${chips}</div>` + (shown.length
? courseList(shown, mode)
@@ -649,19 +722,27 @@
}
const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0);
const sub = [
`${data.courses.length} course${data.courses.length === 1 ? '' : 's'}`,
data.totals.categories ? `${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}` : '',
`${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine`,
].filter(Boolean).join(' · ');
const layoutBtn = (key, label) =>
`<button class="${layout === key ? 'is-on' : ''}" data-layout="${key}">${label}</button>`;
view.innerHTML = `
<div>
<div class="page-head">
<div>
<h1>Library</h1>
<p class="page-sub">${data.courses.length} course${data.courses.length === 1 ? '' : 's'} ·
${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine</p>
<p class="page-sub">${sub}</p>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="seg">
<button class="${layout === 'shelves' ? 'is-on' : ''}" data-layout="shelves">Shelves</button>
<button class="${layout === 'catalog' ? 'is-on' : ''}" data-layout="catalog">Catalog</button>
${data.categories.length ? layoutBtn('categories', 'Categories') : ''}
${layoutBtn('shelves', 'Shelves')}
${layoutBtn('catalog', 'Catalog')}
</div>
${viewToggle()}
</div>
@@ -678,27 +759,50 @@
const data = await load(`api/library.php?view=folder&id=${id}`);
const mode = pref('view');
const hue = hueOf(data.course_id);
const isCategory = data.kind === 'category';
const sections = data.subfolders.length ? `
<div class="section-head">
<h2 class="h-section">Sections</h2>
${viewToggle()}
</div>
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '';
// A category holds courses (and possibly more categories), so it reads like
// a small library of its own; a course holds sections, which are just a
// list. Both come back on the same route — only the shape below differs.
const sections = isCategory
? (data.categories.length ? `
<div class="section-head">
<h2 class="h-section">Categories</h2>
${viewToggle()}
</div>
<div style="margin-bottom:32px">${categoryList(data.categories, mode)}</div>` : '')
+ (data.courses.length ? `
<div class="section-head">
<h2 class="h-section">Courses</h2>
${data.categories.length ? '' : viewToggle()}
</div>
${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '')
: (data.subfolders.length ? `
<div class="section-head">
<h2 class="h-section">Sections</h2>
${viewToggle()}
</div>
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '');
const items = [
...data.videos.map((v) => ({ ...v, kind: 'video' })),
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' }));
const hasChildren = isCategory
? (data.categories.length + data.courses.length) > 0
: data.subfolders.length > 0;
const content = items.length ? `
<h2 class="h-section" style="margin-bottom:14px">Content</h2>
<div class="rows">${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}</div>`
: (!data.subfolders.length
: (!hasChildren
? `<div class="empty"><h2>This folder is empty</h2>
<p>No videos or quizzes were found here in the last scan.</p></div>` : '');
const courseCount = isCategory ? data.categories.reduce((n, c) => n + c.courses, data.courses.length) : 0;
const meta = [
courseCount ? `${courseCount} course${courseCount === 1 ? '' : 's'}` : '',
data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '',
data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '',
fmtLong(data.rollup.duration),
@@ -710,6 +814,7 @@
${crumbs(data.breadcrumbs)}
<div class="page-head">
<div>
${isCategory ? '<span class="eyebrow eyebrow-sm">Category</span>' : ''}
<h1>${esc(data.folder.name)}</h1>
<p class="page-sub">${esc(meta || 'Empty folder')}</p>
</div>
@@ -758,7 +863,7 @@
<span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
<div class="row-main">
<div class="row-title">${esc(c.name)}</div>
<div class="row-sub">${esc([courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
<div class="row-sub">${esc([KIND_NAME[c.kind], courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
</div>
<div class="row-bar" style="flex:none;width:120px">
${bar(c.progress.percent, hueOf(c.id))}
@@ -1034,6 +1139,12 @@
</div>
<div class="fact-row"><span>Indexed</span>
<span class="mono">${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}</span></div>
<div class="fact-row"><span>Courses found</span>
<span class="mono">${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}</span></div>
<div class="fact-row"><span>Shelves</span>
<span class="mono">${data.shelf_counts.progress} in progress ·
${data.shelf_counts.finished} finished ·
${data.shelf_counts.fresh} not started</span></div>
<div class="fact-row"><span>Total running time</span>
<span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div>
<div class="fact-row"><span>A video counts as watched at</span>
@@ -1109,6 +1220,18 @@
resumed = true;
});
// A play button elsewhere in the app (the home hero) asked for the video to
// start, not just for the player to open. Autoplay is only permitted off the
// gesture that got us here, so the flag is consumed once and never survives
// a reload — and it waits for the resume seek above, so play() picks up at
// the saved position rather than at zero.
if (state.playOnLoad === data.video.id) {
state.playOnLoad = null;
const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); };
if (el.readyState >= 1) start();
else el.addEventListener('loadedmetadata', start, { once: true });
}
let lastSaved = -1;
let saveTimer = null;
@@ -1149,6 +1272,10 @@
save(true); // the final position always lands, even within 1 s of the last heartbeat
if (data.next_id && pref('autoplay') !== false) {
status(`${icon('next', 14, 1.7)}Playing next video…`);
// "Autoplay next" has to actually start it — navigating alone just
// opened the next video paused. The gesture that began this playback
// still counts, so the browser lets the next one start.
state.playOnLoad = data.next_id;
nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
}
});
@@ -1160,6 +1287,12 @@
return function cleanup() {
saveBeacon();
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
// Autoplay-next arms playOnLoad before it navigates, and route() tears the
// old view down after the hash has already changed — so only disarm when
// we are leaving for somewhere other than the video it queued.
if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) {
state.playOnLoad = null;
}
if (saveTimer) clearInterval(saveTimer);
document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', saveBeacon);
@@ -1218,7 +1351,7 @@
try {
const res = await api('api/scan.php', { method: 'POST' });
const s = res.stats;
toast(`Scan finished — ${s.folders} folders, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
await loadMeta();
route();
} catch (err) {
@@ -1336,6 +1469,13 @@
/* ---------- delegated actions ---------- */
document.addEventListener('click', (e) => {
// Not a preventDefault: the link still navigates to #/watch/N, this only
// tells the player it was reached from a play button and should start.
const playLink = e.target.closest('[data-play]');
if (playLink && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
state.playOnLoad = +playLink.dataset.play;
}
const scan = e.target.closest('[data-scan]');
if (scan) { e.preventDefault(); runScan(); return; }