Merge pull request #522 from Nezreka/feat/discover-section-controller
Feat/discover section controller
This commit is contained in:
commit
1f2b8f8ccd
6 changed files with 1708 additions and 526 deletions
707
tests/static/test_discover_section_controller.mjs
Normal file
707
tests/static/test_discover_section_controller.mjs
Normal file
|
|
@ -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 `<x>${items.length}</x>`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await ctrl.load();
|
||||||
|
assert.equal(renderCalls, 1);
|
||||||
|
assert.equal(sandbox._elements.get('#carousel').innerHTML, '<x>2</x>');
|
||||||
|
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: () => '<should-not-appear/>',
|
||||||
|
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: () => '<should-not-appear/>',
|
||||||
|
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: () => '<should-not-appear/>',
|
||||||
|
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: () => '<custom-stale/>',
|
||||||
|
});
|
||||||
|
await ctrl.load();
|
||||||
|
assert.equal(sandbox._elements.get('#x').innerHTML, '<custom-stale/>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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: () => '<should-not-appear/>',
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
76
tests/test_discover_section_controller_js.py
Normal file
76
tests/test_discover_section_controller_js.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -7908,6 +7908,7 @@
|
||||||
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='discover-section-controller.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
|
||||||
|
|
|
||||||
452
webui/static/discover-section-controller.js
Normal file
452
webui/static/discover-section-controller.js
Normal file
|
|
@ -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
|
||||||
|
? `<p>${config.loadingMessage}</p>`
|
||||||
|
: '';
|
||||||
|
_setHtml(contentEl, `
|
||||||
|
<div class="${config.loadingClass}">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
${msg}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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, `
|
||||||
|
<div class="${config.emptyClass}">
|
||||||
|
<p>${config.emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} 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 = `
|
||||||
|
<div class="${config.staleClass}">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>${config.staleMessage}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
_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, `
|
||||||
|
<div class="${config.errorClass}">
|
||||||
|
<p>${config.errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
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;
|
||||||
|
})();
|
||||||
|
|
@ -842,32 +842,18 @@ function showDiscoverHeroEmpty() {
|
||||||
if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations';
|
if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDiscoverRecentReleases() {
|
// Recent Releases — first section migrated to the shared
|
||||||
try {
|
// `createDiscoverSectionController`. The controller owns the
|
||||||
const carousel = document.getElementById('recent-releases-carousel');
|
// loading / empty / error / refresh lifecycle that every other
|
||||||
if (!carousel) return;
|
// 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;
|
||||||
|
|
||||||
carousel.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading recent releases...</p></div>';
|
function _renderRecentReleaseCard(album, index) {
|
||||||
|
|
||||||
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 = '<div class="discover-empty"><p>No recent releases found</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store albums for download functionality
|
|
||||||
discoverRecentAlbums = data.albums;
|
|
||||||
|
|
||||||
// Build carousel HTML
|
|
||||||
let html = '';
|
|
||||||
data.albums.forEach((album, index) => {
|
|
||||||
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
|
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
|
||||||
html += `
|
return `
|
||||||
<div class="discover-card" onclick="openDownloadModalForRecentAlbum(${index})" style="cursor: pointer;">
|
<div class="discover-card" onclick="openDownloadModalForRecentAlbum(${index})" style="cursor: pointer;">
|
||||||
<div class="discover-card-image">
|
<div class="discover-card-image">
|
||||||
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
|
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
|
||||||
|
|
@ -879,17 +865,30 @@ async function loadDiscoverRecentReleases() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="discover-empty"><p>Failed to load recent releases</p></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return _recentReleasesCtrl.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -911,46 +910,64 @@ function debouncedYourAlbumsSearch() {
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _yourAlbumsCtrl = null;
|
||||||
|
|
||||||
async function loadYourAlbums() {
|
async function loadYourAlbums() {
|
||||||
const section = document.getElementById('your-albums-section');
|
if (!_yourAlbumsCtrl) {
|
||||||
if (!section) return;
|
_yourAlbumsCtrl = createDiscoverSectionController({
|
||||||
try {
|
id: 'your-albums',
|
||||||
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
|
sectionEl: '#your-albums-section',
|
||||||
if (!resp.ok) return;
|
contentEl: '#your-albums-grid',
|
||||||
const data = await resp.json();
|
fetchUrl: '/api/discover/your-albums?page=1&per_page=48&status=all',
|
||||||
if (!data.success) return;
|
extractItems: (data) => data.albums || [],
|
||||||
|
// Truly empty (no data + not stale) \u2192 hide the whole section
|
||||||
const totalCount = (data.stats && data.stats.total) || 0;
|
// (matches the legacy "Nothing to show yet" early-return). The
|
||||||
if (totalCount === 0 && !data.stale) return; // Nothing to show yet
|
// outer hideWhenEmpty + sectionEl handle the visibility flip.
|
||||||
|
isEmpty: (items, data) => {
|
||||||
section.style.display = '';
|
const total = (data && data.stats && data.stats.total) || 0;
|
||||||
yourAlbums = data.albums || [];
|
return total === 0 && !data.stale;
|
||||||
yourAlbumsTotal = data.total || 0;
|
},
|
||||||
yourAlbumsPage = 1;
|
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: () =>
|
||||||
|
'<div class="discover-loading"><div class="loading-spinner"></div><p>Fetching your albums from connected services...</p></div>',
|
||||||
|
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');
|
const subtitle = document.getElementById('your-albums-subtitle');
|
||||||
if (subtitle && data.stats) {
|
if (subtitle && data.stats) {
|
||||||
const s = data.stats;
|
const s = data.stats;
|
||||||
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
|
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');
|
const filters = document.getElementById('your-albums-filters');
|
||||||
if (filters && totalCount > 0) filters.style.display = '';
|
if (filters && totalCount > 0) filters.style.display = '';
|
||||||
|
|
||||||
const downloadBtn = document.getElementById('your-albums-download-btn');
|
const downloadBtn = document.getElementById('your-albums-download-btn');
|
||||||
if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = '';
|
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);
|
_renderYourAlbumsGrid(yourAlbums);
|
||||||
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
|
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
|
||||||
|
},
|
||||||
if (data.stale && totalCount === 0) {
|
errorMessage: 'Failed to load your albums',
|
||||||
const grid = document.getElementById('your-albums-grid');
|
verboseErrors: true,
|
||||||
if (grid) grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Fetching your albums from connected services...</p></div>';
|
showErrorToast: true,
|
||||||
_pollYourAlbums();
|
});
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading your albums:', e);
|
|
||||||
}
|
}
|
||||||
|
return _yourAlbumsCtrl.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _pollYourAlbums() {
|
function _pollYourAlbums() {
|
||||||
|
|
@ -1331,36 +1348,12 @@ async function downloadMissingYourAlbums() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function loadDiscoverReleaseRadar() {
|
function _renderCompactTrackRow(track, index) {
|
||||||
try {
|
|
||||||
const playlistContainer = document.getElementById('release-radar-playlist');
|
|
||||||
if (!playlistContainer) return;
|
|
||||||
|
|
||||||
playlistContainer.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading release radar...</p></div>';
|
|
||||||
|
|
||||||
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 = '<div class="discover-empty"><p>No new releases available</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store tracks for download/sync functionality
|
|
||||||
discoverReleaseRadarTracks = data.tracks;
|
|
||||||
|
|
||||||
// Build compact playlist HTML
|
|
||||||
let html = '<div class="discover-playlist-tracks-compact">';
|
|
||||||
data.tracks.forEach((track, index) => {
|
|
||||||
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
|
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
|
||||||
const durationMin = Math.floor(track.duration_ms / 60000);
|
const durationMin = Math.floor(track.duration_ms / 60000);
|
||||||
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
|
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
|
||||||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||||
|
return `
|
||||||
html += `
|
|
||||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||||
<div class="track-compact-number">${index + 1}</div>
|
<div class="track-compact-number">${index + 1}</div>
|
||||||
<div class="track-compact-image">
|
<div class="track-compact-image">
|
||||||
|
|
@ -1374,75 +1367,54 @@ async function loadDiscoverReleaseRadar() {
|
||||||
<div class="track-compact-duration">${duration}</div>
|
<div class="track-compact-duration">${duration}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _releaseRadarCtrl = null;
|
||||||
|
|
||||||
|
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 `<div class="discover-playlist-tracks-compact">${rows}</div>`;
|
||||||
|
},
|
||||||
|
loadingMessage: 'Loading release radar...',
|
||||||
|
emptyMessage: 'No new releases available',
|
||||||
|
errorMessage: 'Failed to load release radar',
|
||||||
|
verboseErrors: true,
|
||||||
|
showErrorToast: true,
|
||||||
});
|
});
|
||||||
html += '</div>';
|
}
|
||||||
|
return _releaseRadarCtrl.load();
|
||||||
|
}
|
||||||
|
|
||||||
playlistContainer.innerHTML = html;
|
let _weeklyCtrl = null;
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading release radar:', error);
|
|
||||||
const playlistContainer = document.getElementById('release-radar-playlist');
|
|
||||||
if (playlistContainer) {
|
|
||||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load release radar</p></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDiscoverWeekly() {
|
async function loadDiscoverWeekly() {
|
||||||
try {
|
if (!_weeklyCtrl) {
|
||||||
const playlistContainer = document.getElementById('discovery-weekly-playlist');
|
_weeklyCtrl = createDiscoverSectionController({
|
||||||
if (!playlistContainer) return;
|
id: 'discovery-weekly',
|
||||||
|
contentEl: '#discovery-weekly-playlist',
|
||||||
playlistContainer.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Curating your discovery playlist...</p></div>';
|
fetchUrl: '/api/discover/weekly',
|
||||||
|
extractItems: (data) => data.tracks || [],
|
||||||
const response = await fetch('/api/discover/weekly');
|
renderItems: (items) => {
|
||||||
if (!response.ok) {
|
discoverWeeklyTracks = items;
|
||||||
throw new Error('Failed to fetch discovery weekly');
|
const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
|
||||||
}
|
return `<div class="discover-playlist-tracks-compact">${rows}</div>`;
|
||||||
|
},
|
||||||
const data = await response.json();
|
loadingMessage: 'Curating your discovery playlist...',
|
||||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
emptyMessage: 'No tracks available yet',
|
||||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks available yet</p></div>';
|
errorMessage: 'Failed to load discovery weekly',
|
||||||
return;
|
verboseErrors: true,
|
||||||
}
|
showErrorToast: true,
|
||||||
|
|
||||||
// Store tracks for download/sync functionality
|
|
||||||
discoverWeeklyTracks = data.tracks;
|
|
||||||
|
|
||||||
// Build compact playlist HTML
|
|
||||||
let html = '<div class="discover-playlist-tracks-compact">';
|
|
||||||
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')}`;
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
|
||||||
<div class="track-compact-number">${index + 1}</div>
|
|
||||||
<div class="track-compact-image">
|
|
||||||
<img src="${coverUrl}" alt="${track.album_name}" loading="lazy">
|
|
||||||
</div>
|
|
||||||
<div class="track-compact-info">
|
|
||||||
<div class="track-compact-name">${track.track_name}</div>
|
|
||||||
<div class="track-compact-artist">${track.artist_name}</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-compact-album">${track.album_name}</div>
|
|
||||||
<div class="track-compact-duration">${duration}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
playlistContainer.innerHTML = html;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading discovery weekly:', error);
|
|
||||||
const playlistContainer = document.getElementById('discovery-weekly-playlist');
|
|
||||||
if (playlistContainer) {
|
|
||||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load discovery weekly</p></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return _weeklyCtrl.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -1452,29 +1424,10 @@ async function loadDiscoverWeekly() {
|
||||||
let selectedDecade = null;
|
let selectedDecade = null;
|
||||||
let decadeTracks = [];
|
let decadeTracks = [];
|
||||||
|
|
||||||
async function loadDecadeBrowser() {
|
function _renderDecadeCard(decade) {
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (!data.success || !data.decades || data.decades.length === 0) {
|
|
||||||
carousel.innerHTML = '<div class="discover-empty"><p>No decade content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build decade cards matching Recent Releases style
|
|
||||||
let html = '';
|
|
||||||
data.decades.forEach(decade => {
|
|
||||||
const icon = getDecadeIcon(decade.year);
|
const icon = getDecadeIcon(decade.year);
|
||||||
const label = `${decade.year}s`;
|
const label = `${decade.year}s`;
|
||||||
html += `
|
return `
|
||||||
<div class="discover-card decade-card-modern" onclick="openDecadePlaylist(${decade.year})">
|
<div class="discover-card decade-card-modern" onclick="openDecadePlaylist(${decade.year})">
|
||||||
<div class="discover-card-image decade-card-image">
|
<div class="discover-card-image decade-card-image">
|
||||||
<div class="decade-icon-large">${icon}</div>
|
<div class="decade-icon-large">${icon}</div>
|
||||||
|
|
@ -1486,17 +1439,26 @@ async function loadDecadeBrowser() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _decadeBrowserCtrl = null;
|
||||||
|
|
||||||
|
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 = '<div class="discover-empty"><p>Failed to load decades</p></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return _decadeBrowserCtrl.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDecadeIcon(year) {
|
function getDecadeIcon(year) {
|
||||||
|
|
@ -1554,29 +1516,10 @@ async function openDecadePlaylist(decade) {
|
||||||
let selectedGenre = null;
|
let selectedGenre = null;
|
||||||
let genreTracks = [];
|
let genreTracks = [];
|
||||||
|
|
||||||
async function loadGenreBrowser() {
|
function _renderGenreCard(genre) {
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (!data.success || !data.genres || data.genres.length === 0) {
|
|
||||||
carousel.innerHTML = '<div class="discover-empty"><p>No genre content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build genre cards matching Recent Releases style
|
|
||||||
let html = '';
|
|
||||||
data.genres.forEach(genre => {
|
|
||||||
const icon = getGenreIcon(genre.name);
|
const icon = getGenreIcon(genre.name);
|
||||||
const displayName = capitalizeGenre(genre.name);
|
const displayName = capitalizeGenre(genre.name);
|
||||||
html += `
|
return `
|
||||||
<div class="discover-card genre-card-modern" onclick="openGenrePlaylist('${escapeForInlineJs(genre.name)}')">
|
<div class="discover-card genre-card-modern" onclick="openGenrePlaylist('${escapeForInlineJs(genre.name)}')">
|
||||||
<div class="discover-card-image genre-card-image">
|
<div class="discover-card-image genre-card-image">
|
||||||
<div class="genre-icon-large">${icon}</div>
|
<div class="genre-icon-large">${icon}</div>
|
||||||
|
|
@ -1588,17 +1531,26 @@ async function loadGenreBrowser() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _genreBrowserCtrl = null;
|
||||||
|
|
||||||
|
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 = '<div class="discover-empty"><p>Failed to load genres</p></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return _genreBrowserCtrl.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGenreIcon(genreName) {
|
function getGenreIcon(genreName) {
|
||||||
|
|
@ -1725,6 +1677,31 @@ async function openGenrePlaylist(genre) {
|
||||||
let decadeTracksCache = {}; // Store tracks for each decade
|
let decadeTracksCache = {}; // Store tracks for each decade
|
||||||
let activeDecade = null;
|
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 `
|
||||||
|
<div class="discover-sync-status" id="${idPrefix}-sync-status" style="display: none;">
|
||||||
|
<div class="sync-status-content">
|
||||||
|
<div class="sync-status-label">
|
||||||
|
<span class="sync-icon">⟳</span>
|
||||||
|
<span>Syncing to media server...</span>
|
||||||
|
</div>
|
||||||
|
<div class="sync-status-stats">
|
||||||
|
<span class="sync-stat">✓ <span id="${idPrefix}-sync-completed">0</span></span>
|
||||||
|
<span class="sync-stat">⏳ <span id="${idPrefix}-sync-pending">0</span></span>
|
||||||
|
<span class="sync-stat">✗ <span id="${idPrefix}-sync-failed">0</span></span>
|
||||||
|
<span class="sync-stat">(<span id="${idPrefix}-sync-percentage">0</span>%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDecadeBrowserTabs() {
|
async function loadDecadeBrowserTabs() {
|
||||||
try {
|
try {
|
||||||
const tabsContainer = document.getElementById('decade-tabs');
|
const tabsContainer = document.getElementById('decade-tabs');
|
||||||
|
|
@ -1784,21 +1761,7 @@ async function loadDecadeBrowserTabs() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sync Status Display -->
|
${_renderSyncStatusBlock(tabId)}
|
||||||
<div class="discover-sync-status" id="${tabId}-sync-status" style="display: none;">
|
|
||||||
<div class="sync-status-content">
|
|
||||||
<div class="sync-status-label">
|
|
||||||
<span class="sync-icon">⟳</span>
|
|
||||||
<span>Syncing to media server...</span>
|
|
||||||
</div>
|
|
||||||
<div class="sync-status-stats">
|
|
||||||
<span class="sync-stat">✓ <span id="${tabId}-sync-completed">0</span></span>
|
|
||||||
<span class="sync-stat">⏳ <span id="${tabId}-sync-pending">0</span></span>
|
|
||||||
<span class="sync-stat">✗ <span id="${tabId}-sync-failed">0</span></span>
|
|
||||||
<span class="sync-stat">(<span id="${tabId}-sync-percentage">0</span>%)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Track List -->
|
<!-- Track List -->
|
||||||
|
|
@ -2181,21 +2144,7 @@ async function loadGenreBrowserTabs() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sync Status Display -->
|
${_renderSyncStatusBlock(tabId)}
|
||||||
<div class="discover-sync-status" id="${tabId}-sync-status" style="display: none;">
|
|
||||||
<div class="sync-status-content">
|
|
||||||
<div class="sync-status-label">
|
|
||||||
<span class="sync-icon">⟳</span>
|
|
||||||
<span>Syncing to media server...</span>
|
|
||||||
</div>
|
|
||||||
<div class="sync-status-stats">
|
|
||||||
<span class="sync-stat">✓ <span id="${tabId}-sync-completed">0</span></span>
|
|
||||||
<span class="sync-stat">⏳ <span id="${tabId}-sync-pending">0</span></span>
|
|
||||||
<span class="sync-stat">✗ <span id="${tabId}-sync-failed">0</span></span>
|
|
||||||
<span class="sync-stat">(<span id="${tabId}-sync-percentage">0</span>%)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Track List -->
|
<!-- Track List -->
|
||||||
|
|
@ -3604,41 +3553,9 @@ async function loadSeasonalContent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSeasonalAlbums(seasonData) {
|
function _renderSeasonalAlbumCard(album, index) {
|
||||||
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 = '<div class="discover-empty"><p>No seasonal albums found</p></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build carousel HTML
|
|
||||||
let html = '';
|
|
||||||
discoverSeasonalAlbums.forEach((album, index) => {
|
|
||||||
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
|
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
|
||||||
html += `
|
return `
|
||||||
<div class="discover-card" onclick="openDownloadModalForSeasonalAlbum(${index})" style="cursor: pointer;">
|
<div class="discover-card" onclick="openDownloadModalForSeasonalAlbum(${index})" style="cursor: pointer;">
|
||||||
<div class="discover-card-image">
|
<div class="discover-card-image">
|
||||||
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
|
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
|
||||||
|
|
@ -3650,17 +3567,44 @@ async function loadSeasonalAlbums(seasonData) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
carousel.innerHTML = html;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading seasonal albums:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _seasonalPlaylistCtrl = null;
|
||||||
|
let _seasonalPlaylistCtrlKey = null;
|
||||||
|
|
||||||
async function loadSeasonalPlaylist(seasonData) {
|
async function loadSeasonalPlaylist(seasonData) {
|
||||||
try {
|
|
||||||
const playlistContainer = document.getElementById('seasonal-playlist');
|
const playlistContainer = document.getElementById('seasonal-playlist');
|
||||||
if (!playlistContainer) return;
|
if (!playlistContainer) return;
|
||||||
|
|
||||||
|
|
@ -3681,58 +3625,28 @@ async function loadSeasonalPlaylist(seasonData) {
|
||||||
playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
|
playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistContainer.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading playlist...</p></div>';
|
// Re-create the controller when the season key changes so the
|
||||||
|
// fetchUrl always points at the active season's endpoint.
|
||||||
// Fetch playlist tracks
|
if (!_seasonalPlaylistCtrl || _seasonalPlaylistCtrlKey !== currentSeasonKey) {
|
||||||
const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`);
|
_seasonalPlaylistCtrl = createDiscoverSectionController({
|
||||||
if (!response.ok) {
|
id: 'seasonal-playlist',
|
||||||
throw new Error('Failed to fetch seasonal playlist');
|
contentEl: '#seasonal-playlist',
|
||||||
}
|
fetchUrl: `/api/discover/seasonal/${currentSeasonKey}/playlist`,
|
||||||
|
extractItems: (data) => data.tracks || [],
|
||||||
const data = await response.json();
|
renderItems: (items) => {
|
||||||
|
discoverSeasonalTracks = items;
|
||||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
const rows = items.map((t, i) => _renderCompactTrackRow(t, i)).join('');
|
||||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks available yet</p></div>';
|
return `<div class="discover-playlist-tracks-compact">${rows}</div>`;
|
||||||
return;
|
},
|
||||||
}
|
loadingMessage: 'Loading playlist...',
|
||||||
|
emptyMessage: 'No tracks available yet',
|
||||||
// Store tracks for download/sync functionality
|
errorMessage: 'Failed to load playlist',
|
||||||
discoverSeasonalTracks = data.tracks;
|
verboseErrors: true,
|
||||||
|
showErrorToast: true,
|
||||||
// Build compact playlist HTML
|
|
||||||
let html = '<div class="discover-playlist-tracks-compact">';
|
|
||||||
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')}`;
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
|
||||||
<div class="track-compact-number">${index + 1}</div>
|
|
||||||
<div class="track-compact-image">
|
|
||||||
<img src="${coverUrl}" alt="${track.album_name}" loading="lazy">
|
|
||||||
</div>
|
|
||||||
<div class="track-compact-info">
|
|
||||||
<div class="track-compact-name">${track.track_name}</div>
|
|
||||||
<div class="track-compact-artist">${track.artist_name}</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-compact-album">${track.album_name}</div>
|
|
||||||
<div class="track-compact-duration">${duration}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
html += '</div>';
|
_seasonalPlaylistCtrlKey = currentSeasonKey;
|
||||||
|
|
||||||
playlistContainer.innerHTML = html;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading seasonal playlist:', error);
|
|
||||||
const playlistContainer = document.getElementById('seasonal-playlist');
|
|
||||||
if (playlistContainer) {
|
|
||||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load playlist</p></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return _seasonalPlaylistCtrl.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSeasonalSections() {
|
function hideSeasonalSections() {
|
||||||
|
|
@ -4083,10 +3997,11 @@ async function blockDiscoveryArtist(artistName) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`Blocked ${artistName} from discovery`, '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();
|
loadPersonalizedHiddenGems();
|
||||||
loadDiscoveryShuffle();
|
loadDiscoveryShuffle();
|
||||||
loadPersonalizedDailyMixes();
|
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to block artist', 'error');
|
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)
|
// Backwards compat — called during page init but now a no-op (modal handles it)
|
||||||
// ── Your Artists (Liked Artists Pool) ──
|
// ── Your Artists (Liked Artists Pool) ──
|
||||||
|
|
||||||
|
let _yourArtistsCtrl = null;
|
||||||
|
|
||||||
async function loadYourArtists() {
|
async function loadYourArtists() {
|
||||||
const section = document.getElementById('your-artists-section');
|
if (!_yourArtistsCtrl) {
|
||||||
const carousel = document.getElementById('your-artists-carousel');
|
_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');
|
const subtitle = document.getElementById('your-artists-subtitle');
|
||||||
if (!section || !carousel) return;
|
|
||||||
|
|
||||||
try {
|
// Stale + empty — show custom "still fetching" message
|
||||||
const resp = await fetch('/api/discover/your-artists');
|
if (items.length === 0 && data.stale) {
|
||||||
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...';
|
if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...';
|
||||||
carousel.innerHTML = `
|
return `
|
||||||
<div class="ya-loading">
|
<div class="ya-loading">
|
||||||
<div class="watch-all-loading-spinner"></div>
|
<div class="watch-all-loading-spinner"></div>
|
||||||
<span>Fetching and matching artists from your services...</span>
|
<span>Fetching and matching artists from your services...</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
_pollYourArtists();
|
|
||||||
} else {
|
|
||||||
section.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show section
|
|
||||||
section.style.display = '';
|
|
||||||
|
|
||||||
// Update subtitle with source info
|
// Update subtitle with source info
|
||||||
const sources = new Set();
|
const sources = new Set();
|
||||||
data.artists.forEach(a => (a.source_services || []).forEach(s => sources.add(s)));
|
items.forEach(a => (a.source_services || []).forEach(s => sources.add(s)));
|
||||||
const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
|
const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' };
|
||||||
const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and ');
|
const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and ');
|
||||||
if (subtitle) subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`;
|
if (subtitle) {
|
||||||
|
subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`;
|
||||||
if (data.stale) {
|
if (data.stale) subtitle.textContent += ' (updating...)';
|
||||||
if (subtitle) subtitle.textContent += ' (updating...)';
|
|
||||||
_pollYourArtists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store for modal access and render carousel cards
|
// Store for modal access and render carousel cards
|
||||||
window._yaArtists = {};
|
window._yaArtists = {};
|
||||||
window._yaActiveSource = data.active_source || 'spotify';
|
window._yaActiveSource = data.active_source || 'spotify';
|
||||||
data.artists.forEach(a => { window._yaArtists[a.id] = a; });
|
items.forEach(a => { window._yaArtists[a.id] = a; });
|
||||||
carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join('');
|
return items.map(a => _renderYourArtistCard(a)).join('');
|
||||||
|
},
|
||||||
} catch (err) {
|
onRendered: ({ data }) => {
|
||||||
console.error('Error loading Your Artists:', err);
|
// 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() {
|
function _pollYourArtists() {
|
||||||
|
|
@ -6687,28 +6607,8 @@ async function loadFamiliarFavorites() {
|
||||||
// BECAUSE YOU LISTEN TO
|
// BECAUSE YOU LISTEN TO
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
async function loadBecauseYouListenTo() {
|
function _renderByltSection(section, idx) {
|
||||||
try {
|
return `
|
||||||
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) => `
|
|
||||||
<div class="discover-section bylt-section">
|
<div class="discover-section bylt-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<div class="bylt-header">
|
<div class="bylt-header">
|
||||||
|
|
@ -6721,13 +6621,11 @@ async function loadBecauseYouListenTo() {
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-carousel" id="bylt-carousel-${idx}"></div>
|
<div class="discover-carousel" id="bylt-carousel-${idx}"></div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Render track cards in each carousel
|
function _renderByltTrackCard(t) {
|
||||||
data.sections.forEach((section, idx) => {
|
return `
|
||||||
const carousel = document.getElementById(`bylt-carousel-${idx}`);
|
|
||||||
if (!carousel) return;
|
|
||||||
carousel.innerHTML = section.tracks.map(t => `
|
|
||||||
<div class="discover-card">
|
<div class="discover-card">
|
||||||
<div class="discover-card-image">
|
<div class="discover-card-image">
|
||||||
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.src='/static/placeholder.png'">` : '<div class="discover-card-placeholder">🎵</div>'}
|
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.src='/static/placeholder.png'">` : '<div class="discover-card-placeholder">🎵</div>'}
|
||||||
|
|
@ -6735,12 +6633,52 @@ async function loadBecauseYouListenTo() {
|
||||||
<div class="discover-card-title">${_esc(t.name)}</div>
|
<div class="discover-card-title">${_esc(t.name)}</div>
|
||||||
<div class="discover-card-artist">${_esc(t.artist)}</div>
|
<div class="discover-card-artist">${_esc(t.artist)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
});
|
|
||||||
|
|
||||||
} 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
|
||||||
|
|
@ -3429,6 +3429,14 @@ function closeHelperSearch() {
|
||||||
// projects that span multiple commits before shipping. Strip the flag at
|
// 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.
|
// release time and add a real `date:` line at the top of the version block.
|
||||||
const WHATS_NEW = {
|
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': [
|
'2.4.2': [
|
||||||
// --- May 7, 2026 — patch release ---
|
// --- May 7, 2026 — patch release ---
|
||||||
{ date: 'May 7, 2026 — 2.4.2 release' },
|
{ date: 'May 7, 2026 — 2.4.2 release' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue