From 803dac37887d01b3ecbb389c601693b8b8912386 Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 6 Oct 2026 11:59:06 +0200 Subject: [PATCH] feat(ui): add EPG source priority controls (#24) Render active and inactive EPG priority lists, support move controls, and persist complete EPG source ordering from the settings UI. Co-Authored-By: Claude Opus 4.8 --- public/css/main.css | 87 ++++++++++++++++ public/index.html | 24 ++--- public/js/api.js | 1 + public/js/components/SourceManager.js | 145 +++++++++++++++++++++++++- test/t24Frontend.test.js | 120 +++++++++++++++++++++ 5 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 test/t24Frontend.test.js diff --git a/public/css/main.css b/public/css/main.css index 5a702f7..ae672d0 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1646,9 +1646,96 @@ video::cue { .source-actions { display: flex; + flex-wrap: wrap; gap: var(--space-xs); } +.epg-sources-section h4 { + margin: var(--space-lg) 0 var(--space-xs); +} + +.epg-sources-section .setting-hint + .setting-hint { + margin-top: 2px; +} + +.epg-order-status { + min-height: 1.25rem; + margin: var(--space-sm) 0; + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.epg-order-status[data-state="success"] { + color: var(--color-success); +} + +.epg-order-status[data-state="error"] { + color: var(--color-error); +} + +.epg-priority-controls { + display: flex; + align-items: center; + gap: var(--space-sm); + flex: 0 0 auto; +} + +.epg-priority-rank { + display: grid; + min-width: 68px; + color: var(--color-text-muted); + font-size: 0.6875rem; + text-align: center; +} + +.epg-priority-rank strong { + color: var(--color-text-primary); + font-size: 1.125rem; + line-height: 1.1; +} + +.epg-move-actions { + display: grid; + grid-template-columns: repeat(2, 44px); + gap: var(--space-xs); +} + +.epg-move-actions .btn, +.epg-source-item .source-actions .btn { + min-width: 44px; + min-height: 44px; +} + +.epg-empty-state { + padding: var(--space-lg); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); +} + +.inactive-sources-heading { + margin-top: var(--space-xl) !important; +} + +@media (max-width: 768px) { + .epg-source-item { + align-items: flex-start; + flex-wrap: wrap; + } + + .epg-priority-controls { + width: 100%; + justify-content: space-between; + } + + .epg-source-item .source-info { + min-width: calc(100% - 64px); + } + + .epg-source-item .source-actions { + width: 100%; + } +} + /* Hidden Items */ .hidden-list { display: flex; diff --git a/public/index.html b/public/index.html index afbaf13..a90da5c 100644 --- a/public/index.html +++ b/public/index.html @@ -421,20 +421,18 @@
-
+
-

Built-in EPG sources

-
-

Built-in sources provide free EPG data. You can disable them, but they can’t be removed.

-
-
- -
-
-

Custom EPG sources

+

EPG Sources

-
+

Active sources

+

Sources are checked from top to bottom.

+

Higher sources take priority; lower sources fill missing program data.

+
+
+

Inactive sources

+
@@ -1210,7 +1208,7 @@ - + @@ -1219,7 +1217,7 @@ - + diff --git a/public/js/api.js b/public/js/api.js index 585461e..5ef235f 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -59,6 +59,7 @@ const API = { toggle: (id) => API.request('POST', `/sources/${id}/toggle`), test: (id) => API.request('POST', `/sources/${id}/test`), sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync + updateEpgOrder: (orderedIds) => API.request('PUT', '/sources/epg/order', { orderedIds }), getStatus: () => API.request('GET', '/sources/status'), // Get all statuses estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation) diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index 29f236f..59cae82 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -9,6 +9,11 @@ class SourceManager { this.m3uList = document.getElementById('m3u-list'); this.epgList = document.getElementById('epg-list'); this.builtinEpgList = document.getElementById('builtin-epg-list'); + this.epgActiveList = document.getElementById('epg-active-list'); + this.epgInactiveList = document.getElementById('epg-inactive-list'); + this.epgOrderStatus = document.getElementById('epg-order-status'); + this.epgSources = []; + this.isSavingEpgOrder = false; // Content browser state this.contentType = 'channels'; // 'channels' or 'movies' @@ -116,8 +121,7 @@ class SourceManager { this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream'); this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u'); const epgSources = sources.filter(s => s.type === 'epg'); - this.renderBuiltinEpgSources(epgSources.filter(source => source.builtinKey)); - this.renderSourceList(this.epgList, epgSources.filter(source => !source.builtinKey), 'epg'); + this.renderEpgSources(epgSources); } catch (err) { console.error('Error loading sources:', err); } @@ -192,6 +196,143 @@ class SourceManager { }); } + escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + renderEpgSources(sources, focusTarget = null) { + if (!this.epgActiveList || !this.epgInactiveList) return; + this.epgSources = sources.slice(); + const active = this.epgSources.filter(source => source.enabled); + const inactive = this.epgSources.filter(source => !source.enabled); + + if (!active.length) { + this.epgActiveList.innerHTML = `
+

No active EPG sources

+

Enable a source to load program information.

+
`; + } else { + this.epgActiveList.innerHTML = active.map((source, index) => this.renderEpgSourceRow(source, { + active: true, + rank: index + 1, + canMoveUp: index > 0, + canMoveDown: index < active.length - 1 + })).join(''); + } + + this.epgInactiveList.innerHTML = inactive.length + ? inactive.map(source => this.renderEpgSourceRow(source, { active: false })).join('') + : '

No inactive EPG sources

'; + + this.attachEpgSourceListeners(); + if (focusTarget) { + const selector = `[data-id="${focusTarget.id}"] [data-action="${focusTarget.action}"]`; + const requested = this.epgActiveList.querySelector(selector) || this.epgInactiveList.querySelector(selector); + const row = requested?.closest('.epg-source-item'); + const focusable = requested && !requested.disabled + ? requested + : row?.querySelector('button:not(:disabled)'); + focusable?.focus(); + } + } + + renderEpgSourceRow(source, { active, rank = null, canMoveUp = false, canMoveDown = false }) { + const name = this.escapeHtml(source.name); + const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · '); + const kind = source.builtinKey ? 'Built-in' : 'Custom'; + return `
+ ${active ? `
+ ${rank}Priority ${rank} +
+ + +
+
` : ''} + ${Icons.series} +
+
${name} ${kind}
+
${source.builtinKey ? 'Free EPG data' : 'Custom EPG source'}${coverage ? ` · ${this.escapeHtml(coverage)}` : ''} · ${active ? 'Active' : 'Inactive'}
+
+
+ + + ${source.builtinKey ? '' : ` + `} +
+
`; + } + + attachEpgSourceListeners() { + [this.epgActiveList, this.epgInactiveList].forEach(container => { + container.querySelectorAll('.epg-source-item').forEach(item => { + const id = Number(item.dataset.id); + item.querySelector('[data-action="move-up"]')?.addEventListener('click', () => this.moveEpgSource(id, -1, 'move-up')); + item.querySelector('[data-action="move-down"]')?.addEventListener('click', () => this.moveEpgSource(id, 1, 'move-down')); + item.querySelector('[data-action="refresh"]')?.addEventListener('click', () => this.refreshSource(id, 'epg')); + item.querySelector('[data-action="toggle"]')?.addEventListener('click', () => this.toggleEpgSource(id)); + item.querySelector('[data-action="edit"]')?.addEventListener('click', () => this.showEditModal(id, 'epg')); + item.querySelector('[data-action="delete"]')?.addEventListener('click', () => this.deleteSource(id)); + }); + }); + } + + setEpgOrderStatus(message, state = '') { + if (!this.epgOrderStatus) return; + this.epgOrderStatus.textContent = message; + this.epgOrderStatus.dataset.state = state; + } + + async moveEpgSource(id, direction, action) { + if (this.isSavingEpgOrder) return; + const previous = this.epgSources.slice(); + const active = previous.filter(source => source.enabled); + const inactive = previous.filter(source => !source.enabled); + const index = active.findIndex(source => source.id === id); + const targetIndex = index + direction; + if (index < 0 || targetIndex < 0 || targetIndex >= active.length) return; + [active[index], active[targetIndex]] = [active[targetIndex], active[index]]; + const optimistic = [...active, ...inactive]; + this.isSavingEpgOrder = true; + this.setEpgOrderStatus('Saving priority…', 'saving'); + this.renderEpgSources(optimistic, { id, action }); + try { + const ordered = await API.sources.updateEpgOrder(optimistic.map(source => source.id)); + this.isSavingEpgOrder = false; + this.renderEpgSources(ordered, { id, action }); + this.setEpgOrderStatus('EPG priority saved', 'success'); + } catch (err) { + this.isSavingEpgOrder = false; + this.renderEpgSources(previous, { id, action }); + this.setEpgOrderStatus('EPG priority couldn’t be saved. Try again.', 'error'); + console.error('Error saving EPG source priority:', err); + } + } + + async toggleEpgSource(id) { + try { + await API.sources.toggle(id); + const allSources = await API.sources.getAll(); + const epgSources = allSources.filter(source => source.type === 'epg'); + const active = epgSources.filter(source => source.enabled && source.id !== id); + const toggled = epgSources.find(source => source.id === id); + if (toggled?.enabled) active.push(toggled); + const inactive = epgSources.filter(source => !source.enabled); + const completeOrder = [...active, ...inactive]; + const ordered = await API.sources.updateEpgOrder(completeOrder.map(source => source.id)); + this.renderEpgSources(ordered, { id, action: 'toggle' }); + this.setEpgOrderStatus('EPG priority saved', 'success'); + } catch (err) { + this.setEpgOrderStatus('EPG priority couldn’t be saved. Try again.', 'error'); + await this.loadSources(); + console.error('Error toggling EPG source:', err); + } + } + /** * Show add source modal */ diff --git a/test/t24Frontend.test.js b/test/t24Frontend.test.js new file mode 100644 index 0000000..53ee29b --- /dev/null +++ b/test/t24Frontend.test.js @@ -0,0 +1,120 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const root = path.resolve(__dirname, '..'); +const readScript = file => fs.readFileSync(path.join(root, 'public', file), 'utf8'); + +function createManager() { + const dom = new JSDOM(` +
+
+
+ `, { url: 'http://nodecast.test/', runScripts: 'outside-only' }); + dom.window.Icons = { + series: 'guide', refresh: 'refresh', settings: 'edit', close: 'delete' + }; + dom.window.console.error = () => {}; + dom.window.API = { sources: {} }; + dom.window.eval(`${readScript('js/components/SourceManager.js')}\nwindow.SourceManager = SourceManager;`); + const manager = Object.create(dom.window.SourceManager.prototype); + manager.epgActiveList = dom.window.document.getElementById('epg-active-list'); + manager.epgInactiveList = dom.window.document.getElementById('epg-inactive-list'); + manager.epgOrderStatus = dom.window.document.getElementById('epg-order-status'); + manager.isSavingEpgOrder = false; + manager.refreshSource = () => {}; + manager.toggleEpgSource = () => {}; + manager.showEditModal = () => {}; + manager.deleteSource = () => {}; + return { dom, manager }; +} + +const sources = () => [ + { id: 1, type: 'epg', name: 'Custom France', enabled: true, epgPriority: 0, language: 'fr', country: 'FR' }, + { id: 2, type: 'epg', name: 'EPG.PW US', enabled: true, epgPriority: 10, builtinKey: 'epgpw-us', language: 'en', country: 'US' }, + { id: 3, type: 'epg', name: 'Inactive custom', enabled: false, epgPriority: 20 } +]; + +test('EPG priority view renders ranked active sources and protected inactive/custom actions', () => { + const { manager } = createManager(); + manager.renderEpgSources(sources()); + + const activeRows = manager.epgActiveList.querySelectorAll('.epg-source-item'); + assert.equal(activeRows.length, 2); + assert.match(activeRows[0].textContent, /Priority 1/); + assert.match(activeRows[1].textContent, /Built-in/); + assert.equal(activeRows[1].querySelector('[data-action="edit"]'), null); + assert.equal(activeRows[1].querySelector('[data-action="delete"]'), null); + assert.equal(activeRows[0].querySelector('[data-action="move-up"]').disabled, true); + assert.equal(activeRows[0].querySelector('[data-action="move-down"]').getAttribute('aria-label'), 'Move Custom France down'); + + const inactive = manager.epgInactiveList.querySelector('.epg-source-item'); + assert.doesNotMatch(inactive.textContent, /Priority/); + assert.ok(inactive.querySelector('[data-action="edit"]')); + assert.ok(inactive.querySelector('[data-action="delete"]')); +}); + +test('moving an active EPG source saves a complete order including inactive sources', async () => { + const { dom, manager } = createManager(); + let orderedIds; + dom.window.API.sources.updateEpgOrder = async ids => { + orderedIds = [...ids]; + return [sources()[1], sources()[0], sources()[2]]; + }; + manager.renderEpgSources(sources()); + await manager.moveEpgSource(2, -1, 'move-up'); + + assert.deepEqual(orderedIds, [2, 1, 3]); + assert.match(manager.epgActiveList.querySelector('.epg-source-item').textContent, /EPG.PW US/); + assert.equal(manager.epgOrderStatus.textContent, 'EPG priority saved'); +}); + +test('failed EPG priority save rolls the visual order back and announces the error', async () => { + const { dom, manager } = createManager(); + dom.window.API.sources.updateEpgOrder = async () => { throw new Error('network'); }; + manager.renderEpgSources(sources()); + await manager.moveEpgSource(2, -1, 'move-up'); + + assert.match(manager.epgActiveList.querySelector('.epg-source-item').textContent, /Custom France/); + assert.equal(manager.epgOrderStatus.textContent, 'EPG priority couldn’t be saved. Try again.'); + assert.equal(manager.epgOrderStatus.dataset.state, 'error'); +}); + +test('enabling an EPG source appends it to the active priority list', async () => { + const { dom, manager } = createManager(); + let orderedIds; + dom.window.API.sources.toggle = async () => ({ enabled: true }); + dom.window.API.sources.getAll = async () => sources().map(source => + source.id === 3 ? { ...source, enabled: true } : source + ); + dom.window.API.sources.updateEpgOrder = async ids => { + orderedIds = [...ids]; + return sources().map(source => source.id === 3 ? { ...source, enabled: true } : source); + }; + await dom.window.SourceManager.prototype.toggleEpgSource.call(manager, 3); + assert.deepEqual(orderedIds, [1, 2, 3]); + assert.match(manager.epgActiveList.lastElementChild.textContent, /Inactive custom/); +}); + +test('empty active EPG state gives an actionable explanation', () => { + const { manager } = createManager(); + manager.renderEpgSources(sources().map(source => ({ ...source, enabled: false }))); + assert.match(manager.epgActiveList.textContent, /No active EPG sources/); + assert.match(manager.epgActiveList.textContent, /Enable a source to load program information/); +}); + +test('API client sends the complete EPG order to the dedicated endpoint', async () => { + const dom = new JSDOM('', { url: 'http://nodecast.test/', runScripts: 'outside-only' }); + let request; + dom.window.fetch = async (url, options) => { + request = { url, options }; + return { ok: true, headers: { get: () => 'application/json' }, json: async () => [] }; + }; + dom.window.eval(readScript('js/api.js')); + await dom.window.API.sources.updateEpgOrder([2, 1, 3]); + assert.equal(request.url, '/api/sources/epg/order'); + assert.equal(request.options.method, 'PUT'); + assert.deepEqual(JSON.parse(request.options.body), { orderedIds: [2, 1, 3] }); +});