diff --git a/tests/static/test_discover_section_controller.mjs b/tests/static/test_discover_section_controller.mjs
new file mode 100644
index 00000000..8cc1b655
--- /dev/null
+++ b/tests/static/test_discover_section_controller.mjs
@@ -0,0 +1,707 @@
+// Tests for `createDiscoverSectionController` in
+// `webui/static/discover-section-controller.js`. Run via:
+//
+// node --test tests/static/
+//
+// Or through the Python wrapper at
+// tests/test_discover_section_controller_js.py which shells out to
+// `node --test` and surfaces the result inside the regular pytest run.
+//
+// The controller is loaded into a sandboxed `vm` context with stubbed
+// `window` / `document` / `Element` / `fetch`. No DOM or network — just
+// the lifecycle contract.
+
+import { test, describe, before } from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const CONTROLLER_PATH = resolve(__dirname, '..', '..', 'webui', 'static', 'discover-section-controller.js');
+
+// Minimal Element stub — controller uses `instanceof Element` to tell
+// strings (selectors) apart from DOM refs.
+class Element {
+ constructor(id) {
+ this.id = id;
+ this.innerHTML = '';
+ this.style = { display: '' };
+ }
+}
+
+// Build a fresh sandbox per test so state doesn't leak between cases.
+function makeSandbox(opts = {}) {
+ const elements = new Map();
+ const ensureEl = (sel) => {
+ if (!elements.has(sel)) elements.set(sel, new Element(sel));
+ return elements.get(sel);
+ };
+
+ const sandbox = {
+ Element,
+ window: {},
+ console: {
+ // Quiet by default — turn on by passing { logCalls: true }
+ debug: opts.logCalls ? console.debug : () => {},
+ error: opts.logCalls ? console.error : () => {},
+ log: opts.logCalls ? console.log : () => {},
+ },
+ document: {
+ querySelector: (sel) => ensureEl(sel),
+ },
+ fetch: opts.fetch || (async () => {
+ throw new Error('fetch not stubbed for this test');
+ }),
+ // Toast spy — when controller calls window.showToast, capture it
+ _toasts: [],
+ };
+ sandbox.window.showToast = (msg, type) => sandbox._toasts.push({ msg, type });
+ sandbox._elements = elements;
+ return sandbox;
+}
+
+let CONTROLLER_SOURCE;
+before(() => {
+ CONTROLLER_SOURCE = readFileSync(CONTROLLER_PATH, 'utf8');
+});
+
+function loadController(sandbox) {
+ vm.createContext(sandbox);
+ vm.runInContext(CONTROLLER_SOURCE, sandbox);
+ return sandbox.window.createDiscoverSectionController;
+}
+
+// =========================================================================
+// Config validation
+// =========================================================================
+
+describe('config validation', () => {
+ test('throws on missing id', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.throws(() => create({
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: () => [],
+ renderItems: () => '',
+ }), /config.id required/);
+ });
+
+ test('throws on missing contentEl', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.throws(() => create({
+ id: 'x',
+ fetchUrl: '/u',
+ extractItems: () => [],
+ renderItems: () => '',
+ }), /contentEl required/);
+ });
+
+ test('throws when both fetchUrl and data provided', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.throws(() => create({
+ id: 'x',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ data: { ok: true },
+ extractItems: () => [],
+ renderItems: () => '',
+ }), /mutually exclusive/);
+ });
+
+ test('throws when neither fetchUrl nor data provided', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.throws(() => create({
+ id: 'x',
+ contentEl: '#x',
+ extractItems: () => [],
+ renderItems: () => '',
+ }), /either config.fetchUrl or config.data required/);
+ });
+
+ test('throws when extractItems missing', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.throws(() => create({
+ id: 'x',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ renderItems: () => '',
+ }), /extractItems required/);
+ });
+
+ test('throws when renderItems missing', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.throws(() => create({
+ id: 'x',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: () => [],
+ }), /renderItems required/);
+ });
+
+ test('accepts function fetchUrl', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.doesNotThrow(() => create({
+ id: 'x',
+ contentEl: '#x',
+ fetchUrl: () => '/u',
+ extractItems: () => [],
+ renderItems: () => '',
+ }));
+ });
+
+ test('accepts data instead of fetchUrl', () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ assert.doesNotThrow(() => create({
+ id: 'x',
+ contentEl: '#x',
+ data: { ok: true },
+ extractItems: () => [],
+ renderItems: () => '',
+ }));
+ });
+});
+
+// =========================================================================
+// Happy path — fetch → render
+// =========================================================================
+
+describe('fetch + render lifecycle', () => {
+ test('fetches, parses, calls renderItems, writes innerHTML', async () => {
+ const sandbox = makeSandbox({
+ fetch: async (url) => {
+ assert.equal(url, '/api/test');
+ return {
+ ok: true,
+ json: async () => ({ success: true, items: [{ id: 1 }, { id: 2 }] }),
+ };
+ },
+ });
+ const create = loadController(sandbox);
+ let renderCalls = 0;
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#carousel',
+ fetchUrl: '/api/test',
+ extractItems: (data) => data.items,
+ renderItems: (items) => {
+ renderCalls++;
+ return `${items.length}`;
+ },
+ });
+ await ctrl.load();
+ assert.equal(renderCalls, 1);
+ assert.equal(sandbox._elements.get('#carousel').innerHTML, '2');
+ assert.equal(ctrl.getState().phase, 'rendered');
+ });
+
+ test('fires onRendered hook after render', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({
+ ok: true,
+ json: async () => ({ success: true, items: [{ id: 1 }] }),
+ }),
+ });
+ const create = loadController(sandbox);
+ let hookCalls = 0;
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => 'rendered',
+ onRendered: (ctx) => {
+ hookCalls++;
+ assert.ok(ctx.contentEl);
+ assert.ok(ctx.items);
+ assert.ok(ctx.data);
+ },
+ });
+ await ctrl.load();
+ assert.equal(hookCalls, 1);
+ });
+
+ test('fires onSuccess hook after success gate, before render', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({
+ ok: true,
+ json: async () => ({ success: true, items: [], stats: { count: 5 } }),
+ }),
+ });
+ const create = loadController(sandbox);
+ const order = [];
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => { order.push('render'); return ''; },
+ onSuccess: (data) => { order.push(`success:${data.stats.count}`); },
+ });
+ await ctrl.load();
+ // Empty items → no render. onSuccess still fires.
+ assert.deepEqual(order, ['success:5']);
+ });
+
+ test('fires beforeLoad hook before spinner shows', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({
+ ok: true,
+ json: async () => ({ success: true, items: [{ id: 1 }] }),
+ }),
+ });
+ const create = loadController(sandbox);
+ const order = [];
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => { order.push('render'); return 'r'; },
+ beforeLoad: () => { order.push('before'); },
+ });
+ await ctrl.load();
+ assert.equal(order[0], 'before');
+ assert.equal(order[1], 'render');
+ });
+});
+
+// =========================================================================
+// Empty state
+// =========================================================================
+
+describe('empty state', () => {
+ test('renders empty message when items array is empty', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ emptyMessage: 'Nothing here',
+ });
+ await ctrl.load();
+ const html = sandbox._elements.get('#x').innerHTML;
+ assert.match(html, /Nothing here/);
+ assert.doesNotMatch(html, /should-not-appear/);
+ assert.equal(ctrl.getState().phase, 'empty');
+ });
+
+ test('hides whole section when hideWhenEmpty + empty', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ sectionEl: '#wrapper',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ hideWhenEmpty: true,
+ });
+ await ctrl.load();
+ assert.equal(sandbox._elements.get('#wrapper').style.display, 'none');
+ });
+
+ test('treats success=false as empty (default)', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: false, items: [{ id: 1 }] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ emptyMessage: 'X',
+ });
+ await ctrl.load();
+ assert.equal(ctrl.getState().phase, 'empty');
+ });
+
+ test('custom isSuccess overrides default success-flag check', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ status: 'ok', items: [{ id: 1 }] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ isSuccess: (d) => d.status === 'ok',
+ renderItems: (items) => `r:${items.length}`,
+ });
+ await ctrl.load();
+ assert.equal(sandbox._elements.get('#x').innerHTML, 'r:1');
+ });
+});
+
+// =========================================================================
+// Stale state
+// =========================================================================
+
+describe('stale state', () => {
+ test('renders stale UI + fires onStale when isStale returns true', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [], stale: true }) }),
+ });
+ const create = loadController(sandbox);
+ let staleHookCalled = false;
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ isStale: (items, data) => data.stale && items.length === 0,
+ renderItems: () => '',
+ staleMessage: 'Updating from upstream',
+ onStale: () => { staleHookCalled = true; },
+ });
+ await ctrl.load();
+ assert.equal(ctrl.getState().phase, 'stale');
+ assert.equal(staleHookCalled, true);
+ assert.match(sandbox._elements.get('#x').innerHTML, /Updating from upstream/);
+ });
+
+ test('stale wins over empty when both apply', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [], stale: true }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ isStale: () => true,
+ renderItems: () => '',
+ emptyMessage: 'EMPTY',
+ staleMessage: 'STALE',
+ });
+ await ctrl.load();
+ const html = sandbox._elements.get('#x').innerHTML;
+ assert.match(html, /STALE/);
+ assert.doesNotMatch(html, /EMPTY/);
+ });
+
+ test('custom renderStale overrides default stale UI', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [], stale: true }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ isStale: () => true,
+ renderItems: () => '',
+ renderStale: () => '',
+ });
+ await ctrl.load();
+ assert.equal(sandbox._elements.get('#x').innerHTML, '');
+ });
+});
+
+// =========================================================================
+// Error state
+// =========================================================================
+
+describe('error state', () => {
+ test('renders error block on HTTP non-ok', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: false, status: 500, json: async () => ({}) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ errorMessage: 'load failed',
+ });
+ await ctrl.load();
+ assert.equal(ctrl.getState().phase, 'error');
+ assert.match(sandbox._elements.get('#x').innerHTML, /load failed/);
+ });
+
+ test('renders error block when fetch throws', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => { throw new Error('network down'); },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ errorMessage: 'oops',
+ });
+ await ctrl.load();
+ assert.equal(ctrl.getState().phase, 'error');
+ assert.match(sandbox._elements.get('#x').innerHTML, /oops/);
+ });
+
+ test('fires showToast on error when showErrorToast: true', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => { throw new Error('boom'); },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ errorMessage: 'load broke',
+ showErrorToast: true,
+ });
+ await ctrl.load();
+ assert.equal(sandbox._toasts.length, 1);
+ assert.equal(sandbox._toasts[0].msg, 'load broke');
+ assert.equal(sandbox._toasts[0].type, 'error');
+ });
+
+ test('does NOT fire toast when showErrorToast omitted', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => { throw new Error('boom'); },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ });
+ await ctrl.load();
+ assert.equal(sandbox._toasts.length, 0);
+ });
+});
+
+// =========================================================================
+// No-fetch data: mode
+// =========================================================================
+
+describe('no-fetch data mode', () => {
+ test('renders provided data without calling fetch', async () => {
+ let fetchCalled = false;
+ const sandbox = makeSandbox({
+ fetch: async () => { fetchCalled = true; throw new Error('should not fetch'); },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ data: { success: true, items: [{ id: 1 }, { id: 2 }] },
+ extractItems: (d) => d.items,
+ renderItems: (items) => `n:${items.length}`,
+ });
+ await ctrl.load();
+ assert.equal(fetchCalled, false);
+ assert.equal(sandbox._elements.get('#x').innerHTML, 'n:2');
+ });
+
+ test('accepts data as a function', async () => {
+ const sandbox = makeSandbox();
+ const create = loadController(sandbox);
+ let dataCalls = 0;
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ data: () => { dataCalls++; return { success: true, items: [{ id: 9 }] }; },
+ extractItems: (d) => d.items,
+ renderItems: (items) => `f:${items[0].id}`,
+ });
+ await ctrl.load();
+ assert.equal(dataCalls, 1);
+ assert.equal(sandbox._elements.get('#x').innerHTML, 'f:9');
+ });
+});
+
+// =========================================================================
+// manualDom mode
+// =========================================================================
+
+describe('manualDom mode', () => {
+ test('does NOT write renderItems return into contentEl', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ manualDom: true,
+ extractItems: (d) => d.items,
+ renderItems: () => '',
+ });
+ await ctrl.load();
+ const html = sandbox._elements.get('#x').innerHTML;
+ // Spinner from _showLoading was the last write; manualDom mode
+ // didn't replace it. The renderer gets called for side-effects
+ // (which the test doesn't exercise here) but innerHTML stays
+ // whatever the loading spinner left.
+ assert.doesNotMatch(html, /should-not-appear/);
+ assert.equal(ctrl.getState().phase, 'rendered');
+ });
+
+ test('still fires renderItems for side-effects', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
+ });
+ const create = loadController(sandbox);
+ let renderCalled = false;
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ manualDom: true,
+ extractItems: (d) => d.items,
+ renderItems: () => { renderCalled = true; },
+ });
+ await ctrl.load();
+ assert.equal(renderCalled, true);
+ });
+});
+
+// =========================================================================
+// Fetch URL forms
+// =========================================================================
+
+describe('fetchUrl forms', () => {
+ test('callable fetchUrl is invoked at load time', async () => {
+ let urlCalls = 0;
+ const sandbox = makeSandbox({
+ fetch: async (url) => {
+ assert.equal(url, '/u/computed');
+ return { ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) };
+ },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: () => { urlCalls++; return '/u/computed'; },
+ extractItems: (d) => d.items,
+ renderItems: () => 'r',
+ });
+ await ctrl.load();
+ assert.equal(urlCalls, 1);
+ // Calling refresh re-resolves the URL — important for sections
+ // whose URL depends on runtime state (e.g. season key).
+ await ctrl.refresh();
+ assert.equal(urlCalls, 2);
+ });
+});
+
+// =========================================================================
+// Coalescing + refresh
+// =========================================================================
+
+describe('load coalescing and refresh', () => {
+ test('two concurrent load() calls share one fetch', async () => {
+ let fetchCalls = 0;
+ const sandbox = makeSandbox({
+ fetch: async () => {
+ fetchCalls++;
+ // Yield once so both load() calls land on the same in-flight promise.
+ await new Promise((r) => setImmediate(r));
+ return { ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) };
+ },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => 'r',
+ });
+ await Promise.all([ctrl.load(), ctrl.load(), ctrl.load()]);
+ assert.equal(fetchCalls, 1);
+ });
+
+ test('refresh() bypasses the coalesce and re-fetches', async () => {
+ let fetchCalls = 0;
+ const sandbox = makeSandbox({
+ fetch: async () => {
+ fetchCalls++;
+ return { ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) };
+ },
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => 'r',
+ });
+ await ctrl.load();
+ await ctrl.refresh();
+ await ctrl.refresh();
+ assert.equal(fetchCalls, 3);
+ });
+});
+
+// =========================================================================
+// Hook error containment
+// =========================================================================
+
+describe('hook error containment', () => {
+ test('throwing renderer hook does not crash the controller', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => 'r',
+ onRendered: () => { throw new Error('hook boom'); },
+ });
+ // Test passes if this doesn't throw out of the await.
+ await ctrl.load();
+ assert.equal(ctrl.getState().phase, 'rendered');
+ });
+
+ test('throwing onSuccess hook does not block the render', async () => {
+ const sandbox = makeSandbox({
+ fetch: async () => ({ ok: true, json: async () => ({ success: true, items: [{ id: 1 }] }) }),
+ });
+ const create = loadController(sandbox);
+ const ctrl = create({
+ id: 'test',
+ contentEl: '#x',
+ fetchUrl: '/u',
+ extractItems: (d) => d.items,
+ renderItems: () => 'rendered-anyway',
+ onSuccess: () => { throw new Error('boom'); },
+ });
+ await ctrl.load();
+ assert.equal(sandbox._elements.get('#x').innerHTML, 'rendered-anyway');
+ });
+});
diff --git a/tests/test_discover_section_controller_js.py b/tests/test_discover_section_controller_js.py
new file mode 100644
index 00000000..8ebcd1d1
--- /dev/null
+++ b/tests/test_discover_section_controller_js.py
@@ -0,0 +1,76 @@
+"""Run the JS tests for `webui/static/discover-section-controller.js`
+under the regular pytest sweep.
+
+The actual contract tests live in
+`tests/static/test_discover_section_controller.mjs` and run via
+Node.js's stable built-in test runner (`node --test`). This shim
+shells out to that runner and asserts a clean exit so the JS tests
+fail the suite if the controller contract drifts.
+
+Skipped when:
+ - `node` isn't on PATH (e.g. Python-only dev container).
+ - Node version < 22 (the built-in `--test` runner went stable in 18
+ but the assert-flavor we use is 22+).
+
+Run directly:
+ node --test tests/static/test_discover_section_controller.mjs
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+_REPO_ROOT = Path(__file__).resolve().parents[1]
+_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_discover_section_controller.mjs"
+
+
+def _node_available() -> bool:
+ if not shutil.which("node"):
+ return False
+ try:
+ result = subprocess.run(
+ ["node", "--version"],
+ capture_output=True, text=True, timeout=10,
+ )
+ except (subprocess.SubprocessError, FileNotFoundError):
+ return False
+ if result.returncode != 0:
+ return False
+ # Output looks like "v22.21.0"
+ raw = (result.stdout or "").strip().lstrip("v")
+ try:
+ major = int(raw.split(".")[0])
+ except (ValueError, IndexError):
+ return False
+ return major >= 22
+
+
+def test_discover_section_controller_js():
+ """Pin the JS controller's lifecycle contract via `node --test`."""
+ if not _node_available():
+ pytest.skip("Node.js >= 22 required to run the JS controller tests")
+
+ if not _TEST_FILE.exists():
+ pytest.skip(f"JS test file missing: {_TEST_FILE}")
+
+ result = subprocess.run(
+ ["node", "--test", str(_TEST_FILE)],
+ capture_output=True, text=True,
+ cwd=str(_REPO_ROOT),
+ timeout=60,
+ )
+
+ if result.returncode != 0:
+ # Surface the node test runner output so failures are
+ # debuggable from the pytest log without re-running by hand.
+ pytest.fail(
+ "JS controller tests failed:\n\n"
+ f"--- stdout ---\n{result.stdout}\n"
+ f"--- stderr ---\n{result.stderr}",
+ pytrace=False,
+ )
diff --git a/webui/index.html b/webui/index.html
index 3de06f8e..7b0d993a 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -7908,6 +7908,7 @@
+
diff --git a/webui/static/discover-section-controller.js b/webui/static/discover-section-controller.js
new file mode 100644
index 00000000..fc60635b
--- /dev/null
+++ b/webui/static/discover-section-controller.js
@@ -0,0 +1,452 @@
+/**
+ * Discover Section Controller
+ * ---------------------------
+ *
+ * Owns the lifecycle every discover-page section already does by hand:
+ *
+ * 1. show a loading spinner in the carousel container
+ * 2. fetch the section's endpoint (or use pre-fetched data)
+ * 3. parse the response, decide whether the data is empty
+ * 4. either show the empty state, render the items, show a stale
+ * "still updating" state, or show an error
+ * 5. wire any post-render handlers (download buttons, hover, etc)
+ * 6. expose a refresh() method so the same lifecycle can re-fire
+ *
+ * Each section currently re-implements this by hand in `discover.js`
+ * with subtle drift — different empty-state messages, inconsistent
+ * error handling, inconsistent refresh-button feedback, no consistent
+ * error toast. This controller is the "lift what's truly shared"
+ * extraction: register a section once, the controller handles the
+ * lifecycle, the section provides only its renderer.
+ *
+ * Renderers stay per-section because section data shapes legitimately
+ * differ (album cards vs artist circles vs playlist tiles vs track
+ * rows). The controller is the lifecycle wrapper around those
+ * renderers, not a forced visual abstraction.
+ *
+ * USAGE:
+ *
+ * const ctrl = createDiscoverSectionController({
+ * id: 'recent-releases',
+ * contentEl: '#recent-releases-carousel',
+ * fetchUrl: '/api/discover/recent-releases',
+ * extractItems: (data) => data.albums || [],
+ * renderItems: (items, data, ctx) => buildCardsHtml(items),
+ * onRendered: (ctx) => attachClickHandlers(ctx.contentEl),
+ * loadingMessage: 'Loading recent releases...',
+ * emptyMessage: 'No recent releases found',
+ * errorMessage: 'Failed to load recent releases',
+ * });
+ * ctrl.load();
+ *
+ * EXTENSIONS:
+ *
+ * `fetchUrl` accepts a function returning a string for sections
+ * whose endpoint depends on runtime state (e.g. seasonal playlist
+ * keyed by `currentSeasonKey`).
+ *
+ * `data` lets a section bypass fetch entirely — the controller still
+ * runs success / empty / render / onRendered, just without going to
+ * the network. Use when a parent already fetched and just wants the
+ * shared lifecycle. `data` may be a value or a `() => value`
+ * function. Sections must supply EITHER `fetchUrl` OR `data`, not
+ * both.
+ *
+ * `beforeLoad(ctx)` runs before the spinner shows. Useful for
+ * ensuring `contentEl` exists (e.g. dynamically inserted sections)
+ * or updating sibling headers / subtitles before any visual change.
+ *
+ * `onSuccess(data, ctx)` runs after the success check passes but
+ * before isEmpty / isStale checks. Cleaner home for header text
+ * updates that depend on response data (vs folding them into
+ * renderItems).
+ *
+ * `isStale(items, data)` + `onStale(ctx)` give sections a third
+ * render state for "data is empty but the upstream is still
+ * discovering". Returning true from `isStale` renders the stale
+ * state (default: spinner + "Updating..." copy, override via
+ * `renderStale` or `staleMessage`) and fires `onStale` so the
+ * section can start a poller. Stale wins over empty when both apply.
+ *
+ * `showErrorToast: true` opens a global `showToast(...)` on error
+ * in addition to the in-section error block. Default off — sections
+ * that have no recovery action shouldn't shout at the user.
+ *
+ * `manualDom: true` tells the controller to NOT write the
+ * `renderItems` return value into `contentEl`. The renderer takes
+ * full responsibility for the DOM (e.g. delegating to an existing
+ * grid renderer that targets a child element). The renderer is
+ * still called, just for its side-effects. Default false.
+ */
+
+(function () {
+ 'use strict';
+
+ function _validateConfig(cfg) {
+ if (!cfg || typeof cfg !== 'object') {
+ throw new Error('createDiscoverSectionController: config required');
+ }
+ if (typeof cfg.id !== 'string' || !cfg.id) {
+ throw new Error('createDiscoverSectionController: config.id required (string)');
+ }
+ if (typeof cfg.contentEl !== 'string' && !(cfg.contentEl instanceof Element)) {
+ throw new Error(`[discover:${cfg.id}] config.contentEl required (selector or Element)`);
+ }
+ const hasFetch = (typeof cfg.fetchUrl === 'string' && cfg.fetchUrl) || typeof cfg.fetchUrl === 'function';
+ const hasData = cfg.data !== undefined;
+ if (!hasFetch && !hasData) {
+ throw new Error(`[discover:${cfg.id}] either config.fetchUrl or config.data required`);
+ }
+ if (hasFetch && hasData) {
+ throw new Error(`[discover:${cfg.id}] config.fetchUrl and config.data are mutually exclusive`);
+ }
+ if (typeof cfg.renderItems !== 'function') {
+ throw new Error(`[discover:${cfg.id}] config.renderItems required (function)`);
+ }
+ // Cin standard — explicit > implicit. Each section knows its own
+ // response shape; the controller refusing to guess prevents
+ // silent wrong-key bugs (e.g. an endpoint that returns
+ // `data.results` getting auto-pulled instead of the intended
+ // `data.tracks`).
+ if (typeof cfg.extractItems !== 'function') {
+ throw new Error(`[discover:${cfg.id}] config.extractItems required (function returning array)`);
+ }
+ }
+
+ function _resolveEl(el) {
+ if (el instanceof Element) return el;
+ if (typeof el === 'string') return document.querySelector(el);
+ return null;
+ }
+
+ /**
+ * @param {Object} cfg - Section config (see file header for shape)
+ * @returns {Object} Public API: { load, refresh, destroy, getState }
+ */
+ function createDiscoverSectionController(cfg) {
+ _validateConfig(cfg);
+
+ const config = Object.assign({
+ sectionEl: null,
+ hideWhenEmpty: false,
+ renderEmptyState: true,
+ fetchMethod: 'GET',
+ fetchOptions: null,
+ // Either fetchUrl (string or () => string) or data
+ // (value or () => value). Validated mutually exclusive above.
+ extractItems: null,
+ isSuccess: null,
+ isEmpty: null,
+ // Stale = data is empty but upstream is still discovering.
+ // Returning true here renders the stale state instead of
+ // empty, and fires onStale so the section can poll.
+ isStale: null,
+ renderStale: null,
+ staleMessage: 'Updating...',
+ // Hooks
+ beforeLoad: null, // (ctx) => void — before spinner shows
+ onSuccess: null, // (data, ctx) => void — after success gate
+ onStale: null, // (ctx) => void — when stale state renders
+ onRendered: null, // (ctx) => void — after content renders
+ // UX copy
+ loadingMessage: 'Loading...',
+ emptyMessage: 'Nothing to show',
+ errorMessage: 'Failed to load',
+ loadingClass: 'discover-loading',
+ emptyClass: 'discover-empty',
+ errorClass: 'discover-empty',
+ staleClass: 'discover-loading',
+ // Errors
+ verboseErrors: false,
+ showErrorToast: false, // also fire window.showToast on error
+ // Renderer takes responsibility for the DOM — controller
+ // calls renderItems but does NOT write its return value
+ // into contentEl. Use when delegating to an existing
+ // renderer that targets a child element.
+ manualDom: false,
+ }, cfg);
+
+ const state = {
+ phase: 'idle', // idle | loading | rendered | empty | stale | error
+ lastData: null,
+ lastError: null,
+ inFlight: null,
+ };
+
+ function _setHtml(el, html) {
+ if (el) el.innerHTML = html;
+ }
+
+ function _ctx(extra) {
+ return Object.assign(
+ { contentEl: _resolveEl(config.contentEl), config },
+ extra || {},
+ );
+ }
+
+ function _showLoading() {
+ const contentEl = _resolveEl(config.contentEl);
+ if (!contentEl) return;
+ const msg = config.loadingMessage
+ ? `
${config.loadingMessage}
`
+ : '';
+ _setHtml(contentEl, `
+
+ `);
+ state.phase = 'loading';
+ }
+
+ function _showEmpty() {
+ const contentEl = _resolveEl(config.contentEl);
+ if (!contentEl) return;
+ if (config.hideWhenEmpty) {
+ const sectionEl = _resolveEl(config.sectionEl);
+ if (sectionEl) sectionEl.style.display = 'none';
+ state.phase = 'empty';
+ return;
+ }
+ if (config.renderEmptyState) {
+ _setHtml(contentEl, `
+
+
${config.emptyMessage}
+
+ `);
+ } else {
+ _setHtml(contentEl, '');
+ }
+ state.phase = 'empty';
+ }
+
+ function _showStale(items, data) {
+ const contentEl = _resolveEl(config.contentEl);
+ if (!contentEl) return;
+ _showSection();
+ // Custom renderStale wins. Otherwise default spinner + copy.
+ let html;
+ if (typeof config.renderStale === 'function') {
+ try {
+ html = config.renderStale(items, data, _ctx({ items, data }));
+ } catch (err) {
+ console.debug(`[discover:${config.id}] renderStale threw:`, err);
+ html = null;
+ }
+ }
+ if (html === null || html === undefined) {
+ html = `
+
+
+
${config.staleMessage}
+
+ `;
+ }
+ _setHtml(contentEl, html);
+ state.phase = 'stale';
+
+ if (typeof config.onStale === 'function') {
+ try {
+ config.onStale(_ctx({ items, data }));
+ } catch (err) {
+ console.debug(`[discover:${config.id}] onStale hook threw:`, err);
+ }
+ }
+ }
+
+ function _showError(error) {
+ const contentEl = _resolveEl(config.contentEl);
+ if (!contentEl) return;
+ _setHtml(contentEl, `
+
+
${config.errorMessage}
+
+ `);
+ state.phase = 'error';
+ state.lastError = error;
+ const log = config.verboseErrors ? console.error : console.debug;
+ log(`[discover:${config.id}]`, error);
+ if (config.showErrorToast && typeof window.showToast === 'function') {
+ try {
+ window.showToast(config.errorMessage, 'error');
+ } catch (toastErr) {
+ console.debug(`[discover:${config.id}] toast failed:`, toastErr);
+ }
+ }
+ }
+
+ function _showSection() {
+ const sectionEl = _resolveEl(config.sectionEl);
+ if (sectionEl) sectionEl.style.display = '';
+ }
+
+ function _extractItems(data) {
+ // Validation guarantees `extractItems` is a function. Wrap
+ // the call so a renderer-side typo (returning undefined etc)
+ // doesn't crash the loop — fall back to empty list.
+ const items = config.extractItems(data);
+ return Array.isArray(items) ? items : [];
+ }
+
+ function _isSuccess(data) {
+ if (config.isSuccess) return config.isSuccess(data);
+ if (data && Object.prototype.hasOwnProperty.call(data, 'success')) {
+ return Boolean(data.success);
+ }
+ return true;
+ }
+
+ function _isEmpty(items, data) {
+ if (config.isEmpty) return config.isEmpty(items, data);
+ return !Array.isArray(items) || items.length === 0;
+ }
+
+ function _isStale(items, data) {
+ if (typeof config.isStale !== 'function') return false;
+ try {
+ return Boolean(config.isStale(items, data));
+ } catch (err) {
+ console.debug(`[discover:${config.id}] isStale threw:`, err);
+ return false;
+ }
+ }
+
+ function _resolveFetchUrl() {
+ if (typeof config.fetchUrl === 'function') return config.fetchUrl();
+ return config.fetchUrl;
+ }
+
+ function _resolveStaticData() {
+ if (typeof config.data === 'function') return config.data();
+ return config.data;
+ }
+
+ async function load() {
+ // Coalesce concurrent loads — refresh() bypasses the coalesce.
+ if (state.inFlight) return state.inFlight;
+
+ // Run beforeLoad first so it can set up `contentEl` (dynamic
+ // section creation) before the visibility check below.
+ if (typeof config.beforeLoad === 'function') {
+ try {
+ config.beforeLoad(_ctx());
+ } catch (err) {
+ console.debug(`[discover:${config.id}] beforeLoad hook threw:`, err);
+ }
+ }
+
+ const contentEl = _resolveEl(config.contentEl);
+ if (!contentEl) {
+ console.debug(`[discover:${config.id}] contentEl not found, skipping load`);
+ return Promise.resolve();
+ }
+
+ _showLoading();
+
+ const promise = (async () => {
+ try {
+ let data;
+ if (config.data !== undefined) {
+ // No-fetch mode — parent already has the data.
+ data = _resolveStaticData();
+ } else {
+ const fetchOpts = (typeof config.fetchOptions === 'function')
+ ? (config.fetchOptions() || {})
+ : {};
+ const init = Object.assign(
+ { method: config.fetchMethod },
+ fetchOpts,
+ );
+ const url = _resolveFetchUrl();
+ const resp = await fetch(url, init);
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}`);
+ }
+ data = await resp.json();
+ }
+ state.lastData = data;
+
+ if (!_isSuccess(data)) {
+ _showEmpty();
+ return;
+ }
+
+ if (typeof config.onSuccess === 'function') {
+ try {
+ config.onSuccess(data, _ctx({ data }));
+ } catch (err) {
+ console.debug(`[discover:${config.id}] onSuccess hook threw:`, err);
+ }
+ }
+
+ const items = _extractItems(data);
+
+ // Stale wins over empty — section is empty *now* but
+ // upstream is still discovering, so show updating UI
+ // rather than the bare "nothing here" copy.
+ if (_isStale(items, data)) {
+ _showStale(items, data);
+ return;
+ }
+
+ if (_isEmpty(items, data)) {
+ _showEmpty();
+ return;
+ }
+
+ _showSection();
+ const html = config.renderItems(items, data, _ctx({ items, data }));
+ if (!config.manualDom) {
+ // Default: controller owns the DOM. Renderer
+ // returns the HTML, controller swaps it in.
+ // Falsy returns become an empty container.
+ _setHtml(contentEl, html || '');
+ }
+ // manualDom mode: renderer already wrote whatever
+ // it needs into the page; controller leaves
+ // contentEl alone.
+ state.phase = 'rendered';
+
+ if (typeof config.onRendered === 'function') {
+ try {
+ config.onRendered(_ctx({ items, data }));
+ } catch (hookErr) {
+ console.debug(`[discover:${config.id}] onRendered hook threw:`, hookErr);
+ }
+ }
+ } catch (err) {
+ _showError(err);
+ } finally {
+ state.inFlight = null;
+ }
+ })();
+
+ state.inFlight = promise;
+ return promise;
+ }
+
+ async function refresh() {
+ state.inFlight = null;
+ return load();
+ }
+
+ function destroy() {
+ state.inFlight = null;
+ state.lastData = null;
+ state.lastError = null;
+ state.phase = 'idle';
+ }
+
+ function getState() {
+ return {
+ phase: state.phase,
+ hasData: state.lastData !== null,
+ error: state.lastError,
+ };
+ }
+
+ return { load, refresh, destroy, getState };
+ }
+
+ window.createDiscoverSectionController = createDiscoverSectionController;
+})();
diff --git a/webui/static/discover.js b/webui/static/discover.js
index 7671d015..c7ba7e36 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -842,54 +842,53 @@ function showDiscoverHeroEmpty() {
if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations';
}
-async function loadDiscoverRecentReleases() {
- try {
- const carousel = document.getElementById('recent-releases-carousel');
- if (!carousel) return;
-
- carousel.innerHTML = 'Loading recent releases...
';
-
- const response = await fetch('/api/discover/recent-releases');
- if (!response.ok) {
- throw new Error('Failed to fetch recent releases');
- }
-
- const data = await response.json();
- if (!data.success || !data.albums || data.albums.length === 0) {
- carousel.innerHTML = '';
- return;
- }
-
- // Store albums for download functionality
- discoverRecentAlbums = data.albums;
+// Recent Releases — first section migrated to the shared
+// `createDiscoverSectionController`. The controller owns the
+// loading / empty / error / refresh lifecycle that every other
+// discover section currently re-implements by hand. This function
+// stays as the public entry-point so existing callers don't change;
+// internally it builds (or reuses) the controller and triggers a
+// load. See `discover-section-controller.js` for the contract.
+let _recentReleasesCtrl = null;
+
+function _renderRecentReleaseCard(album, index) {
+ const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
+ return `
+
+
+

+
+
+
${album.album_name}
+
${album.artist_name}
+
${album.release_date}
+
+
+ `;
+}
- // Build carousel HTML
- let html = '';
- data.albums.forEach((album, index) => {
- const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
- html += `
-
-
-

-
-
-
${album.album_name}
-
${album.artist_name}
-
${album.release_date}
-
-
- `;
+async function loadDiscoverRecentReleases() {
+ if (!_recentReleasesCtrl) {
+ _recentReleasesCtrl = createDiscoverSectionController({
+ id: 'recent-releases',
+ contentEl: '#recent-releases-carousel',
+ fetchUrl: '/api/discover/recent-releases',
+ extractItems: (data) => data.albums || [],
+ renderItems: (items) => {
+ // Module-level `discoverRecentAlbums` is what the click
+ // handler reads to look up the album by index. Keep it
+ // in sync so `openDownloadModalForRecentAlbum(index)`
+ // still resolves correctly after re-renders.
+ discoverRecentAlbums = items;
+ return items.map((album, i) => _renderRecentReleaseCard(album, i)).join('');
+ },
+ loadingMessage: 'Loading recent releases...',
+ emptyMessage: 'No recent releases found',
+ errorMessage: 'Failed to load recent releases',
+ showErrorToast: true,
});
-
- carousel.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading recent releases:', error);
- const carousel = document.getElementById('recent-releases-carousel');
- if (carousel) {
- carousel.innerHTML = 'Failed to load recent releases
';
- }
}
+ return _recentReleasesCtrl.load();
}
// ===============================
@@ -911,46 +910,64 @@ function debouncedYourAlbumsSearch() {
}, 400);
}
-async function loadYourAlbums() {
- const section = document.getElementById('your-albums-section');
- if (!section) return;
- try {
- const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
- if (!resp.ok) return;
- const data = await resp.json();
- if (!data.success) return;
-
- const totalCount = (data.stats && data.stats.total) || 0;
- if (totalCount === 0 && !data.stale) return; // Nothing to show yet
-
- section.style.display = '';
- yourAlbums = data.albums || [];
- yourAlbumsTotal = data.total || 0;
- yourAlbumsPage = 1;
-
- const subtitle = document.getElementById('your-albums-subtitle');
- if (subtitle && data.stats) {
- const s = data.stats;
- subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
- }
-
- const filters = document.getElementById('your-albums-filters');
- if (filters && totalCount > 0) filters.style.display = '';
-
- const downloadBtn = document.getElementById('your-albums-download-btn');
- if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = '';
+let _yourAlbumsCtrl = null;
- _renderYourAlbumsGrid(yourAlbums);
- _renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
-
- if (data.stale && totalCount === 0) {
- const grid = document.getElementById('your-albums-grid');
- if (grid) grid.innerHTML = 'Fetching your albums from connected services...
';
- _pollYourAlbums();
- }
- } catch (e) {
- console.error('Error loading your albums:', e);
+async function loadYourAlbums() {
+ if (!_yourAlbumsCtrl) {
+ _yourAlbumsCtrl = createDiscoverSectionController({
+ id: 'your-albums',
+ sectionEl: '#your-albums-section',
+ contentEl: '#your-albums-grid',
+ fetchUrl: '/api/discover/your-albums?page=1&per_page=48&status=all',
+ extractItems: (data) => data.albums || [],
+ // Truly empty (no data + not stale) \u2192 hide the whole section
+ // (matches the legacy "Nothing to show yet" early-return). The
+ // outer hideWhenEmpty + sectionEl handle the visibility flip.
+ isEmpty: (items, data) => {
+ const total = (data && data.stats && data.stats.total) || 0;
+ return total === 0 && !data.stale;
+ },
+ hideWhenEmpty: true,
+ // Stale + no albums yet \u2192 show the "fetching from connected
+ // services" UI and start the poller. Fires before isEmpty.
+ isStale: (items, data) => {
+ const total = (data && data.stats && data.stats.total) || 0;
+ return Boolean(data && data.stale) && total === 0;
+ },
+ renderStale: () =>
+ 'Fetching your albums from connected services...
',
+ onStale: () => _pollYourAlbums(),
+ // Side-effects against sibling DOM (subtitle / filters /
+ // download button) belong here, not in renderItems.
+ onSuccess: (data) => {
+ const subtitle = document.getElementById('your-albums-subtitle');
+ if (subtitle && data.stats) {
+ const s = data.stats;
+ subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
+ }
+ const totalCount = (data.stats && data.stats.total) || 0;
+ const filters = document.getElementById('your-albums-filters');
+ if (filters && totalCount > 0) filters.style.display = '';
+ const downloadBtn = document.getElementById('your-albums-download-btn');
+ if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = '';
+ },
+ // Renderer delegates to the existing grid renderer, which
+ // writes its own DOM into `#your-albums-grid`. `manualDom`
+ // tells the controller not to clobber it.
+ manualDom: true,
+ renderItems: (items, data) => {
+ yourAlbums = items;
+ yourAlbumsTotal = data.total || 0;
+ yourAlbumsPage = 1;
+ _renderYourAlbumsGrid(yourAlbums);
+ _renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
+ },
+ errorMessage: 'Failed to load your albums',
+ verboseErrors: true,
+ showErrorToast: true,
+ });
}
+ return _yourAlbumsCtrl.load();
}
function _pollYourAlbums() {
@@ -1331,118 +1348,73 @@ async function downloadMissingYourAlbums() {
}
-async function loadDiscoverReleaseRadar() {
- try {
- const playlistContainer = document.getElementById('release-radar-playlist');
- if (!playlistContainer) return;
-
- playlistContainer.innerHTML = '';
-
- const response = await fetch('/api/discover/release-radar');
- if (!response.ok) {
- throw new Error('Failed to fetch release radar');
- }
-
- const data = await response.json();
- if (!data.success || !data.tracks || data.tracks.length === 0) {
- playlistContainer.innerHTML = 'No new releases available
';
- return;
- }
-
- // Store tracks for download/sync functionality
- discoverReleaseRadarTracks = data.tracks;
+function _renderCompactTrackRow(track, index) {
+ const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
+ const durationMin = Math.floor(track.duration_ms / 60000);
+ const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
+ const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
+ return `
+
+
${index + 1}
+
+

+
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `;
+}
- // Build compact playlist HTML
- let html = '';
- data.tracks.forEach((track, index) => {
- const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
- const durationMin = Math.floor(track.duration_ms / 60000);
- const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
- const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
+let _releaseRadarCtrl = null;
- html += `
-
-
${index + 1}
-
-

-
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `;
+async function loadDiscoverReleaseRadar() {
+ if (!_releaseRadarCtrl) {
+ _releaseRadarCtrl = createDiscoverSectionController({
+ id: 'release-radar',
+ contentEl: '#release-radar-playlist',
+ fetchUrl: '/api/discover/release-radar',
+ extractItems: (data) => data.tracks || [],
+ renderItems: (items) => {
+ discoverReleaseRadarTracks = items;
+ const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
+ return `
${rows}
`;
+ },
+ loadingMessage: 'Loading release radar...',
+ emptyMessage: 'No new releases available',
+ errorMessage: 'Failed to load release radar',
+ verboseErrors: true,
+ showErrorToast: true,
});
- html += '
';
-
- playlistContainer.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading release radar:', error);
- const playlistContainer = document.getElementById('release-radar-playlist');
- if (playlistContainer) {
- playlistContainer.innerHTML = 'Failed to load release radar
';
- }
}
+ return _releaseRadarCtrl.load();
}
-async function loadDiscoverWeekly() {
- try {
- const playlistContainer = document.getElementById('discovery-weekly-playlist');
- if (!playlistContainer) return;
-
- playlistContainer.innerHTML = 'Curating your discovery playlist...
';
-
- const response = await fetch('/api/discover/weekly');
- if (!response.ok) {
- throw new Error('Failed to fetch discovery weekly');
- }
-
- const data = await response.json();
- if (!data.success || !data.tracks || data.tracks.length === 0) {
- playlistContainer.innerHTML = '';
- return;
- }
-
- // Store tracks for download/sync functionality
- discoverWeeklyTracks = data.tracks;
-
- // Build compact playlist HTML
- let html = '';
- data.tracks.forEach((track, index) => {
- const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
- const durationMin = Math.floor(track.duration_ms / 60000);
- const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
- const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
+let _weeklyCtrl = null;
- html += `
-
-
${index + 1}
-
-

-
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `;
+async function loadDiscoverWeekly() {
+ if (!_weeklyCtrl) {
+ _weeklyCtrl = createDiscoverSectionController({
+ id: 'discovery-weekly',
+ contentEl: '#discovery-weekly-playlist',
+ fetchUrl: '/api/discover/weekly',
+ extractItems: (data) => data.tracks || [],
+ renderItems: (items) => {
+ discoverWeeklyTracks = items;
+ const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
+ return `
${rows}
`;
+ },
+ loadingMessage: 'Curating your discovery playlist...',
+ emptyMessage: 'No tracks available yet',
+ errorMessage: 'Failed to load discovery weekly',
+ verboseErrors: true,
+ showErrorToast: true,
});
- html += '
';
-
- playlistContainer.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading discovery weekly:', error);
- const playlistContainer = document.getElementById('discovery-weekly-playlist');
- if (playlistContainer) {
- playlistContainer.innerHTML = 'Failed to load discovery weekly
';
- }
}
+ return _weeklyCtrl.load();
}
// ===============================
@@ -1452,51 +1424,41 @@ async function loadDiscoverWeekly() {
let selectedDecade = null;
let decadeTracks = [];
-async function loadDecadeBrowser() {
- try {
- const carousel = document.getElementById('decade-browser-carousel');
- if (!carousel) return;
-
- // Fetch available decades from backend
- const response = await fetch('/api/discover/decades/available');
- if (!response.ok) {
- throw new Error('Failed to fetch available decades');
- }
+function _renderDecadeCard(decade) {
+ const icon = getDecadeIcon(decade.year);
+ const label = `${decade.year}s`;
+ return `
+
+
+
+
${label}
+
${decade.track_count} tracks
+
Classics
+
+
+ `;
+}
- const data = await response.json();
- if (!data.success || !data.decades || data.decades.length === 0) {
- carousel.innerHTML = 'No decade content available yet. Run a watchlist scan to populate your discovery pool!
';
- return;
- }
+let _decadeBrowserCtrl = null;
- // Build decade cards matching Recent Releases style
- let html = '';
- data.decades.forEach(decade => {
- const icon = getDecadeIcon(decade.year);
- const label = `${decade.year}s`;
- html += `
-
-
-
-
${label}
-
${decade.track_count} tracks
-
Classics
-
-
- `;
+async function loadDecadeBrowser() {
+ if (!_decadeBrowserCtrl) {
+ _decadeBrowserCtrl = createDiscoverSectionController({
+ id: 'decade-browser',
+ contentEl: '#decade-browser-carousel',
+ fetchUrl: '/api/discover/decades/available',
+ extractItems: (data) => data.decades || [],
+ renderItems: (items) => items.map(d => _renderDecadeCard(d)).join(''),
+ loadingMessage: 'Loading decades...',
+ emptyMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!',
+ errorMessage: 'Failed to load decades',
+ verboseErrors: true,
+ showErrorToast: true,
});
-
- carousel.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading decade browser:', error);
- const carousel = document.getElementById('decade-browser-carousel');
- if (carousel) {
- carousel.innerHTML = '';
- }
}
+ return _decadeBrowserCtrl.load();
}
function getDecadeIcon(year) {
@@ -1554,51 +1516,41 @@ async function openDecadePlaylist(decade) {
let selectedGenre = null;
let genreTracks = [];
-async function loadGenreBrowser() {
- try {
- const carousel = document.getElementById('genre-browser-carousel');
- if (!carousel) return;
-
- // Fetch available genres from backend
- const response = await fetch('/api/discover/genres/available');
- if (!response.ok) {
- throw new Error('Failed to fetch available genres');
- }
+function _renderGenreCard(genre) {
+ const icon = getGenreIcon(genre.name);
+ const displayName = capitalizeGenre(genre.name);
+ return `
+
+
+
+
${displayName}
+
${genre.track_count} tracks
+
Curated
+
+
+ `;
+}
- const data = await response.json();
- if (!data.success || !data.genres || data.genres.length === 0) {
- carousel.innerHTML = 'No genre content available yet. Run a watchlist scan to populate your discovery pool!
';
- return;
- }
+let _genreBrowserCtrl = null;
- // Build genre cards matching Recent Releases style
- let html = '';
- data.genres.forEach(genre => {
- const icon = getGenreIcon(genre.name);
- const displayName = capitalizeGenre(genre.name);
- html += `
-
-
-
-
${displayName}
-
${genre.track_count} tracks
-
Curated
-
-
- `;
+async function loadGenreBrowser() {
+ if (!_genreBrowserCtrl) {
+ _genreBrowserCtrl = createDiscoverSectionController({
+ id: 'genre-browser',
+ contentEl: '#genre-browser-carousel',
+ fetchUrl: '/api/discover/genres/available',
+ extractItems: (data) => data.genres || [],
+ renderItems: (items) => items.map(g => _renderGenreCard(g)).join(''),
+ loadingMessage: 'Loading genres...',
+ emptyMessage: 'No genre content available yet. Run a watchlist scan to populate your discovery pool!',
+ errorMessage: 'Failed to load genres',
+ verboseErrors: true,
+ showErrorToast: true,
});
-
- carousel.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading genre browser:', error);
- const carousel = document.getElementById('genre-browser-carousel');
- if (carousel) {
- carousel.innerHTML = '';
- }
}
+ return _genreBrowserCtrl.load();
}
function getGenreIcon(genreName) {
@@ -1725,6 +1677,31 @@ async function openGenrePlaylist(genre) {
let decadeTracksCache = {}; // Store tracks for each decade
let activeDecade = null;
+// Shared sync-status display block. Used by per-tab playlists
+// (decade browser, genre browser) where we show download progress
+// in the standard "✓ completed | ⏳ pending | ✗ failed (N%)" format.
+// ListenBrainz playlists use a different shape (total/matched/failed)
+// because they show MATCHING progress against the library, not
+// download progress, so they intentionally don't use this helper.
+function _renderSyncStatusBlock(idPrefix) {
+ return `
+
+
+
+ ⟳
+ Syncing to media server...
+
+
+ ✓ 0
+ ⏳ 0
+ ✗ 0
+ (0%)
+
+
+
+ `;
+}
+
async function loadDecadeBrowserTabs() {
try {
const tabsContainer = document.getElementById('decade-tabs');
@@ -1784,21 +1761,7 @@ async function loadDecadeBrowserTabs() {
-
-
-
-
- ⟳
- Syncing to media server...
-
-
- ✓ 0
- ⏳ 0
- ✗ 0
- (0%)
-
-
-
+ ${_renderSyncStatusBlock(tabId)}
@@ -2181,21 +2144,7 @@ async function loadGenreBrowserTabs() {
-
-
-
-
- ⟳
- Syncing to media server...
-
-
- ✓ 0
- ⏳ 0
- ✗ 0
- (0%)
-
-
-
+ ${_renderSyncStatusBlock(tabId)}
@@ -3604,135 +3553,100 @@ async function loadSeasonalContent() {
}
}
-async function loadSeasonalAlbums(seasonData) {
- try {
- const carousel = document.getElementById('seasonal-albums-carousel');
- if (!carousel) return;
-
- // Show seasonal section
- const seasonalSection = document.getElementById('seasonal-albums-section');
- if (seasonalSection) {
- seasonalSection.style.display = 'block';
- }
-
- // Update header
- const seasonalTitle = document.getElementById('seasonal-albums-title');
- const seasonalSubtitle = document.getElementById('seasonal-albums-subtitle');
-
- if (seasonalTitle) {
- seasonalTitle.textContent = `${seasonData.icon} ${seasonData.name}`;
- }
- if (seasonalSubtitle) {
- seasonalSubtitle.textContent = seasonData.description;
- }
-
- // Store albums for download functionality
- discoverSeasonalAlbums = seasonData.albums || [];
-
- if (discoverSeasonalAlbums.length === 0) {
- carousel.innerHTML = '';
- return;
- }
-
- // Build carousel HTML
- let html = '';
- discoverSeasonalAlbums.forEach((album, index) => {
- const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
- html += `
-
-
-

-
-
-
${album.album_name}
-
${album.artist_name}
- ${album.release_date ? `
${album.release_date}
` : ''}
-
-
- `;
- });
-
- carousel.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading seasonal albums:', error);
- }
+function _renderSeasonalAlbumCard(album, index) {
+ const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
+ return `
+
+
+

+
+
+
${album.album_name}
+
${album.artist_name}
+ ${album.release_date ? `
${album.release_date}
` : ''}
+
+
+ `;
}
-async function loadSeasonalPlaylist(seasonData) {
- try {
- const playlistContainer = document.getElementById('seasonal-playlist');
- if (!playlistContainer) return;
-
- // Show seasonal playlist section
- const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
- if (seasonalPlaylistSection) {
- seasonalPlaylistSection.style.display = 'block';
- }
-
- // Update header
- const playlistTitle = document.getElementById('seasonal-playlist-title');
- const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle');
-
- if (playlistTitle) {
- playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`;
- }
- if (playlistSubtitle) {
- playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
- }
-
- playlistContainer.innerHTML = '';
+// Seasonal Albums uses no-fetch `data:` mode — the parent
+// `loadSeasonalContent` already fetched the season payload and passes
+// the album list directly. Controller is recreated per call so the
+// per-season `data` snapshot is current.
+async function loadSeasonalAlbums(seasonData) {
+ const albums = (seasonData && seasonData.albums) || [];
+ const ctrl = createDiscoverSectionController({
+ id: 'seasonal-albums',
+ sectionEl: '#seasonal-albums-section',
+ contentEl: '#seasonal-albums-carousel',
+ data: { success: true, albums },
+ extractItems: (data) => data.albums,
+ beforeLoad: () => {
+ const section = document.getElementById('seasonal-albums-section');
+ if (section) section.style.display = 'block';
+ const titleEl = document.getElementById('seasonal-albums-title');
+ const subtitleEl = document.getElementById('seasonal-albums-subtitle');
+ if (titleEl && seasonData) titleEl.textContent = `${seasonData.icon} ${seasonData.name}`;
+ if (subtitleEl && seasonData) subtitleEl.textContent = seasonData.description;
+ },
+ renderItems: (items) => {
+ discoverSeasonalAlbums = items;
+ return items.map((album, i) => _renderSeasonalAlbumCard(album, i)).join('');
+ },
+ emptyMessage: 'No seasonal albums found',
+ errorMessage: 'Failed to load seasonal albums',
+ verboseErrors: true,
+ showErrorToast: true,
+ });
+ return ctrl.load();
+}
- // Fetch playlist tracks
- const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`);
- if (!response.ok) {
- throw new Error('Failed to fetch seasonal playlist');
- }
+let _seasonalPlaylistCtrl = null;
+let _seasonalPlaylistCtrlKey = null;
- const data = await response.json();
+async function loadSeasonalPlaylist(seasonData) {
+ const playlistContainer = document.getElementById('seasonal-playlist');
+ if (!playlistContainer) return;
- if (!data.success || !data.tracks || data.tracks.length === 0) {
- playlistContainer.innerHTML = '';
- return;
- }
+ // Show seasonal playlist section
+ const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
+ if (seasonalPlaylistSection) {
+ seasonalPlaylistSection.style.display = 'block';
+ }
- // Store tracks for download/sync functionality
- discoverSeasonalTracks = data.tracks;
+ // Update header
+ const playlistTitle = document.getElementById('seasonal-playlist-title');
+ const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle');
- // Build compact playlist HTML
- let html = '';
- data.tracks.forEach((track, index) => {
- const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
- const durationMin = Math.floor(track.duration_ms / 60000);
- const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
- const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
+ if (playlistTitle) {
+ playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`;
+ }
+ if (playlistSubtitle) {
+ playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
+ }
- html += `
-
-
${index + 1}
-
-

-
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `;
+ // Re-create the controller when the season key changes so the
+ // fetchUrl always points at the active season's endpoint.
+ if (!_seasonalPlaylistCtrl || _seasonalPlaylistCtrlKey !== currentSeasonKey) {
+ _seasonalPlaylistCtrl = createDiscoverSectionController({
+ id: 'seasonal-playlist',
+ contentEl: '#seasonal-playlist',
+ fetchUrl: `/api/discover/seasonal/${currentSeasonKey}/playlist`,
+ extractItems: (data) => data.tracks || [],
+ renderItems: (items) => {
+ discoverSeasonalTracks = items;
+ const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
+ return `
${rows}
`;
+ },
+ loadingMessage: 'Loading playlist...',
+ emptyMessage: 'No tracks available yet',
+ errorMessage: 'Failed to load playlist',
+ verboseErrors: true,
+ showErrorToast: true,
});
- html += '
';
-
- playlistContainer.innerHTML = html;
-
- } catch (error) {
- console.error('Error loading seasonal playlist:', error);
- const playlistContainer = document.getElementById('seasonal-playlist');
- if (playlistContainer) {
- playlistContainer.innerHTML = '';
- }
+ _seasonalPlaylistCtrlKey = currentSeasonKey;
}
+ return _seasonalPlaylistCtrl.load();
}
function hideSeasonalSections() {
@@ -4083,10 +3997,11 @@ async function blockDiscoveryArtist(artistName) {
const data = await res.json();
if (data.success) {
showToast(`Blocked ${artistName} from discovery`, 'success');
- // Refresh all discovery sections to remove the artist
+ // Refresh discovery sections to remove the artist. Daily Mixes
+ // is intentionally paused (see loadDiscoverPage), so don't
+ // refresh it — the section isn't on the page anyway.
loadPersonalizedHiddenGems();
loadDiscoveryShuffle();
- loadPersonalizedDailyMixes();
} else {
showToast(data.error || 'Failed to block artist', 'error');
}
@@ -4227,59 +4142,64 @@ async function unblockDiscoveryArtist(id, name) {
// Backwards compat — called during page init but now a no-op (modal handles it)
// ── Your Artists (Liked Artists Pool) ──
-async function loadYourArtists() {
- const section = document.getElementById('your-artists-section');
- const carousel = document.getElementById('your-artists-carousel');
- const subtitle = document.getElementById('your-artists-subtitle');
- if (!section || !carousel) return;
-
- try {
- const resp = await fetch('/api/discover/your-artists');
- if (!resp.ok) return;
- const data = await resp.json();
-
- if (!data.artists || data.artists.length === 0) {
- if (data.stale) {
- // First load — show section with loading state, poll until ready
- section.style.display = '';
- if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...';
- carousel.innerHTML = `
-
-
-
Fetching and matching artists from your services...
-
- `;
- _pollYourArtists();
- } else {
- section.style.display = 'none';
- }
- return;
- }
+let _yourArtistsCtrl = null;
- // Show section
- section.style.display = '';
-
- // Update subtitle with source info
- const sources = new Set();
- data.artists.forEach(a => (a.source_services || []).forEach(s => sources.add(s)));
- const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
- const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and ');
- if (subtitle) subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`;
-
- if (data.stale) {
- if (subtitle) subtitle.textContent += ' (updating...)';
- _pollYourArtists();
- }
+async function loadYourArtists() {
+ if (!_yourArtistsCtrl) {
+ _yourArtistsCtrl = createDiscoverSectionController({
+ id: 'your-artists',
+ sectionEl: '#your-artists-section',
+ contentEl: '#your-artists-carousel',
+ fetchUrl: '/api/discover/your-artists',
+ extractItems: (data) => data.artists || [],
+ // Only treat as "truly empty" when there's no data AND the
+ // upstream isn't still discovering. When stale + empty, the
+ // renderer shows a custom in-progress message and a poller
+ // is started in onRendered.
+ isEmpty: (items, data) => items.length === 0 && !data.stale,
+ hideWhenEmpty: true,
+ renderItems: (items, data) => {
+ const subtitle = document.getElementById('your-artists-subtitle');
+
+ // Stale + empty — show custom "still fetching" message
+ if (items.length === 0 && data.stale) {
+ if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...';
+ return `
+
+
+
Fetching and matching artists from your services...
+
+ `;
+ }
- // Store for modal access and render carousel cards
- window._yaArtists = {};
- window._yaActiveSource = data.active_source || 'spotify';
- data.artists.forEach(a => { window._yaArtists[a.id] = a; });
- carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join('');
+ // Update subtitle with source info
+ const sources = new Set();
+ items.forEach(a => (a.source_services || []).forEach(s => sources.add(s)));
+ const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
+ const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and ');
+ if (subtitle) {
+ subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`;
+ if (data.stale) subtitle.textContent += ' (updating...)';
+ }
- } catch (err) {
- console.error('Error loading Your Artists:', err);
+ // Store for modal access and render carousel cards
+ window._yaArtists = {};
+ window._yaActiveSource = data.active_source || 'spotify';
+ items.forEach(a => { window._yaArtists[a.id] = a; });
+ return items.map(a => _renderYourArtistCard(a)).join('');
+ },
+ onRendered: ({ data }) => {
+ // Continue polling while upstream is still discovering.
+ if (data.stale) _pollYourArtists();
+ },
+ loadingMessage: 'Loading your artists...',
+ emptyMessage: 'No followed artists found',
+ errorMessage: 'Failed to load your artists',
+ verboseErrors: true,
+ showErrorToast: true,
+ });
}
+ return _yourArtistsCtrl.load();
}
function _pollYourArtists() {
@@ -6687,60 +6607,78 @@ async function loadFamiliarFavorites() {
// BECAUSE YOU LISTEN TO
// ===============================
-async function loadBecauseYouListenTo() {
- try {
- const resp = await fetch('/api/discover/because-you-listen-to');
- if (!resp.ok) return;
- const data = await resp.json();
- if (!data.success || !data.sections || data.sections.length === 0) return;
-
- // Find or create the BYLT container
- let byltContainer = document.getElementById('discover-bylt-sections');
- if (!byltContainer) {
- // Insert after the release radar section
- const releaseRadar = document.getElementById('discover-release-radar');
- if (!releaseRadar) return;
- const parent = releaseRadar.closest('.discover-section');
- if (!parent) return;
-
- byltContainer = document.createElement('div');
- byltContainer.id = 'discover-bylt-sections';
- parent.parentNode.insertBefore(byltContainer, parent.nextSibling);
- }
-
- byltContainer.innerHTML = data.sections.map((section, idx) => `
-
-