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\)/); +});