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 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 11:58:50 +02:00
parent 9718155e75
commit afe91089e5
8 changed files with 254 additions and 8 deletions

View File

@ -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 };