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, ` +
+
+ ${msg} +
+ `); + 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 = '

No recent releases found

'; - 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.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.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 = '

Loading release radar...

'; - - 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.album_name} +
+
+
${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.album_name} -
-
-
${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 = '

No tracks available yet

'; - 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.album_name} -
-
-
${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 ` +
+
+
${icon}
+
+
+

${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 += ` -
-
-
${icon}
-
-
-

${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 = '

Failed to load decades

'; - } } + 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 ` +
+
+
${icon}
+
+
+

${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 += ` -
-
-
${icon}
-
-
-

${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 = '

Failed to load genres

'; - } } + 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 ` + + `; +} + async function loadDecadeBrowserTabs() { try { const tabsContainer = document.getElementById('decade-tabs'); @@ -1784,21 +1761,7 @@ async function loadDecadeBrowserTabs() { - - + ${_renderSyncStatusBlock(tabId)} @@ -2181,21 +2144,7 @@ async function loadGenreBrowserTabs() { - - + ${_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 = '

No seasonal albums found

'; - 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.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.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 = '

Loading playlist...

'; +// 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 = '

No tracks available yet

'; - 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.album_name} -
-
-
${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 = '

Failed to load playlist

'; - } + _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) => ` -
-
-
- ${section.artist_image ? `` : ''} -
-
Because you listen to
-

${_esc(section.artist_name)}

-
+function _renderByltSection(section, idx) { + return ` +
+
+
+ ${section.artist_image ? `` : ''} +
+
Because you listen to
+

${_esc(section.artist_name)}

-
- `).join(''); + +
+ `; +} - // Render track cards in each carousel - data.sections.forEach((section, idx) => { - const carousel = document.getElementById(`bylt-carousel-${idx}`); - if (!carousel) return; - carousel.innerHTML = section.tracks.map(t => ` -
-
- ${t.image_url ? `` : '
🎵
'} -
-
${_esc(t.name)}
-
${_esc(t.artist)}
-
- `).join(''); - }); +function _renderByltTrackCard(t) { + return ` +
+
+ ${t.image_url ? `` : '
🎵
'} +
+
${_esc(t.name)}
+
${_esc(t.artist)}
+
+ `; +} - } catch (error) { - console.debug('Error loading Because You Listen To:', error); +let _byltCtrl = null; + +async function loadBecauseYouListenTo() { + // Ensure the BYLT container exists in the DOM. It's dynamically + // inserted after the release radar section because the markup + // doesn't ship a placeholder for it. Bail if anchor section + // isn't present. + let byltContainer = document.getElementById('discover-bylt-sections'); + if (!byltContainer) { + 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); + } + + if (!_byltCtrl) { + _byltCtrl = createDiscoverSectionController({ + id: 'because-you-listen-to', + contentEl: '#discover-bylt-sections', + fetchUrl: '/api/discover/because-you-listen-to', + extractItems: (data) => data.sections || [], + // No per-section empty/loading copy — when there's nothing + // to show we leave the container blank rather than render a + // placeholder, matching the original no-op behavior. + renderEmptyState: false, + loadingMessage: '', + renderItems: (items) => items.map((s, i) => _renderByltSection(s, i)).join(''), + onRendered: ({ items }) => { + // Inject track cards into each section's carousel after + // the section wrappers exist in the DOM. + items.forEach((section, idx) => { + const carousel = document.getElementById(`bylt-carousel-${idx}`); + if (!carousel) return; + carousel.innerHTML = section.tracks.map(t => _renderByltTrackCard(t)).join(''); + }); + }, + }); } + return _byltCtrl.load(); } // =============================== diff --git a/webui/static/helper.js b/webui/static/helper.js index 255d5ed0..7323a284 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3429,6 +3429,14 @@ function closeHelperSearch() { // projects that span multiple commits before shipping. Strip the flag at // release time and add a real `date:` line at the top of the version block. const WHATS_NEW = { + '2.4.3': [ + // --- post-2.4.2 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- + { date: 'Unreleased — 2.4.3 dev cycle' }, + { title: 'Internal: Discover Controller — Cin Pre-Review Polish', desc: 'tightened the controller before opening the PR. (1) dropped the magic `extractItems` defaults — controller used to auto-pull `data.items` / `data.albums` / `data.artists` / `data.tracks` / `data.results` if no extractor was provided. removed the fallback chain. each section now MUST supply its own `extractItems(data) => array` callback. cin standard: explicit > implicit; the auto-fallback could silently grab the wrong key on endpoints that return multiple arrays. validated at register-time so misuse fails immediately. all 10 existing call sites already had explicit extractors so no migration churn. (2) replaced the `renderItems` returning null convention (used by Your Albums + manualDom-style sections) with an explicit `manualDom: true` config flag. clearer intent at the call site, less likely to be confused with a renderer error. (3) added a minimal node `--test` JS test file at `tests/static/test_discover_section_controller.mjs` — 32 tests pin the lifecycle contract: config validation (every required field), happy-path fetch+render, empty/stale/error states, no-fetch `data:` mode, manualDom mode, callable `fetchUrl`, load coalescing, refresh bypass, hook error containment, error toasts. runs via `node --test tests/static/` directly, OR via the regular pytest sweep (`tests/test_discover_section_controller_js.py` shells out to node and asserts a clean exit). skipped gracefully when node isn\'t available or is < 22. closes the "controller is a contract, pin it at the test boundary" gap that cin would have flagged. 2205/2205 full suite green (was 2204 + 1 new pytest wrapper); 32/32 node --test pass; ruff clean; js parses clean.', page: 'discover' }, + { title: 'Internal: Discover Cleanup Round — Toast Errors, Stale State, Skipped Sections', desc: 'follow-up to the controller migration. extended `createDiscoverSectionController` with the hooks the per-section migrations surfaced as needed: callable `fetchUrl` (resolves the seasonal-playlist recreate-on-key-change hack), no-fetch `data:` mode (lets render-only sections like seasonal albums use the controller without inventing a fake endpoint), `beforeLoad` hook (lets dynamically-inserted sections like because-you-listen-to ensure their container exists before the spinner shows), `onSuccess(data)` hook (cleaner home for sibling header / subtitle / button updates than folding them into renderItems), and an `isStale` / `onStale` / `renderStale` triple for the third render state (data is empty BUT upstream is still discovering — show updating UI + start a poller, instead of the bare empty-state copy). turned on `showErrorToast: true` for every migrated section — section load failures now surface a global toast instead of silently spinning forever or swallowing into console.debug. that\'s the JohnBaumb #369 pattern applied at the UI layer. migrated the two sections that didn\'t fit the original controller contract: `loadYourAlbums` (uses isStale/onStale for stale-fetch UI + onSuccess for subtitle/filters/download-button side-effects + renderItems returning null since it delegates to the existing grid renderer) and `loadSeasonalAlbums` (uses no-fetch data mode since the parent `loadSeasonalContent` already fetched the season payload). also lifted the duplicated decade-tab + genre-tab sync-status block (✓/⏳/✗/percentage) into a `_renderSyncStatusBlock(idPrefix)` helper — two call sites now share one implementation. listenbrainz playlists keep their own block because the semantics differ (matching progress vs download progress). audit found the 13 supposedly-dead hidden sections aren\'t dead at all — they\'re gated on user data (discovery pool, library content, metadata cache) and self-surface when their data exists. removed one orphaned `loadPersonalizedDailyMixes()` call from `blockDiscoveryArtist` — daily mixes is intentionally paused, refreshing it from there was a no-op.', page: 'discover' }, + { title: 'Internal: Migrate 7 More Discover Sections to the Controller', desc: 'follow-up to the foundation commit. migrated fresh tape, the archives, time machine intro carousel, browse by genre intro carousel, seasonal mix, your artists, and because-you-listen-to onto `createDiscoverSectionController`. each one drops its own hand-rolled try/catch + spinner injection + empty-state HTML + error swallow in favor of a config object — controller owns the lifecycle. net 76 lines smaller in discover.js even after adding the per-section render helpers. skipped two sections that don\'t fit the controller\'s single-fetch / single-render-target shape: `loadYourAlbums` (paginated grid + filters, four separate UI elements updated) and `loadSeasonalAlbums` (no fetch — receives pre-fetched data from parent). hidden / dead sections (~13 of them) untouched in this pass — separate audit commit will surface or kill them. controller extension candidates surfaced for follow-up: callable `fetchUrl` (so seasonal playlist doesn\'t need controller-recreate-on-key-change), explicit `isStale` / `onStale` hook (so your-artists doesn\'t fold stale handling into renderItems), `beforeLoad` hook (so because-you-listen-to can let the controller own the dynamic container creation), and a no-fetch `data:` mode (so render-only sections like seasonal albums can use the controller). zero behavior changes — every public load function keeps its name + signature so existing callers, refresh buttons, and dashboard wiring don\'t notice the swap.', page: 'discover' }, + { title: 'Internal: Discover Section Controller Foundation', desc: 'every section on the discover page (recent releases, your artists, your albums, seasonal, fresh tape, the archives, etc) re-implements the same lifecycle by hand: show spinner → fetch endpoint → parse → either render or show empty state or show error → maybe wire post-render handlers → maybe expose refresh. ~30 sections, all subtly drifting — different empty messages, different error handling (some console.debug, some silently swallowed, some leave the spinner spinning forever), different sync-status icons, no consistent error toast. lifted that lifecycle into a shared `createDiscoverSectionController` (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 wrapper, not a forced visual abstraction). this commit is the foundation: built the controller + migrated `recent releases` as proof. each remaining section will migrate in its own follow-up commit (keeps reviews small + lets us sequence the work). once everything is on the controller, the discover-page cleanup work (kill 13 dead sections, standardize sync-status icons, add error toasts) becomes single-line registry edits instead of section-by-section rewrites.', page: 'discover' }, + ], '2.4.2': [ // --- May 7, 2026 — patch release --- { date: 'May 7, 2026 — 2.4.2 release' },