From afe91089e572d865857eb6445682f7420fa41718 Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 6 Oct 2026 11:58:50 +0200 Subject: [PATCH 1/2] feat(epg): order EPG sources by priority (#24) Add persistent EPG priority ordering, source reorder validation, and priority-aware EPG matching for live channels. Co-Authored-By: Claude Opus 4.8 --- server/db.js | 20 +++++- server/routes/live.js | 4 +- server/routes/sources.js | 14 ++++ server/services/builtinEpgRegistry.js | 9 ++- server/services/epgMatchingService.js | 61 ++++++++++++++++ server/services/epgSourceOrdering.js | 61 ++++++++++++++++ server/services/liveProjectionService.js | 5 +- test/t24EpgSourcePriority.test.js | 88 ++++++++++++++++++++++++ 8 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 server/services/epgMatchingService.js create mode 100644 server/services/epgSourceOrdering.js create mode 100644 test/t24EpgSourcePriority.test.js diff --git a/server/db.js b/server/db.js index 8186e2a..35191ec 100644 --- a/server/db.js +++ b/server/db.js @@ -2,6 +2,7 @@ const fs = require('fs/promises'); const path = require('path'); const { existsSync, mkdirSync } = require('fs'); const { ensureBuiltinSources } = require('./services/builtinEpgRegistry'); +const { applyEpgOrder, nextEpgPriority, sortEpgSources } = require('./services/epgSourceOrdering'); // Ensure data directory exists (sync is fine for startup) const dataDir = path.join(__dirname, '..', 'data'); @@ -18,7 +19,8 @@ async function loadDb() { try { const fileContent = await fs.readFile(dbPath, 'utf-8'); const data = JSON.parse(fileContent); - return ensureBuiltinSources({ + const original = JSON.stringify(data); + const migrated = ensureBuiltinSources({ sources: data.sources || [], hiddenItems: data.hiddenItems || [], favorites: data.favorites || [], @@ -26,6 +28,8 @@ async function loadDb() { users: data.users || [], nextId: data.nextId || 1 }); + if (original !== JSON.stringify(migrated)) await saveDb(migrated); + return migrated; } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist, return default @@ -137,7 +141,8 @@ async function saveDb(data) { const sources = { async getAll() { const db = await loadDb(); - return db.sources; + const epg = sortEpgSources(db.sources); + return [...db.sources.filter(source => source.type !== 'epg'), ...epg]; }, async getById(id) { @@ -147,7 +152,8 @@ const sources = { async getByType(type) { const db = await loadDb(); - return db.sources.filter(s => s.type === type && s.enabled); + const matching = db.sources.filter(s => s.type === type && s.enabled); + return type === 'epg' ? sortEpgSources(matching) : matching; }, async create(source) { @@ -155,6 +161,7 @@ const sources = { const newSource = { id: db.nextId++, ...source, + ...(source.type === 'epg' ? { epgPriority: nextEpgPriority(db.sources) } : {}), enabled: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString() @@ -203,6 +210,13 @@ const sources = { await saveDb(db); } return source; + }, + + async reorderEpg(orderedIds) { + const db = await loadDb(); + const ordered = applyEpgOrder(db.sources, orderedIds); + await saveDb(db); + return ordered; } }; diff --git a/server/routes/live.js b/server/routes/live.js index 0f5774c..1fcf03a 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -3,6 +3,7 @@ const { requireAuth, requireAdmin } = require('../auth'); const liveProjection = require('../services/liveProjectionService'); const iptvOrgCatalog = require('../services/iptvOrgCatalogService'); const { getDb } = require('../db/sqlite'); +const { sources } = require('../db'); const router = express.Router(); @@ -44,7 +45,8 @@ router.get('/channels', requireAuth, async (req, res) => { if (state.status !== 'success' && state.status !== 'building') { state = await liveProjection.startRebuild().promise; } - const allChannels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id }); + const epgSources = await sources.getByType('epg'); + const allChannels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id, epgSources }); const availableLanguages = [...new Map(allChannels.map(channel => [channel.language.code, channel.language])).values()] .map(language => ({ code: language.code, short: language.short, label: language.label })) .sort((left, right) => left.label.localeCompare(right.label)); diff --git a/server/routes/sources.js b/server/routes/sources.js index a5a1549..726f877 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -47,6 +47,20 @@ router.get('/type/:type', async (req, res) => { } }); +router.put('/epg/order', async (req, res) => { + try { + const ordered = await sources.reorderEpg(req.body?.orderedIds); + res.json(ordered.map(source => ({ + ...source, + password: source.password ? '••••••••' : null + }))); + } catch (err) { + if (err.code) return res.status(400).json({ error: err.message, code: err.code }); + console.error('Error reordering EPG sources:', err); + res.status(500).json({ error: 'Failed to reorder EPG sources' }); + } +}); + // Get single source router.get('/:id', async (req, res) => { try { diff --git a/server/services/builtinEpgRegistry.js b/server/services/builtinEpgRegistry.js index e276e77..36817de 100644 --- a/server/services/builtinEpgRegistry.js +++ b/server/services/builtinEpgRegistry.js @@ -1,6 +1,6 @@ const BUILTIN_EPG_SOURCES = Object.freeze([ - Object.freeze({ builtinKey: 'epgpw-fr', type: 'epg', name: 'EPG.PW France', url: 'https://epg.pw/xmltv/epg_FR.xml.gz', provider: 'EPG.PW', language: 'fr', country: 'FR', priority: 100 }), - Object.freeze({ builtinKey: 'epgpw-us', type: 'epg', name: 'EPG.PW United States', url: 'https://epg.pw/xmltv/epg_US.xml.gz', provider: 'EPG.PW', language: 'en', country: 'US', priority: 90 }) + Object.freeze({ builtinKey: 'epgpw-fr', type: 'epg', name: 'EPG.PW France', url: 'https://epg.pw/xmltv/epg_FR.xml.gz', provider: 'EPG.PW', language: 'fr', country: 'FR' }), + Object.freeze({ builtinKey: 'epgpw-us', type: 'epg', name: 'EPG.PW United States', url: 'https://epg.pw/xmltv/epg_US.xml.gz', provider: 'EPG.PW', language: 'en', country: 'US' }) ]); function protectedFields(source) { @@ -9,11 +9,14 @@ function protectedFields(source) { function ensureBuiltinSources(database) { database.sources ||= []; + const { migrateEpgPriorities, nextEpgPriority } = require('./epgSourceOrdering'); + migrateEpgPriorities(database); for (const definition of BUILTIN_EPG_SOURCES) { const existing = database.sources.find(source => source.builtinKey === definition.builtinKey); if (existing) Object.assign(existing, definition, protectedFields(definition)); - else database.sources.push({ id: database.nextId++, ...definition, ...protectedFields(definition), enabled: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); + else database.sources.push({ id: database.nextId++, ...definition, epgPriority: nextEpgPriority(database.sources), ...protectedFields(definition), enabled: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); } + migrateEpgPriorities(database); return database; } diff --git a/server/services/epgMatchingService.js b/server/services/epgMatchingService.js new file mode 100644 index 0000000..c623b3c --- /dev/null +++ b/server/services/epgMatchingService.js @@ -0,0 +1,61 @@ +const { numericPriority, sortEpgSources } = require('./epgSourceOrdering'); + +function normalize(value) { + return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +function candidateScore(channel, epgChannel) { + const variantIds = new Set((channel.variants || []).flatMap(variant => [variant.tvgId, variant.epg_channel_id]).filter(Boolean).map(String)); + if (variantIds.has(String(epgChannel.id))) return 100; + const wantedNames = new Set([channel.standardName, channel.displayName, ...(channel.variants || []).map(variant => variant.name)].map(normalize).filter(Boolean)); + const candidateName = normalize(epgChannel.name); + if (wantedNames.has(candidateName)) return 90; + if (candidateName && [...wantedNames].some(name => name.includes(candidateName) || candidateName.includes(name))) return 50; + return 0; +} + +function chooseBestMatch(channel, candidates, epgSources) { + const language = channel.language?.code; + const eligible = sortEpgSources(epgSources, { enabledOnly: true }).filter(source => + !source.language || !language || language === 'und' || source.language === language + ); + const priorities = new Map(eligible.map(source => [source.id, numericPriority(source)])); + return candidates + .filter(candidate => priorities.has(candidate.sourceId)) + .map(candidate => ({ ...candidate, matchConfidence: candidateScore(channel, candidate) })) + .filter(candidate => candidate.matchConfidence > 0) + .sort((left, right) => right.matchConfidence - left.matchConfidence || + priorities.get(left.sourceId) - priorities.get(right.sourceId) || left.sourceId - right.sourceId)[0] || null; +} + +function findBestMatch(database, channel, epgSources, now = Date.now()) { + const language = channel.language?.code; + const enabled = sortEpgSources(epgSources, { enabledOnly: true }).filter(source => + !source.language || !language || language === 'und' || source.language === language + ); + if (!enabled.length) return null; + const ids = enabled.map(source => source.id); + const placeholders = ids.map(() => '?').join(','); + const epgChannels = database.prepare(` + SELECT source_id AS sourceId, item_id AS id, name + FROM playlist_items + WHERE type = 'epg_channel' AND source_id IN (${placeholders}) + `).all(...ids); + const best = chooseBestMatch(channel, epgChannels, enabled); + if (!best) return null; + const current = database.prepare(` + SELECT title, description, start_time AS startTime, end_time AS endTime + FROM epg_programs + WHERE source_id = ? AND channel_id = ? AND start_time <= ? AND end_time > ? + ORDER BY start_time DESC LIMIT 1 + `).get(best.sourceId, best.id, now, now); + return { + sourceId: best.sourceId, + channelId: best.id, + matchConfidence: best.matchConfidence, + currentProgram: current || undefined + }; +} + +module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize }; diff --git a/server/services/epgSourceOrdering.js b/server/services/epgSourceOrdering.js new file mode 100644 index 0000000..e5b7dd1 --- /dev/null +++ b/server/services/epgSourceOrdering.js @@ -0,0 +1,61 @@ +const PRIORITY_STEP = 10; + +function numericPriority(source) { + return Number.isInteger(source.epgPriority) && source.epgPriority >= 0 + ? source.epgPriority + : Number.MAX_SAFE_INTEGER; +} + +function sortEpgSources(list, { enabledOnly = false } = {}) { + return list + .filter(source => source.type === 'epg' && (!enabledOnly || source.enabled)) + .slice() + .sort((left, right) => numericPriority(left) - numericPriority(right) || left.id - right.id); +} + +function migrateEpgPriorities(database) { + const epgSources = database.sources.filter(source => source.type === 'epg'); + let next = epgSources.reduce((maximum, source) => Number.isInteger(source.epgPriority) + ? Math.max(maximum, source.epgPriority + PRIORITY_STEP) + : maximum, 0); + for (const source of epgSources) { + if (!Number.isInteger(source.epgPriority) || source.epgPriority < 0) { + source.epgPriority = next; + next += PRIORITY_STEP; + } + } + return database; +} + +function nextEpgPriority(sources) { + return sortEpgSources(sources).reduce((maximum, source) => Math.max(maximum, numericPriority(source) + PRIORITY_STEP), 0); +} + +function validateCompleteOrder(allSources, orderedIds) { + if (!Array.isArray(orderedIds)) return { code: 'INVALID_ORDER', message: 'orderedIds must be an array' }; + if (orderedIds.some(id => !Number.isInteger(id) || id <= 0)) return { code: 'INVALID_ORDER_ID', message: 'orderedIds must contain positive integer IDs' }; + if (new Set(orderedIds).size !== orderedIds.length) return { code: 'DUPLICATE_SOURCE_ID', message: 'orderedIds contains duplicate IDs' }; + const byId = new Map(allSources.map(source => [source.id, source])); + const unknown = orderedIds.find(id => !byId.has(id)); + if (unknown !== undefined) return { code: 'UNKNOWN_SOURCE_ID', message: `Unknown source ID: ${unknown}` }; + const nonEpg = orderedIds.find(id => byId.get(id).type !== 'epg'); + if (nonEpg !== undefined) return { code: 'SOURCE_NOT_EPG', message: `Source ${nonEpg} is not an EPG source` }; + const expected = allSources.filter(source => source.type === 'epg').map(source => source.id); + if (orderedIds.length !== expected.length || expected.some(id => !orderedIds.includes(id))) { + return { code: 'INCOMPLETE_EPG_ORDER', message: 'orderedIds must contain every EPG source' }; + } + return null; +} + +function applyEpgOrder(allSources, orderedIds, updatedAt = new Date().toISOString()) { + const error = validateCompleteOrder(allSources, orderedIds); + if (error) throw Object.assign(new Error(error.message), error); + orderedIds.forEach((id, index) => { + const source = allSources.find(item => item.id === id); + source.epgPriority = index * PRIORITY_STEP; + source.updated_at = updatedAt; + }); + return sortEpgSources(allSources); +} + +module.exports = { applyEpgOrder, migrateEpgPriorities, nextEpgPriority, numericPriority, PRIORITY_STEP, sortEpgSources, validateCompleteOrder }; diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index dcd80e1..58f0577 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -3,6 +3,7 @@ const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring'); const { detectLanguage } = require('./channelLanguage'); const { matchesSearch } = require('./flexibleChannelSearch'); const iptvOrgCatalog = require('./iptvOrgCatalogService'); +const epgMatching = require('./epgMatchingService'); const QUALITY_TOKENS = new Set([ '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', @@ -345,7 +346,7 @@ function parseData(value) { try { return value ? JSON.parse(value) : {}; } catch { return {}; } } -function getChannels({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [] }, database = null) { +function getChannels({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null) { database ||= defaultDatabase(); const params = []; let sourceFilter = ''; @@ -371,6 +372,8 @@ function getChannels({ sourceId = null, includeHidden = false, userId = null, q channel.standardName = match.name; channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence }; } + const epgMatch = epgMatching.findBestMatch(database, channel, epgSources); + if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code }; return channel; }).filter(channel => (!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) diff --git a/test/t24EpgSourcePriority.test.js b/test/t24EpgSourcePriority.test.js new file mode 100644 index 0000000..92f88a8 --- /dev/null +++ b/test/t24EpgSourcePriority.test.js @@ -0,0 +1,88 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const fs = require('fs'); +const path = require('path'); +const { + applyEpgOrder, + migrateEpgPriorities, + nextEpgPriority, + sortEpgSources, + validateCompleteOrder +} = require('../server/services/epgSourceOrdering'); +const { chooseBestMatch, findBestMatch } = require('../server/services/epgMatchingService'); +const { ensureBuiltinSources } = require('../server/services/builtinEpgRegistry'); + +const source = (id, type = 'epg', extra = {}) => ({ id, type, enabled: true, ...extra }); + +test('priority migration is idempotent and appends built-ins after existing custom EPG sources', () => { + const data = { sources: [source(1, 'epg', { name: 'Custom' })], nextId: 2 }; + ensureBuiltinSources(data); + const first = data.sources.map(item => [item.id, item.epgPriority]); + ensureBuiltinSources(data); + assert.deepEqual(data.sources.map(item => [item.id, item.epgPriority]), first); + assert.equal(data.sources[0].epgPriority, 0); + assert.ok(data.sources.filter(item => item.builtinKey).every(item => item.epgPriority > 0)); + assert.equal(nextEpgPriority(data.sources), 30); + migrateEpgPriorities(data); +}); + +test('complete EPG order normalizes priorities for enabled and disabled built-in/custom sources', () => { + const all = [source(1), source(2, 'm3u'), source(3, 'epg', { enabled: false, builtinKey: 'built-in' })]; + assert.equal(validateCompleteOrder(all, [3, 1]), null); + const ordered = applyEpgOrder(all, [3, 1], 'now'); + assert.deepEqual(ordered.map(item => [item.id, item.epgPriority]), [[3, 0], [1, 10]]); + assert.equal(all.find(item => item.id === 3).updated_at, 'now'); +}); + +test('EPG order validation rejects duplicates, unknown, non-EPG and incomplete lists', () => { + const all = [source(1), source(2), source(3, 'm3u')]; + assert.equal(validateCompleteOrder(all, [1, 1]).code, 'DUPLICATE_SOURCE_ID'); + assert.equal(validateCompleteOrder(all, [1, 9]).code, 'UNKNOWN_SOURCE_ID'); + assert.equal(validateCompleteOrder(all, [1, 3]).code, 'SOURCE_NOT_EPG'); + assert.equal(validateCompleteOrder(all, [1]).code, 'INCOMPLETE_EPG_ORDER'); +}); + +test('enabled EPG sources are sorted ascending and disabled sources are excluded from reads', () => { + const all = [source(1, 'epg', { epgPriority: 20 }), source(2, 'epg', { epgPriority: 0, enabled: false }), source(3, 'epg', { epgPriority: 10 })]; + assert.deepEqual(sortEpgSources(all).map(item => item.id), [2, 3, 1]); + assert.deepEqual(sortEpgSources(all, { enabledOnly: true }).map(item => item.id), [3, 1]); +}); + +test('match score wins before source priority and priority breaks equal scores', () => { + const channel = { displayName: 'News France', variants: [{ tvgId: 'news.fr', name: 'News France' }] }; + const sources = [source(1, 'epg', { epgPriority: 0 }), source(2, 'epg', { epgPriority: 10 })]; + const strongLater = chooseBestMatch(channel, [ + { sourceId: 1, id: 'other', name: 'News' }, + { sourceId: 2, id: 'news.fr', name: 'Different' } + ], sources); + assert.equal(strongLater.sourceId, 2); + const tied = chooseBestMatch(channel, [ + { sourceId: 2, id: 'news.fr', name: 'News France' }, + { sourceId: 1, id: 'news.fr', name: 'News France' } + ], sources); + assert.equal(tied.sourceId, 1); +}); + +test('EPG matching ignores disabled sources and returns the current programme from the selected source', () => { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE playlist_items (source_id INTEGER, item_id TEXT, type TEXT, name TEXT); + CREATE TABLE epg_programs (source_id INTEGER, channel_id TEXT, start_time INTEGER, end_time INTEGER, title TEXT, description TEXT); + INSERT INTO playlist_items VALUES (1, 'news.fr', 'epg_channel', 'News France'); + INSERT INTO playlist_items VALUES (2, 'news.fr', 'epg_channel', 'News France'); + INSERT INTO epg_programs VALUES (2, 'news.fr', 900, 1100, 'Current news', 'Headlines'); + `); + const match = findBestMatch(db, { displayName: 'News France', variants: [{ tvgId: 'news.fr' }] }, [ + source(1, 'epg', { epgPriority: 0, enabled: false }), + source(2, 'epg', { epgPriority: 10 }) + ], 1000); + assert.equal(match.sourceId, 2); + assert.equal(match.currentProgram.title, 'Current news'); +}); + +test('sources router exposes the complete-order endpoint', () => { + const text = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'sources.js'), 'utf8'); + assert.match(text, /router\.put\('\/epg\/order'/); + assert.match(text, /sources\.reorderEpg\(req\.body\?\.orderedIds\)/); +}); From 803dac37887d01b3ecbb389c601693b8b8912386 Mon Sep 17 00:00:00 2001 From: blomios Date: Tue, 6 Oct 2026 11:59:06 +0200 Subject: [PATCH 2/2] 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] }); +});