diff --git a/config/examples/app.example.yaml b/config/examples/app.example.yaml index 517d190..25179ca 100644 --- a/config/examples/app.example.yaml +++ b/config/examples/app.example.yaml @@ -16,6 +16,13 @@ # - Custom port: base_url: "https://iptv.example.com:8443" # - Subdirectory: base_url: "https://example.com/iptv" +# Optional: Trust forwarded client-IP, host, and protocol headers only from +# your reverse proxy. Leave empty for direct LAN access (the secure default). +# trusted_proxies: +# - "127.0.0.1" +# - "::1" +# - "192.168.1.10" + # ============================================================================ # Admin Authentication Configuration # ============================================================================ diff --git a/index.js b/index.js index ac443dd..f7df1a2 100644 --- a/index.js +++ b/index.js @@ -49,7 +49,15 @@ const appConfig = loadConfig('app'); const config = { ...appConfig, host: 'localhost' }; -app.set('trust proxy', true); +// Forwarded headers are trusted only when the direct peer is explicitly +// configured as a reverse proxy. A direct LAN client must not be able to +// forge X-Forwarded-For/Host/Proto to impersonate loopback or HTTPS. +app.set( + 'trust proxy', + Array.isArray(appConfig.trusted_proxies) && appConfig.trusted_proxies.length + ? appConfig.trusted_proxies + : false +); app.use(express.json({ limit: '1mb' })); // Session middleware — secret is read from config or generated and saved on first run diff --git a/libs/cache-manager.js b/libs/cache-manager.js index 217bb6c..4ee4805 100644 --- a/libs/cache-manager.js +++ b/libs/cache-manager.js @@ -14,8 +14,8 @@ class CacheManager { * @param {number} ttl - Time to live in milliseconds (0 = no expiration) * @returns {Cache} Cache instance */ - createCache(name, ttl = 0) { - const cache = new Cache(name, ttl); + createCache(name, ttl = 0, maxEntries = Infinity) { + const cache = new Cache(name, ttl, maxEntries); this.caches.set(name, cache); return cache; } @@ -52,9 +52,10 @@ class CacheManager { } class Cache { - constructor(name, ttl = 0) { + constructor(name, ttl = 0, maxEntries = Infinity) { this.name = name; this.ttl = ttl; // TTL in milliseconds + this.maxEntries = maxEntries; this.data = new Map(); this.timestamps = new Map(); this.hits = 0; @@ -67,8 +68,18 @@ class Cache { * @param {*} value - Value to cache */ set(key, value) { + // Map insertion order gives us a small, dependency-free LRU cache. Refresh + // an existing entry so frequently used variants are not evicted first. + if (this.data.has(key)) { + this.data.delete(key); + this.timestamps.delete(key); + } this.data.set(key, value); this.timestamps.set(key, Date.now()); + while (this.data.size > this.maxEntries) { + const oldestKey = this.data.keys().next().value; + this.delete(oldestKey); + } } /** @@ -96,7 +107,11 @@ class Cache { } this.hits++; - return this.data.get(key); + const value = this.data.get(key); + // Promote a hit to most recently used. + this.data.delete(key); + this.data.set(key, value); + return value; } /** @@ -170,6 +185,7 @@ class Cache { name: this.name, size, ttl: this.ttl, + maxEntries: this.maxEntries, hits: this.hits, misses: this.misses, hitRate: `${hitRate}%`, diff --git a/libs/canonical-channel-service.js b/libs/canonical-channel-service.js index 0641a11..63935fd 100644 --- a/libs/canonical-channel-service.js +++ b/libs/canonical-channel-service.js @@ -112,29 +112,26 @@ export function rebuildCanonicalChannels() { const mapping = loadChannelMapFromStore(); const reverseIndex = buildReverseIndex(mapping); const db = getDatabase(); + const existingCanonicalRows = db + .prepare( + `SELECT id, slug, name, custom_name, tvg_id, guide_number, published, created_at + FROM canonical_channels` + ) + .all(); + const existingCanonicalById = new Map(existingCanonicalRows.map(row => [row.id, row])); const existingCanonicalByIdentity = new Map( - db - .prepare( - `SELECT id, slug, name, custom_name, tvg_id, guide_number, published, created_at - FROM canonical_channels` - ) - .all() - .map(row => [ - getCanonicalIdentity({ - name: row.name, - tvg_id: row.tvg_id, - guideNumber: row.guide_number, - }), - row, - ]) - ); - const existingCanonicalIdentityById = new Map( - Array.from(existingCanonicalByIdentity.entries()).map(([identity, row]) => [row.id, identity]) + existingCanonicalRows.map(row => [ + getCanonicalIdentity({ + name: row.name, + tvg_id: row.tvg_id, + guideNumber: row.guide_number, + }), + row, + ]) ); - const existingBindingsByKey = new Map( - db - .prepare( - `SELECT + const existingBindingRows = db + .prepare( + `SELECT cb.id, cb.source_channel_id, cb.canonical_channel_id, @@ -144,10 +141,21 @@ export function rebuildCanonicalChannels() { cb.confidence, cb.resolution_state FROM channel_bindings cb` - ) - .all() - .map(row => [`${row.canonical_channel_id}|${row.source_channel_id}`, hydrateBindingRow(row)]) + ) + .all() + .map(hydrateBindingRow); + const existingBindingsByKey = new Map( + existingBindingRows.map(row => [`${row.canonical_channel_id}|${row.source_channel_id}`, row]) ); + const existingCanonicalIdsBySourceChannelId = new Map(); + for (const binding of existingBindingRows) { + if (!existingCanonicalIdsBySourceChannelId.has(binding.source_channel_id)) { + existingCanonicalIdsBySourceChannelId.set(binding.source_channel_id, []); + } + existingCanonicalIdsBySourceChannelId + .get(binding.source_channel_id) + .push(binding.canonical_channel_id); + } const existingGuideBindingsByKey = new Map( db .prepare( @@ -187,12 +195,7 @@ export function rebuildCanonicalChannels() { .map(row => row.id) ); - const canonicalByIdentity = new Map(); - const canonicalRows = []; - const bindingRows = []; - const guideBindingRows = []; - const seenGuideBindings = new Set(); - const usedSlugs = new Set(); + const canonicalGroupsByIdentity = new Map(); for (const sourceChannel of sourceChannels) { const hydrated = hydrateSourceChannel(sourceChannel); @@ -203,58 +206,110 @@ export function rebuildCanonicalChannels() { continue; } - let canonicalId = canonicalByIdentity.get(identity); - if (!canonicalId) { - const preserved = existingCanonicalByIdentity.get(identity); - canonicalId = preserved?.id || crypto.randomUUID(); - const now = new Date().toISOString(); - canonicalByIdentity.set(identity, canonicalId); - canonicalRows.push({ - id: canonicalId, - slug: allocateUniqueSlug( - preserved?.slug || slugify(canonicalChannel.tvg_id || canonicalChannel.name), - usedSlugs, - canonicalRows.length + 1 - ), - name: canonicalChannel.name, - custom_name: preserved?.custom_name || null, - tvg_id: canonicalChannel.tvg_id || null, - guide_number: canonicalChannel.guideNumber || null, - logo: canonicalChannel.logo || null, - group_name: canonicalChannel.group || null, - published: preserved?.published ?? 1, - created_at: preserved?.created_at || now, - updated_at: now, - }); + if (!canonicalGroupsByIdentity.has(identity)) { + canonicalGroupsByIdentity.set(identity, { identity, canonicalChannel, channels: [] }); } + canonicalGroupsByIdentity.get(identity).channels.push({ sourceChannel, hydrated, resolution }); + } + + // Source-channel IDs survive mapping-label and guide-number edits. Prefer an + // existing canonical ID associated with those stable IDs over the editable + // display identity. If mappings merge, the group with the largest overlap + // retains its ID (then identity order breaks ties); if they split, that same + // group retains the old settings and newly split groups start with defaults. + const canonicalGroups = Array.from(canonicalGroupsByIdentity.values()).map(group => { + const candidateCounts = new Map(); + for (const { sourceChannel } of group.channels) { + for (const canonicalId of existingCanonicalIdsBySourceChannelId.get(sourceChannel.id) || []) { + candidateCounts.set(canonicalId, (candidateCounts.get(canonicalId) || 0) + 1); + } + } + return { ...group, candidateCounts }; + }); + canonicalGroups.sort((left, right) => { + const leftMax = Math.max(0, ...left.candidateCounts.values()); + const rightMax = Math.max(0, ...right.candidateCounts.values()); + return rightMax - leftMax || left.identity.localeCompare(right.identity); + }); + + const claimedCanonicalIds = new Set(); + for (const group of canonicalGroups) { + const candidates = Array.from(group.candidateCounts.entries()) + .filter(([canonicalId]) => !claimedCanonicalIds.has(canonicalId)) + .sort(([leftId, leftCount], [rightId, rightCount]) => { + const left = existingCanonicalById.get(leftId); + const right = existingCanonicalById.get(rightId); + return ( + rightCount - leftCount || + String(left?.created_at || '').localeCompare(String(right?.created_at || '')) || + leftId.localeCompare(rightId) + ); + }); + const identityMatch = existingCanonicalByIdentity.get(group.identity); + group.canonicalId = + candidates[0]?.[0] || + (identityMatch && !claimedCanonicalIds.has(identityMatch.id) ? identityMatch.id : null) || + crypto.randomUUID(); + claimedCanonicalIds.add(group.canonicalId); + } - const existingBinding = existingBindingsByKey.get(`${canonicalId}|${sourceChannel.id}`); - bindingRows.push({ - id: existingBinding?.id || crypto.randomUUID(), - source_channel_id: sourceChannel.id, - canonical_channel_id: canonicalId, - binding_type: - existingBinding?.binding_type || (resolution.matched ? 'mapping' : 'source'), - priority: existingBinding?.priority || 0, - is_preferred_stream: existingBinding?.is_preferred_stream || 0, - confidence: existingBinding?.confidence ?? 1, - resolution_state: - existingBinding?.resolution_state || (resolution.matched ? 'resolved' : 'discovered'), + const canonicalRows = []; + const bindingRows = []; + const guideBindingRows = []; + const seenGuideBindings = new Set(); + const usedSlugs = new Set(); + + for (const group of canonicalGroups) { + const { canonicalChannel, canonicalId } = group; + const preserved = existingCanonicalById.get(canonicalId); + const now = new Date().toISOString(); + canonicalRows.push({ + id: canonicalId, + slug: allocateUniqueSlug( + preserved?.slug || slugify(canonicalChannel.tvg_id || canonicalChannel.name), + usedSlugs, + canonicalRows.length + 1 + ), + name: canonicalChannel.name, + custom_name: preserved?.custom_name || null, + tvg_id: canonicalChannel.tvg_id || null, + guide_number: canonicalChannel.guideNumber || null, + logo: canonicalChannel.logo || null, + group_name: canonicalChannel.group || null, + published: preserved?.published ?? 1, + created_at: preserved?.created_at || now, + updated_at: now, }); - const defaultGuideBindingId = pickGuideBindingId(canonicalChannel, hydrated); - if (epgEnabledSourceIds.has(sourceChannel.source_id) && defaultGuideBindingId) { - const guideKey = `${canonicalId}|${sourceChannel.source_id}`; - if (!seenGuideBindings.has(guideKey)) { - seenGuideBindings.add(guideKey); - const preservedGuideBinding = existingGuideBindingsByKey.get(guideKey); - guideBindingRows.push({ - id: preservedGuideBinding?.id || crypto.randomUUID(), - canonical_channel_id: canonicalId, - source_id: sourceChannel.source_id, - epg_channel_id: preservedGuideBinding?.epg_channel_id || defaultGuideBindingId, - priority: preservedGuideBinding?.priority ?? 1, - }); + for (const { sourceChannel, hydrated, resolution } of group.channels) { + const existingBinding = existingBindingsByKey.get(`${canonicalId}|${sourceChannel.id}`); + bindingRows.push({ + id: existingBinding?.id || crypto.randomUUID(), + source_channel_id: sourceChannel.id, + canonical_channel_id: canonicalId, + binding_type: + existingBinding?.binding_type || (resolution.matched ? 'mapping' : 'source'), + priority: existingBinding?.priority || 0, + is_preferred_stream: existingBinding?.is_preferred_stream || 0, + confidence: existingBinding?.confidence ?? 1, + resolution_state: + existingBinding?.resolution_state || (resolution.matched ? 'resolved' : 'discovered'), + }); + + const defaultGuideBindingId = pickGuideBindingId(canonicalChannel, hydrated); + if (epgEnabledSourceIds.has(sourceChannel.source_id) && defaultGuideBindingId) { + const guideKey = `${canonicalId}|${sourceChannel.source_id}`; + if (!seenGuideBindings.has(guideKey)) { + seenGuideBindings.add(guideKey); + const preservedGuideBinding = existingGuideBindingsByKey.get(guideKey); + guideBindingRows.push({ + id: preservedGuideBinding?.id || crypto.randomUUID(), + canonical_channel_id: canonicalId, + source_id: sourceChannel.source_id, + epg_channel_id: preservedGuideBinding?.epg_channel_id || defaultGuideBindingId, + priority: preservedGuideBinding?.priority ?? 1, + }); + } } } } @@ -352,17 +407,16 @@ export function rebuildCanonicalChannels() { ); } + const retainedCanonicalIds = new Set(canonicalRows.map(row => row.id)); for (const row of existingOutputProfileRows) { - const identity = existingCanonicalIdentityById.get(row.canonical_channel_id); - const nextCanonicalId = identity ? canonicalByIdentity.get(identity) : null; - if (!nextCanonicalId) { + if (!retainedCanonicalIds.has(row.canonical_channel_id)) { continue; } insertOutputProfileChannel.run( row.id, row.output_profile_id, - nextCanonicalId, + row.canonical_channel_id, row.position, row.guide_number_override, row.enabled diff --git a/libs/config-loader.js b/libs/config-loader.js index 6eee23f..1b222e4 100644 --- a/libs/config-loader.js +++ b/libs/config-loader.js @@ -57,6 +57,9 @@ const m3uSchema = Joi.object({ urls: Joi.array() .items( Joi.object({ + id: Joi.string().trim().min(1).optional().messages({ + 'string.empty': 'Provider "id" cannot be empty', + }), name: Joi.string().required().messages({ 'any.required': 'Each M3U source must have a "name"', 'string.empty': 'M3U source "name" cannot be empty', @@ -94,6 +97,9 @@ const providersSchema = Joi.object({ providers: Joi.array() .items( Joi.object({ + id: Joi.string().trim().min(1).optional().messages({ + 'string.empty': 'Provider "id" cannot be empty', + }), name: Joi.string().required().messages({ 'any.required': 'Each provider must have a "name"', 'string.empty': 'Provider "name" cannot be empty', @@ -108,6 +114,7 @@ const providersSchema = Joi.object({ epg: Joi.string().optional().allow(null, '').messages({ 'string.base': 'Provider "epg" must be a string URL', }), + enabled: Joi.boolean().optional(), }) ) .default([]), @@ -117,6 +124,13 @@ const appSchema = Joi.object({ base_url: Joi.string().uri({ allowRelative: false }).optional().allow(null, '').messages({ 'string.uri': 'app.yaml "base_url" must be a valid URL', }), + trusted_proxies: Joi.array() + .items(Joi.string().trim().max(255)) + .optional() + .messages({ + 'array.base': 'app.yaml "trusted_proxies" must be an array of proxy IPs or CIDR ranges', + 'string.base': 'Each trusted proxy must be a string', + }), oauth: Joi.object({ issuer: Joi.string().uri({ allowRelative: false }).optional().allow(null, '').messages({ 'string.uri': 'app.yaml "oauth.issuer" must be a valid URL', diff --git a/libs/getBaseUrl.js b/libs/getBaseUrl.js index a28d4ac..c0a4d80 100644 --- a/libs/getBaseUrl.js +++ b/libs/getBaseUrl.js @@ -13,18 +13,19 @@ function normalizeLoopbackHost(host) { * @returns {string} Base URL (e.g., "https://example.com") */ export default function getBaseUrl(req) { - const protocol = - req.get('X-Forwarded-Proto') || - req.get('X-Forwarded-Protocol') || - req.get('X-Url-Scheme') || - (req.get('X-Forwarded-Ssl') === 'on' ? 'https' : req.protocol); - - // Prefer proxy/forwarded headers, then fall back to Express hostname/headers - const host = - req.get('X-Forwarded-Host') || - req.get('host') || - req.hostname || - (req.headers && req.headers.host); + // Express resolves protocol and host from forwarded headers only when its + // configured trust-proxy function accepts the direct peer. Never inspect + // those headers ourselves for a live Express request. + const isExpressRequest = Boolean(req.app && typeof req.app.get === 'function'); + const protocol = isExpressRequest + ? req.protocol + : req.get('X-Forwarded-Proto') || + req.get('X-Forwarded-Protocol') || + req.get('X-Url-Scheme') || + (req.get('X-Forwarded-Ssl') === 'on' ? 'https' : req.protocol); + const host = isExpressRequest + ? req.host || req.get('host') + : req.get('X-Forwarded-Host') || req.get('host') || req.hostname || (req.headers && req.headers.host); // If we still cannot determine a host, return empty string so callers can handle it explicitly if (!host) { diff --git a/libs/output-profile-service.js b/libs/output-profile-service.js index 485d657..b0c837d 100644 --- a/libs/output-profile-service.js +++ b/libs/output-profile-service.js @@ -353,6 +353,7 @@ function hydrateOutputChannel(row) { logo: row.logo || raw.logo || '', group: row.group_name || raw.group || '', source: row.source_name || raw.source || '', + sourceChannelId: row.source_channel_id || null, streamName: row.source_channel_name || raw.name || row.canonical_name, original_url: row.stream_url || raw.original_url || '', position: row.position, @@ -381,6 +382,7 @@ export function getOutputProfileChannels(slug = DEFAULT_PROFILE_SLUG) { cc.logo, cc.group_name, cb.id AS binding_id, + cb.source_channel_id AS source_channel_id, cb.priority, cb.is_preferred_stream, sc.name AS source_channel_name, @@ -417,6 +419,38 @@ export function getOutputProfileChannels(slug = DEFAULT_PROFILE_SLUG) { return outputChannels.sort(compareOutputChannels); } +/** + * Return whether a profile has been initialized with editable channel entries. + * + * An empty result from getOutputProfileChannels() is ambiguous: it can mean a + * legacy installation has not yet built its output-profile model, or that an + * operator deliberately disabled every entry (or removed every effective guide + * number). Callers that publish a lineup need that distinction so they do not + * fall back to the legacy mapped-channel output after an intentional edit. + * + * @param {string} slug output profile slug + * @returns {boolean} true when the profile has one or more persisted entries + */ +export function hasOutputProfileEntries(slug = DEFAULT_PROFILE_SLUG) { + ensureDatabaseReady(); + + const profile = getProfileBySlug(slug); + if (!profile) { + return false; + } + + const entry = getDatabase() + .prepare( + `SELECT 1 + FROM output_profile_channels + WHERE output_profile_id = ? + LIMIT 1` + ) + .get(profile.id); + + return Boolean(entry); +} + export function updateOutputProfileEntries(slug = DEFAULT_PROFILE_SLUG, channels = []) { ensureDatabaseReady(); @@ -609,6 +643,7 @@ export default { deleteOutputProfile, getOutputProfile, getOutputProfileChannels, + hasOutputProfileEntries, listOutputProfileEntries, listOutputProfiles, syncAllOutputProfiles, diff --git a/libs/proxy-image.js b/libs/proxy-image.js index 3b0ebf6..7cfc5d9 100644 --- a/libs/proxy-image.js +++ b/libs/proxy-image.js @@ -22,11 +22,44 @@ export function imageProxyRoute(app) { return res.status(400).send('Invalid image URL'); } + const upstreamRequest = new AbortController(); + let upstreamBody; + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + upstreamRequest.abort(); + const upstreamSocket = upstreamBody?.socket; + if (upstreamBody && !upstreamBody.destroyed) upstreamBody.destroy(); + upstreamSocket?.destroy(); + }; + const abortForDisconnectedClient = () => { + if (!res.writableEnded) cleanup(); + }; + req.on('aborted', cleanup); + res.on('close', abortForDisconnectedClient); + res.on('error', cleanup); + try { - const response = await axios.get(parsedUrl.href, { responseType: 'stream' }); + const response = await axios.get(parsedUrl.href, { + responseType: 'stream', + signal: upstreamRequest.signal, + }); + upstreamBody = response.data; + if (cleanedUp) { + const upstreamSocket = upstreamBody.socket; + if (!upstreamBody.destroyed) upstreamBody.destroy(); + upstreamSocket?.destroy(); + return; + } res.set(response.headers); - response.data.pipe(res); + upstreamBody.on('error', err => { + if (!res.destroyed) res.destroy(err); + }); + upstreamBody.pipe(res); } catch (err) { + cleanup(); + if (res.destroyed || res.writableEnded) return; console.warn(`Failed to fetch image from ${decodedUrl}: ${err.message}`); // Return a more helpful error message if (err.response?.status === 404) { diff --git a/libs/source-channel-resolver.js b/libs/source-channel-resolver.js new file mode 100644 index 0000000..1e22d60 --- /dev/null +++ b/libs/source-channel-resolver.js @@ -0,0 +1,25 @@ +import { getDatabase } from './database.js'; + +/** Resolve a stable source-channel route target without relying on mutable names. */ +export function getSourceChannelById(id) { + const row = getDatabase() + .prepare( + `SELECT sc.name, sc.tvg_id, sc.stream_url, sc.raw_json, s.name AS source_name + FROM source_channels sc JOIN sources s ON s.id = sc.source_id WHERE sc.id = ?` + ) + .get(id); + if (!row) return null; + let raw = {}; + try { + raw = row.raw_json ? JSON.parse(row.raw_json) : {}; + } catch (_err) { + raw = {}; + } + return { + ...raw, + name: row.name, + source: row.source_name, + tvg_id: row.tvg_id || raw.tvg_id || '', + original_url: row.stream_url || raw.original_url || '', + }; +} diff --git a/libs/source-service.js b/libs/source-service.js index 7c8d9c0..62abf8c 100644 --- a/libs/source-service.js +++ b/libs/source-service.js @@ -32,12 +32,12 @@ function normalizeSourceRow(row) { }; } -function serializeSource(provider, timestamp) { +function serializeSource(provider, timestamp, existingSource = null) { const type = provider.type || 'm3u'; const sourceUrl = provider.url || ''; return { - id: provider.id || crypto.randomUUID(), + id: existingSource?.id || provider.id || crypto.randomUUID(), name: provider.name, type, base_url: type === 'hdhomerun' ? sourceUrl : null, @@ -46,7 +46,7 @@ function serializeSource(provider, timestamp) { auth_mode: null, headers_json: null, enabled: provider.enabled === false ? 0 : 1, - created_at: timestamp, + created_at: existingSource?.created_at || timestamp, updated_at: timestamp, }; } @@ -54,10 +54,12 @@ function serializeSource(provider, timestamp) { function toProvidersConfig(sources) { return { providers: sources.map(source => ({ + id: source.id, name: source.name, url: source.url, type: source.type || 'm3u', ...(source.epg ? { epg: source.epg } : {}), + ...(source.enabled === false ? { enabled: false } : {}), })), }; } @@ -71,12 +73,28 @@ function replaceSourcesInternal(providersConfig, { writeConfig = true } = {}) { const providers = Array.isArray(providersConfig?.providers) ? providersConfig.providers : []; const timestamp = new Date().toISOString(); - const rows = providers.map(provider => serializeSource(provider, timestamp)); - const applyChanges = transaction(sourceRows => { - getDatabase().prepare('DELETE FROM sources').run(); + const applyChanges = transaction(nextProviders => { + const db = getDatabase(); + const existingSources = db + .prepare( + `SELECT id, name, type, base_url, playlist_url, epg_url, auth_mode, headers_json, enabled, created_at + FROM sources` + ) + .all(); + const existingById = new Map(existingSources.map(source => [source.id, source])); + const existingByName = new Map(); + for (const source of existingSources) { + // A name is a legacy fallback only when it is unambiguous. Current + // provider exports include IDs, so renamed providers retain their IDs. + existingByName.set( + source.name, + existingByName.has(source.name) ? null : source + ); + } + const retainedIds = new Set(); - const insertSource = getDatabase().prepare(` + const insertSource = db.prepare(` INSERT INTO sources ( id, name, @@ -91,25 +109,69 @@ function replaceSourcesInternal(providersConfig, { writeConfig = true } = {}) { updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); + const updateSource = db.prepare(` + UPDATE sources + SET name = ?, + type = ?, + base_url = ?, + playlist_url = ?, + epg_url = ?, + enabled = ?, + updated_at = ? + WHERE id = ? + `); + const deleteSource = db.prepare('DELETE FROM sources WHERE id = ?'); + const sourceRows = []; + + for (const provider of nextProviders) { + const existing = + existingById.get(provider.id) || existingByName.get(provider.name) || null; + if (existing && retainedIds.has(existing.id)) { + throw new Error(`Provider ${provider.name} is specified more than once`); + } + + const row = serializeSource(provider, timestamp, existing); + sourceRows.push(row); + retainedIds.add(row.id); + + if (existing) { + updateSource.run( + row.name, + row.type, + row.base_url, + row.playlist_url, + row.epg_url, + row.enabled, + row.updated_at, + row.id + ); + } else { + insertSource.run( + row.id, + row.name, + row.type, + row.base_url, + row.playlist_url, + row.epg_url, + row.auth_mode, + row.headers_json, + row.enabled, + row.created_at, + row.updated_at + ); + } + } - for (const row of sourceRows) { - insertSource.run( - row.id, - row.name, - row.type, - row.base_url, - row.playlist_url, - row.epg_url, - row.auth_mode, - row.headers_json, - row.enabled, - row.created_at, - row.updated_at - ); + for (const source of existingSources) { + if (!retainedIds.has(source.id)) { + deleteSource.run(source.id); + } } + + return sourceRows; }); - applyChanges(rows); + const rows = applyChanges(providers); if (writeConfig) { writeProvidersConfigFile(toProvidersConfig(rows.map(normalizeSourceRow))); @@ -193,6 +255,7 @@ export function replaceM3UConfig(m3uConfig) { replaceProvidersConfig({ providers: urls.map(source => ({ name: source.name, + id: source.id, url: source.url, type: source.type || 'm3u', epg: existingSourcesByName.get(source.name)?.epg || '', @@ -220,6 +283,7 @@ export function replaceEPGConfig(epgConfig) { replaceProvidersConfig({ providers: sources.map(source => ({ name: source.name, + id: source.id, url: source.url, type: source.type || 'm3u', epg: epgBySourceName.get(source.name) || '', diff --git a/scripts/check-channel-health.js b/scripts/check-channel-health.js index 266cd39..017d62b 100644 --- a/scripts/check-channel-health.js +++ b/scripts/check-channel-health.js @@ -41,6 +41,7 @@ function isHealthyStatus(statusCode, contentType) { async function checkStream(url) { const started = Date.now(); + let stream; // First try HEAD (faster) then GET if needed try { const headResp = await axios.head(url, { @@ -78,7 +79,7 @@ async function checkStream(url) { const statusCode = getResp.status; const contentType = getResp.headers['content-type'] || ''; // Wait for first byte up to 4s or until 1KB read - const stream = getResp.data; + stream = getResp.data; let bytesRead = 0; let ttfb = null; await new Promise(resolve => { @@ -135,6 +136,11 @@ async function checkStream(url) { method: 'GET', }; } catch (err) { + // Axios may attach an error response body (for example, when a server + // returns an error after opening a streaming response). Release it too. + if (err.response?.data && typeof err.response.data.destroy === 'function') { + err.response.data.destroy(); + } return { healthy: false, statusCode: err.response?.status || 0, @@ -145,6 +151,13 @@ async function checkStream(url) { error: err.message || 'request failed', method: 'GET-ERR', }; + } finally { + // A probe only needs a small sample. Always close the GET body after the + // byte/time budget, including failures, so scheduled probes cannot retain + // live provider connections. + const streamSocket = stream?.socket; + if (stream && !stream.destroyed) stream.destroy(); + streamSocket?.destroy(); } } diff --git a/server/backup.js b/server/backup.js index a3b7e2e..d90d111 100644 --- a/server/backup.js +++ b/server/backup.js @@ -2,20 +2,21 @@ import express from 'express'; import fs from 'fs/promises'; import fsSync from 'fs'; import path from 'path'; +import crypto from 'crypto'; import archiver from 'archiver'; +import yaml from 'yaml'; import { getConfigPath, getDataPath } from '../libs/paths.js'; import { requireAuth, invalidateAuthCache } from './auth.js'; -import { closeDatabase, getDatabasePath, initDatabase } from '../libs/database.js'; -import { ensureAppConfigSeeded, loadAppConfigFromStore, replaceAppConfig } from '../libs/app-settings-service.js'; +import { closeDatabase, getDatabase, getDatabasePath, initDatabase } from '../libs/database.js'; +import { invalidateCache } from '../libs/channels-cache.js'; +import { invalidateLineupCaches } from './lineup.js'; +import { invalidateEPGCache } from './epg.js'; +import { ensureAppConfigSeeded } from '../libs/app-settings-service.js'; import { ensureSourcesSeeded, - loadProvidersConfigFromStore, - replaceProvidersConfig, } from '../libs/source-service.js'; import { ensureChannelMapSeeded, - loadChannelMapFromStore, - replaceChannelMap, } from '../libs/channel-map-service.js'; const router = express.Router(); @@ -31,12 +32,200 @@ const DATABASE_RUNTIME_FILES = [ const ADDITIONAL_RUNTIME_FILES = []; const RUNTIME_BACKUP_FILES = [...DATABASE_RUNTIME_FILES, ...ADDITIONAL_RUNTIME_FILES]; const BACKUPS_DIR = getDataPath('backups'); +let backupOperation = Promise.resolve(); + +function runBackupOperation(operation) { + const result = backupOperation.then(operation, operation); + backupOperation = result.catch(() => {}); + return result; +} /** Ensure the backups directory exists. */ async function ensureBackupsDir() { await fs.mkdir(BACKUPS_DIR, { recursive: true }); } +function getRuntimePath(file) { + return [...PRIMARY_CONFIG_FILES, ...LEGACY_COMPAT_CONFIG_FILES].includes(file) + ? getConfigPath(file) + : getDataPath(file); +} + +async function validateStagedRestore(stageDir, files, hasDatabaseSnapshot) { + for (const file of files) { + if ([...PRIMARY_CONFIG_FILES, ...LEGACY_COMPAT_CONFIG_FILES].includes(file)) { + yaml.parse(await fs.readFile(path.join(stageDir, file), 'utf8')); + } + } + + if (hasDatabaseSnapshot) { + // Import lazily so the normal backup/list endpoints do not open a second DB. + const { default: Database } = await import('better-sqlite3'); + const stagedDatabase = new Database(path.join(stageDir, DATABASE_FILENAME), { + readonly: true, + fileMustExist: true, + }); + try { + const integrity = stagedDatabase.pragma('quick_check', { simple: true }); + const foreignKeys = stagedDatabase.pragma('foreign_key_check'); + if (integrity !== 'ok' || foreignKeys.length > 0) { + throw new Error('Backup database failed integrity validation'); + } + } finally { + stagedDatabase.close(); + } + } +} + +function exportPrimaryConfigsFromDatabase() { + const db = getDatabase(); + const providers = db + .prepare( + `SELECT id, name, type, base_url, playlist_url, epg_url, enabled + FROM sources + ORDER BY created_at ASC, name ASC` + ) + .all() + .map(source => ({ + id: source.id, + name: source.name, + url: + source.type === 'hdhomerun' + ? source.base_url || source.playlist_url || '' + : source.playlist_url || source.base_url || '', + type: source.type || 'm3u', + ...(source.epg_url ? { epg: source.epg_url } : {}), + ...(source.enabled === 0 ? { enabled: false } : {}), + })); + const channelMap = db + .prepare( + `SELECT key, name, guide_number, tvg_id, logo, stream_url, group_name + FROM channel_mappings + ORDER BY rowid ASC` + ) + .all() + .reduce((map, row) => { + map[row.key] = { + ...(row.name ? { name: row.name } : {}), + ...(row.guide_number ? { number: row.guide_number } : {}), + ...(row.tvg_id ? { tvg_id: row.tvg_id } : {}), + ...(row.logo ? { logo: row.logo } : {}), + ...(row.stream_url ? { url: row.stream_url } : {}), + ...(row.group_name ? { group: row.group_name } : {}), + }; + return map; + }, {}); + const appRow = db + .prepare("SELECT value_json FROM app_settings WHERE key = 'app-config'") + .get(); + let appConfig = {}; + try { + appConfig = appRow?.value_json ? JSON.parse(appRow.value_json) : {}; + } catch { + appConfig = {}; + } + + return { providers: { providers }, channelMap, appConfig }; +} + +async function restoreBackupSnapshot(backupDir, filesInBackup, hasDatabaseSnapshot) { + const allowedFiles = [ + ...PRIMARY_CONFIG_FILES, + ...LEGACY_COMPAT_CONFIG_FILES, + ...RUNTIME_BACKUP_FILES, + ]; + const files = filesInBackup.filter(file => allowedFiles.includes(file)); + const stageDir = getDataPath(`.restore-stage-${crypto.randomUUID()}`); + const rollbackDir = getDataPath(`.restore-rollback-${crypto.randomUUID()}`); + const restored = []; + + await fs.mkdir(stageDir, { recursive: true }); + try { + for (const file of files) { + await fs.copyFile(path.join(backupDir, file), path.join(stageDir, file)); + } + await validateStagedRestore(stageDir, files, hasDatabaseSnapshot); + + const targetFiles = hasDatabaseSnapshot + ? [ + ...LEGACY_COMPAT_CONFIG_FILES.filter(file => files.includes(file)), + ...DATABASE_RUNTIME_FILES, + ] + : files.filter(file => [...PRIMARY_CONFIG_FILES, ...LEGACY_COMPAT_CONFIG_FILES].includes(file)); + + if (hasDatabaseSnapshot) { + closeDatabase(); + } + await fs.mkdir(rollbackDir, { recursive: true }); + + try { + for (const file of targetFiles) { + const destination = getRuntimePath(file); + if (fsSync.existsSync(destination)) { + await fs.copyFile(destination, path.join(rollbackDir, file)); + } + } + + for (const file of targetFiles) { + const staged = path.join(stageDir, file); + const destination = getRuntimePath(file); + if (fsSync.existsSync(staged)) { + await fs.copyFile(staged, destination); + restored.push(file); + } else if (hasDatabaseSnapshot && DATABASE_RUNTIME_FILES.includes(file)) { + await fs.rm(destination, { force: true }); + } + } + + if (hasDatabaseSnapshot) { + initDatabase(); + // The database is authoritative for these compatibility files. Export + // it directly instead of replaying a destructive configuration replace. + const configs = exportPrimaryConfigsFromDatabase(); + await fs.writeFile( + getConfigPath('providers.yaml'), + yaml.stringify(configs.providers), + 'utf8' + ); + await fs.writeFile(getConfigPath('channel-map.yaml'), yaml.stringify(configs.channelMap), 'utf8'); + await fs.writeFile(getConfigPath('app.yaml'), yaml.stringify(configs.appConfig), 'utf8'); + for (const file of PRIMARY_CONFIG_FILES) { + if (!restored.includes(file)) { + restored.push(file); + } + } + } + } catch (error) { + if (hasDatabaseSnapshot) { + closeDatabase(); + } + for (const file of targetFiles) { + const destination = getRuntimePath(file); + const rollback = path.join(rollbackDir, file); + if (fsSync.existsSync(rollback)) { + await fs.copyFile(rollback, destination); + } else { + await fs.rm(destination, { force: true }); + } + } + throw error; + } finally { + if (hasDatabaseSnapshot) { + initDatabase(); + } + } + + invalidateAuthCache(); + invalidateEPGCache(); + invalidateLineupCaches(); + await invalidateCache(); + return restored; + } finally { + await fs.rm(stageDir, { recursive: true, force: true }); + await fs.rm(rollbackDir, { recursive: true, force: true }); + } +} + /** * Resolve and validate a backup name to a safe absolute path within BACKUPS_DIR. * Returns the resolved path, or null if the name is invalid. @@ -74,20 +263,33 @@ router.get('/api/config/backups', requireAuth, async (req, res) => { * @returns {Promise<{name: string, files: string[]}>} */ export async function createBackupSnapshot() { - await ensureBackupsDir(); - ensureSourcesSeeded(); - ensureChannelMapSeeded(); - ensureAppConfigSeeded(); + return runBackupOperation(async () => { + await ensureBackupsDir(); + const db = initDatabase(); + // Do not reseed populated database state from potentially older YAML just + // before snapshotting it. Only bootstrap a genuinely empty installation. + if (db.prepare('SELECT COUNT(*) AS count FROM sources').get().count === 0) { + ensureSourcesSeeded(); + } + if (db.prepare('SELECT COUNT(*) AS count FROM channel_mappings').get().count === 0) { + ensureChannelMapSeeded(); + } + if (db.prepare('SELECT COUNT(*) AS count FROM app_settings').get().count === 0) { + ensureAppConfigSeeded(); + } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const backupName = `backup-${timestamp}`; - const backupDir = path.join(BACKUPS_DIR, backupName); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const backupName = `backup-${timestamp}-${crypto.randomInt(100000, 1000000)}`; + const backupDir = path.join(BACKUPS_DIR, backupName); - await fs.mkdir(backupDir, { recursive: true }); - closeDatabase(); + await fs.mkdir(backupDir, { recursive: true }); - try { const copied = []; + // SQLite's backup API creates a transactionally consistent snapshot without + // closing the shared connection or racing a concurrent writer. + await db.backup(path.join(backupDir, DATABASE_FILENAME)); + copied.push(DATABASE_FILENAME); + for (const file of [...PRIMARY_CONFIG_FILES, ...LEGACY_COMPAT_CONFIG_FILES]) { const src = getConfigPath(file); if (fsSync.existsSync(src)) { @@ -96,7 +298,7 @@ export async function createBackupSnapshot() { } } - for (const file of RUNTIME_BACKUP_FILES) { + for (const file of RUNTIME_BACKUP_FILES.filter(file => file !== DATABASE_FILENAME)) { const src = getDataPath(file); if (fsSync.existsSync(src)) { await fs.copyFile(src, path.join(backupDir, file)); @@ -105,9 +307,7 @@ export async function createBackupSnapshot() { } return { name: backupName, files: copied }; - } finally { - initDatabase(); - } + }); } /** @@ -151,53 +351,9 @@ router.post('/api/config/backups/:name/restore', requireAuth, async (req, res) = const filesInBackup = await fs.readdir(backupDir); const hasDatabaseSnapshot = filesInBackup.includes(DATABASE_FILENAME); - - if (hasDatabaseSnapshot) { - closeDatabase(); - - for (const file of [...PRIMARY_CONFIG_FILES, ...DATABASE_RUNTIME_FILES]) { - await fs.rm(file.endsWith('.yaml') ? getConfigPath(file) : getDataPath(file), { - force: true, - }); - } - } - - const restored = []; - for (const file of filesInBackup) { - if ( - hasDatabaseSnapshot && - PRIMARY_CONFIG_FILES.includes(file) && - !LEGACY_COMPAT_CONFIG_FILES.includes(file) - ) { - continue; - } - - if (![...PRIMARY_CONFIG_FILES, ...LEGACY_COMPAT_CONFIG_FILES, ...RUNTIME_BACKUP_FILES].includes(file)) { - continue; - } - - const src = path.join(backupDir, file); - if (fsSync.existsSync(src)) { - const dest = [...PRIMARY_CONFIG_FILES, ...LEGACY_COMPAT_CONFIG_FILES].includes(file) - ? getConfigPath(file) - : getDataPath(file); - await fs.copyFile(src, dest); - restored.push(file); - } - } - - if (hasDatabaseSnapshot) { - initDatabase(); - replaceProvidersConfig(loadProvidersConfigFromStore()); - replaceChannelMap(loadChannelMapFromStore()); - replaceAppConfig(loadAppConfigFromStore()); - restored.push(...PRIMARY_CONFIG_FILES.filter(file => !restored.includes(file))); - } - - // If app config was restored, the in-process auth config cache may now be stale. - if (restored.includes('app.yaml') || hasDatabaseSnapshot) { - invalidateAuthCache(); - } + const restored = await runBackupOperation(() => + restoreBackupSnapshot(backupDir, filesInBackup, hasDatabaseSnapshot) + ); res.json({ status: 'restored', name, files: restored }); } catch (e) { diff --git a/server/epg.js b/server/epg.js index cd67ca4..5b4b73c 100644 --- a/server/epg.js +++ b/server/epg.js @@ -13,11 +13,13 @@ import { listSources } from '../libs/source-service.js'; import { getOutputProfile, getOutputProfileChannels, + hasOutputProfileEntries, listOutputProfiles, } from '../libs/output-profile-service.js'; import { listGuideBindings } from '../libs/canonical-channel-service.js'; import { getProxiedImageUrl } from '../libs/proxy-image.js'; +import getBaseUrl from '../libs/getBaseUrl.js'; // Rate limiter for the public XMLTV endpoint const epgLimiter = RateLimit({ @@ -59,6 +61,10 @@ let lastEPGSourceResults = []; // Maximum number of programmes returned per guide query (matches /api/guide behaviour) const MAX_GUIDE_PROGRAMMES = 20; +// Rewritten XMLTV responses can be several megabytes. Cache only a bounded +// number of request variants so arbitrary filter combinations cannot retain +// unbounded copies of the guide in memory. +const MAX_EPG_CACHE_ENTRIES = 32; /** * Extract a plain string from an XMLTV text field which may be a raw string, @@ -109,16 +115,13 @@ export function parseXMLTVDate(str) { } function rewriteImageUrls(xmlString, req) { - const protocol = - req.get('X-Forwarded-Proto') || - req.get('X-Forwarded-Protocol') || - req.get('X-Url-Scheme') || - (req.get('X-Forwarded-Ssl') === 'on' ? 'https' : req.protocol); + const requestBaseUrl = getBaseUrl(req); - // Check cache - include filter params in cache key + // Check cache. A profile affects both selected records and display names. const filterSource = req.query.source || ''; const filterChannels = req.query.channels || ''; - const cacheKey = `${protocol}://${req.get('host')}|source:${filterSource}|channels:${filterChannels}`; + const profileSlug = req.params.slug ? String(req.params.slug).trim() : 'default'; + const cacheKey = `${requestBaseUrl}|profile:${profileSlug}|source:${filterSource}|channels:${filterChannels}`; if (epgCache && epgCache.has(cacheKey)) { return epgCache.get(cacheKey); } @@ -205,6 +208,13 @@ export function _resetMergedEPGForTesting() { lastEPGSourceResults = []; } +/** Clear derived guide data after a state restore. */ +export function invalidateEPGCache() { + mergedEPG = null; + epgCache = null; + lastEPGSourceResults = []; +} + function loadOutputChannels(slug = '') { if (slug) { const profile = getOutputProfile(slug); @@ -216,7 +226,7 @@ function loadOutputChannels(slug = '') { } const outputChannels = getOutputProfileChannels(); - return outputChannels.length > 0 ? outputChannels : getChannels(); + return outputChannels.length > 0 || hasOutputProfileEntries() ? outputChannels : getChannels(); } function loadChannelsForGuideMerge() { @@ -244,6 +254,52 @@ function cloneNode(node) { return JSON.parse(JSON.stringify(node)); } +/** + * Deduplicate the merged XMLTV records deterministically. + * + * EPG sources are processed in the stable order returned by listSources() + * (created-at, then source name), and records retain their order within each + * source. The first channel for an id and the first programme for + * (channel, start, title) win. Keeping the entire first node preserves + * language-tagged title fields and any provider-specific metadata. + * + * Records missing one of the programme identity fields are preserved rather + * than collapsed, because they cannot be safely identified as duplicates. + * + * @param {{tv?: {channel?: Array, programme?: Array}}} guide + * @returns {{tv: {channel: Array, programme: Array}}} + */ +export function deduplicateXmltvRecords(guide) { + const channels = []; + const programmes = []; + const channelIds = new Set(); + const programmeKeys = new Set(); + + for (const channel of [].concat(guide?.tv?.channel || [])) { + if (!channel) continue; + const channelId = String(channel?.['@_id'] || '').trim(); + if (!channelId || !channelIds.has(channelId)) { + channels.push(channel); + if (channelId) channelIds.add(channelId); + } + } + + for (const programme of [].concat(guide?.tv?.programme || [])) { + if (!programme) continue; + const channelId = String(programme?.['@_channel'] || '').trim(); + const start = String(programme?.['@_start'] || '').trim(); + const title = extractTextField(programme?.title); + const key = channelId && start && title ? `${channelId}\u0000${start}\u0000${title}` : ''; + + if (!key || !programmeKeys.has(key)) { + programmes.push(programme); + if (key) programmeKeys.add(key); + } + } + + return { tv: { channel: channels, programme: programmes } }; +} + function getDisplayNameValues(channel) { return [] .concat(channel?.['display-name'] || []) @@ -338,7 +394,7 @@ export async function setupEPGRoutes(app) { // Initialize EPG cache with TTL from config (default: 6 hours) const epgTTL = (appConfig.cache?.epg_ttl ?? 21600) * 1000; // Convert seconds to milliseconds - epgCache = cacheManager.createCache('epg', epgTTL); + epgCache = cacheManager.createCache('epg', epgTTL, MAX_EPG_CACHE_ENTRIES); console.log(`EPG cache initialized with TTL: ${epgTTL / 1000}s`); async function fetchAndMergeEPGs() { @@ -354,9 +410,15 @@ export async function setupEPGRoutes(app) { const sourceName = source.name; const sourceUrl = source.url; const sourceBindingRules = guideBindingsBySource.get(sourceName) || []; - const bindingRuleByInputId = new Map( - sourceBindingRules.map(rule => [rule.inputChannelId, rule]) - ); + // An input guide channel can intentionally drive more than one + // canonical output. Keep every selected rule so both IDs are emitted. + const bindingRulesByInputId = new Map(); + for (const rule of sourceBindingRules) { + if (!bindingRulesByInputId.has(rule.inputChannelId)) { + bindingRulesByInputId.set(rule.inputChannelId, []); + } + bindingRulesByInputId.get(rule.inputChannelId).push(rule); + } const sourceChannels = allChannels.filter(c => c.source === sourceName); const fallbackChannels = sourceChannels.filter( channel => !channel.canonicalId || !selectedCanonicalIds.has(channel.canonicalId) @@ -420,16 +482,18 @@ export async function setupEPGRoutes(app) { return []; } - const selectedRule = bindingRuleByInputId.get(channel['@_id']); - if (selectedRule) { - const rewrittenChannel = cloneNode(channel); - rewrittenChannel['@_id'] = selectedRule.outputChannelId; - if (!getDisplayNameValues(rewrittenChannel).includes(selectedRule.outputName)) { - rewrittenChannel['display-name'] = [selectedRule.outputName].concat( - [].concat(rewrittenChannel['display-name'] || []) - ); - } - return [rewrittenChannel]; + const selectedRules = bindingRulesByInputId.get(channel['@_id']); + if (selectedRules) { + return selectedRules.map(selectedRule => { + const rewrittenChannel = cloneNode(channel); + rewrittenChannel['@_id'] = selectedRule.outputChannelId; + if (!getDisplayNameValues(rewrittenChannel).includes(selectedRule.outputName)) { + rewrittenChannel['display-name'] = [selectedRule.outputName].concat( + [].concat(rewrittenChannel['display-name'] || []) + ); + } + return rewrittenChannel; + }); } const displayNames = getDisplayNameValues(channel); @@ -463,11 +527,13 @@ export async function setupEPGRoutes(app) { return []; } - const selectedRule = bindingRuleByInputId.get(programme['@_channel']); - if (selectedRule) { - const rewrittenProgramme = cloneNode(programme); - rewrittenProgramme['@_channel'] = selectedRule.outputChannelId; - return [rewrittenProgramme]; + const selectedRules = bindingRulesByInputId.get(programme['@_channel']); + if (selectedRules) { + return selectedRules.map(selectedRule => { + const rewrittenProgramme = cloneNode(programme); + rewrittenProgramme['@_channel'] = selectedRule.outputChannelId; + return rewrittenProgramme; + }); } if ( @@ -555,6 +621,10 @@ export async function setupEPGRoutes(app) { } } + const deduplicated = deduplicateXmltvRecords(merged); + merged.tv.channel = deduplicated.tv.channel; + merged.tv.programme = deduplicated.tv.programme; + lastEPGSourceResults = sourceResults; try { @@ -597,40 +667,32 @@ export async function setupEPGRoutes(app) { let xmlToSend = mergedEPG; - if ((profileSlug || filterSource || filterChannels) && typeof mergedEPG === 'string' && mergedEPG.trim()) { + if (typeof mergedEPG === 'string' && mergedEPG.trim()) { try { const parsed = parser.parse(mergedEPG); if (!parsed || !parsed.tv) { throw new Error('Invalid EPG structure'); } - const allChannels = displayNameChannels; - const allowedTvgIds = new Set(); - - if (profileSlug) { - allChannels.forEach(channel => { - if (channel?.tvg_id) { - allowedTvgIds.add(channel.tvg_id); - } - }); - } - + // The unscoped route is the default profile, rather than the union + // used while merging sources. Apply membership first, then intersect + // all requested filters so they cannot expand profile visibility. + let allowedChannels = displayNameChannels; if (filterSource) { - allowedTvgIds.clear(); - displayNameChannels = allChannels.filter(c => c && c.source === filterSource); - displayNameChannels.forEach(c => { - if (c.tvg_id) { - allowedTvgIds.add(c.tvg_id); - } - }); - } else if (filterChannels) { - allowedTvgIds.clear(); + allowedChannels = allowedChannels.filter(c => c && c.source === filterSource); + } + if (filterChannels) { const allowedChannelIds = new Set(filterChannels.map(id => id.trim())); - filterChannels.forEach(id => allowedTvgIds.add(id.trim())); - displayNameChannels = allChannels.filter(c => c?.tvg_id && allowedChannelIds.has(c.tvg_id)); + allowedChannels = allowedChannels.filter( + channel => channel?.tvg_id && allowedChannelIds.has(channel.tvg_id) + ); } + displayNameChannels = allowedChannels; + const allowedTvgIds = new Set( + allowedChannels.map(channel => channel?.tvg_id).filter(Boolean) + ); - if (allowedTvgIds.size > 0 && parsed.tv) { + if (parsed.tv) { const tv = parsed.tv; tv.channel = [].concat(tv.channel || []).filter(c => c && allowedTvgIds.has(c['@_id'])); tv.programme = [] diff --git a/server/lineup.js b/server/lineup.js index 4e154c4..929ceb9 100644 --- a/server/lineup.js +++ b/server/lineup.js @@ -7,7 +7,12 @@ import { getChannels } from '../libs/channels-cache.js'; import { asyncHandler, AppError } from './error-handler.js'; import cacheManager from '../libs/cache-manager.js'; import { loadChannelMapFromStore } from '../libs/channel-map-service.js'; -import { getOutputProfile, getOutputProfileChannels } from '../libs/output-profile-service.js'; +import { getSourceChannelById } from '../libs/source-channel-resolver.js'; +import { + getOutputProfile, + getOutputProfileChannels, + hasOutputProfileEntries, +} from '../libs/output-profile-service.js'; // Rate limiter for public playlist endpoints const lineupLimiter = RateLimit({ @@ -101,6 +106,13 @@ function rewriteUriToProxy(uri, playlistUrl, req, source, name) { return `${baseUrl}/stream/${sourcePart}/${namePart}?upstream=${encodeURIComponent(resolvedUrl)}`; } +function getStreamUrl(baseUrl, channel) { + if (channel?.sourceChannelId) { + return `${baseUrl}/stream/channel/${encodeURIComponent(channel.sourceChannelId)}`; + } + return `${baseUrl}/stream/${encodeURIComponent(channel.source || 'unknown')}/${encodeURIComponent(channel.streamName || channel.name)}`; +} + function rewriteHlsPlaylist(body, playlistUrl, req, source, name) { const lines = String(body || '').split('\n'); const rewritten = lines.map(line => { @@ -166,7 +178,12 @@ function loadPublishedChannels(includeUnmapped) { } const outputChannels = getOutputProfileChannels(); - if (outputChannels.length > 0) { + // Once the default profile has entries, it is the source of truth even when + // none are currently publishable. In particular, falling back here would + // republish mapped source channels after an operator disables every entry or + // clears every effective guide number. Keep the legacy mapping fallback + // only for installations where no profile entries have been initialized. + if (outputChannels.length > 0 || hasOutputProfileEntries()) { return outputChannels; } @@ -261,7 +278,7 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { .map(channel => ({ GuideNumber: resolveGuideNumberForLineup(channel), GuideName: channel.name, - URL: `${baseUrl}/stream/${encodeURIComponent(channel.source || 'unknown')}/${encodeURIComponent(channel.streamName || channel.name)}`, + URL: getStreamUrl(baseUrl, channel), })); jsonCache.set(cacheKey, lineup); @@ -327,7 +344,7 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { : ''; const tvgChno = resolveGuideNumberForM3U(channel); const groupTitle = channel.source || ''; - const streamUrl = `${baseUrl}/stream/${encodeURIComponent(channel.source)}/${encodeURIComponent(channel.streamName || channel.name)}`; + const streamUrl = getStreamUrl(baseUrl, channel); output += `#EXTINF:-1 tvg-id="${tvgId}" tvg-name="${tvgName}" tvg-logo="${tvgLogo}" group-title="${groupTitle}"`; if (tvgChno) { @@ -349,11 +366,18 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { app.get('/profiles/:slug/lineup.m3u', lineupLimiter, handleLineupM3u); app.all('/stream/:source/:name', async (req, res) => { - const { source, name } = req.params; + let { source, name } = req.params; const upstreamOverride = req.query.upstream ? String(req.query.upstream) : null; const channels = loadChannels(); - const channel = channels.find(c => c.source === source && c.name === name); + let channel = channels.find(c => c.source === source && c.name === name); + if (source === 'channel') { + channel = getSourceChannelById(name); + if (channel) { + source = channel.source; + name = channel.name; + } + } if (!channel) return res.status(404).send('Channel not found'); @@ -444,6 +468,32 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { const userAgent = req.headers['user-agent'] || ''; let usageKey; let usageInterval; + let upstreamBody; + let cleanedUp = false; + const upstreamRequest = new AbortController(); + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + upstreamRequest.abort(); + // axios does not automatically tear down a fulfilled response body when the + // downstream client disappears. Destroy it so a paused live stream does not + // continue to occupy an upstream tuner/provider connection. + const upstreamSocket = upstreamBody?.socket; + if (upstreamBody && !upstreamBody.destroyed) upstreamBody.destroy(); + // Explicitly tear down the response socket too. Some HTTP agents retain + // a socket after destroying only the readable response wrapper. + upstreamSocket?.destroy(); + if (usageInterval) clearInterval(usageInterval); + usageInterval = null; + if (usageKey) unregisterUsage(usageKey); + usageKey = null; + }; + const abortForDisconnectedClient = () => { + if (!res.writableEnded) cleanup(); + }; + req.on('aborted', cleanup); + res.on('close', abortForDisconnectedClient); + res.on('error', cleanup); const touchViewer = async () => { usageKey = await registerUsage({ ip: String(ip), channelId: String(channelId), userAgent }); touchUsage(usageKey); @@ -452,18 +502,17 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { if (usageKey) return; usageKey = await registerUsage({ ip: String(ip), channelId: String(channelId), userAgent }); usageInterval = setInterval(() => touchUsage(usageKey), 10000); - const cleanup = () => { - if (usageInterval) clearInterval(usageInterval); - usageInterval = null; - if (usageKey) unregisterUsage(usageKey); - usageKey = null; - }; - res.on('close', cleanup); res.on('finish', cleanup); - res.on('error', cleanup); }; const handleStreamResponse = async (response, resolvedUrl, isUpstreamOverride) => { + upstreamBody = response.data; + if (cleanedUp) { + const upstreamSocket = upstreamBody.socket; + if (!upstreamBody.destroyed) upstreamBody.destroy(); + upstreamSocket?.destroy(); + return; + } const responseUrl = response.request?.res?.responseUrl || resolvedUrl; const contentType = response.headers?.['content-type'] || ''; // Determine whether the upstream response is an HLS playlist: @@ -533,7 +582,7 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { response.data.on('error', err => { console.warn('[stream] upstream error %s/%s: %s', source, name, err.message); - res.destroy(err); + if (!res.destroyed) res.destroy(err); }); res.set(response.headers); @@ -548,14 +597,15 @@ export function setupLineupRoutes(app, config, usageHelpers = {}) { const axiosOptions = { responseType: 'stream', timeout: 15000, + signal: upstreamRequest.signal, ...(upstreamOverride ? { maxRedirects: 0 } : {}), }; const response = await axios.get(upstreamUrl, axiosOptions); await handleStreamResponse(response, upstreamUrl, !!upstreamOverride); } catch (err) { - if (usageKey && !upstreamOverride) unregisterUsage(usageKey); - if (usageInterval) clearInterval(usageInterval); + cleanup(); + if (res.destroyed || res.writableEnded) return; console.warn('[stream] failed %s/%s: %s', source, name, err.message, { status: err.response?.status, code: err.code, diff --git a/server/mcp.js b/server/mcp.js index 196e70e..f44c0ed 100644 --- a/server/mcp.js +++ b/server/mcp.js @@ -514,9 +514,11 @@ function createMcpServer() { {}, async () => { const providers = listSources().map(source => ({ + id: source.id, name: source.name, type: source.type || 'm3u', hasEpg: Boolean(source.epg), + enabled: source.enabled, })); return createSuccess('list_providers', providers, { diff --git a/server/oauth.js b/server/oauth.js index 9c45693..13c8a6c 100644 --- a/server/oauth.js +++ b/server/oauth.js @@ -11,6 +11,7 @@ import { revokeAccessToken, } from '../libs/oauth-service.js'; import { loadAppConfigFromStore } from '../libs/app-settings-service.js'; +import resolveRequestBaseUrl from '../libs/getBaseUrl.js'; const router = express.Router(); @@ -35,7 +36,7 @@ function getBaseUrl(req) { return appConfig.base_url.trim().replace(/\/+$/, ''); } - return `${req.protocol}://${req.get('host')}`.replace(/\/+$/, ''); + return resolveRequestBaseUrl(req).replace(/\/+$/, ''); } function getMetadata(req) { diff --git a/server/stream-probe.js b/server/stream-probe.js index fda89e6..6362fd0 100644 --- a/server/stream-probe.js +++ b/server/stream-probe.js @@ -1,5 +1,6 @@ import axios from 'axios'; import { getChannels } from '../libs/channels-cache.js'; +import { getSourceChannelById } from '../libs/source-channel-resolver.js'; import { requireAuth } from './auth.js'; // Read at most 64 KB — enough to reliably find PAT and PMT in any MPEG-TS stream. @@ -363,9 +364,13 @@ async function readStreamBytes(stream, maxBytes) { */ export function setupStreamProbeRoutes(app) { app.get('/api/stream-probe/:source/:name', requireAuth, async (req, res) => { - const { source, name } = req.params; + let { source, name } = req.params; const channels = getChannels(); - const channel = channels.find(c => c.source === source && c.name === name); + const channel = source === 'channel' ? getSourceChannelById(name) : channels.find(c => c.source === source && c.name === name); + if (channel) { + source = channel.source; + name = channel.name; + } if (!channel) return res.status(404).json({ error: 'Channel not found' }); diff --git a/server/transcode.js b/server/transcode.js index ef06340..376edd3 100644 --- a/server/transcode.js +++ b/server/transcode.js @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import RateLimit from 'express-rate-limit'; import { getChannels } from '../libs/channels-cache.js'; +import { getSourceChannelById } from '../libs/source-channel-resolver.js'; // Rate limiter for the transcoding endpoint — each ffmpeg process consumes significant // CPU and network resources, so keep the per-IP limit stricter than other endpoints. @@ -46,9 +47,13 @@ function ffmpegCommand() { */ export function setupTranscodeRoutes(app) { app.get('/transcode/:source/:name', transcodeLimiter, (req, res) => { - const { source, name } = req.params; + let { source, name } = req.params; const channels = getChannels(); - const channel = channels.find(c => c.source === source && c.name === name); + const channel = source === 'channel' ? getSourceChannelById(name) : channels.find(c => c.source === source && c.name === name); + if (channel) { + source = channel.source; + name = channel.name; + } if (!channel) { return res.status(404).send('Channel not found'); diff --git a/test/integration/canonical-routes.test.js b/test/integration/canonical-routes.test.js index 9d0565e..0a727df 100644 --- a/test/integration/canonical-routes.test.js +++ b/test/integration/canonical-routes.test.js @@ -399,4 +399,114 @@ describe('canonical model routes', () => { expect(selectedAfterReload.source.id).to.equal(nextBinding.source.id); expect(selectedAfterReload.epgChannelId).to.equal('preferred.guide.id'); }); + + it('preserves canonical settings when a mapping is renamed or renumbered', async () => { + const channelsResponse = await axios.get(`${baseUrl}/api/canonical/channels`); + const canonicalChannel = channelsResponse.data.channels[0]; + const bindingsResponse = await axios.get(`${baseUrl}/api/canonical/bindings`); + const preferredStream = bindingsResponse.data.bindings.find( + binding => binding.sourceChannel.source === 'IPTV Two' + ); + const guideBindingsResponse = await axios.get(`${baseUrl}/api/canonical/guide-bindings`); + const guideBinding = guideBindingsResponse.data.bindings.find( + binding => binding.source.name === 'IPTV Two' + ); + + await axios.patch(`${baseUrl}/api/canonical/channels/${canonicalChannel.id}`, { + customName: 'My Saved Channel', + }); + await axios.patch(`${baseUrl}/api/canonical/channels/${canonicalChannel.id}`, { + published: false, + }); + await axios.patch(`${baseUrl}/api/canonical/channels/${canonicalChannel.id}/preferred-stream`, { + sourceChannelId: preferredStream.sourceChannel.id, + }); + await axios.patch(`${baseUrl}/api/canonical/channels/${canonicalChannel.id}/guide-binding`, { + sourceId: guideBinding.source.id, + epgChannelId: 'my.saved.guide', + }); + await axios.patch(`${baseUrl}/api/output-profiles/default/channels`, { + channels: [ + { + canonicalId: canonicalChannel.id, + position: 9, + enabled: true, + guideNumberOverride: '909', + }, + ], + }); + + const channelMapService = await import('../../libs/channel-map-service.js'); + channelMapService.replaceChannelMap({ + 'Source One': { + name: 'Renamed Mapping', + tvg_id: 'renamed.mapping', + number: '909', + }, + 'Source Two': { + name: 'Renamed Mapping', + tvg_id: 'renamed.mapping', + number: '909', + }, + }); + + nock('http://canonical-routes.example') + .get('/one.m3u') + .reply( + 200, + ['#EXTM3U', '#EXTINF:-1 tvg-id="raw.one",Source One', 'http://streams.example/one'].join( + '\n' + ) + ); + nock('http://canonical-routes.example') + .get('/two.m3u') + .reply( + 200, + ['#EXTM3U', '#EXTINF:-1 tvg-id="raw.two",Source Two', 'http://streams.example/two'].join( + '\n' + ) + ); + + await parseM3UModule.parseAll(); + + const [reloadedChannels, reloadedBindings, reloadedGuideBindings, reloadedProfileEntries] = + await Promise.all([ + axios.get(`${baseUrl}/api/canonical/channels`), + axios.get(`${baseUrl}/api/canonical/bindings`), + axios.get(`${baseUrl}/api/canonical/guide-bindings`), + axios.get(`${baseUrl}/api/output-profiles/default/entries`), + ]); + + expect(reloadedChannels.data.channels).to.deep.include({ + id: canonicalChannel.id, + name: 'My Saved Channel', + baseName: 'Renamed Mapping', + customName: 'My Saved Channel', + guideNumber: '909', + published: false, + }); + expect( + reloadedBindings.data.bindings.find( + binding => binding.canonical.id === canonicalChannel.id && binding.isPreferredStream + ).sourceChannel.id + ).to.equal(preferredStream.sourceChannel.id); + expect( + reloadedGuideBindings.data.bindings.find( + binding => binding.canonical.id === canonicalChannel.id && binding.selected + ) + ).to.include({ epgChannelId: 'my.saved.guide' }); + expect(reloadedProfileEntries.data.entries).to.deep.include({ + position: 9, + guideNumberOverride: '909', + enabled: true, + canonical: { + id: canonicalChannel.id, + name: 'My Saved Channel', + baseName: 'Renamed Mapping', + customName: 'My Saved Channel', + tvg_id: 'renamed.mapping', + guideNumber: '909', + }, + }); + }); }); diff --git a/test/integration/epg.test.js b/test/integration/epg.test.js index 49ab6c3..d18d7ff 100644 --- a/test/integration/epg.test.js +++ b/test/integration/epg.test.js @@ -2,6 +2,7 @@ import { describe, it, beforeEach } from 'mocha'; import { expect } from 'chai'; import { XMLParser } from 'fast-xml-parser'; import { loadFixture, generateXMLTV } from '../helpers.js'; +import { deduplicateXmltvRecords } from '../../server/epg.js'; describe('EPG/XMLTV Integration', () => { let parser; @@ -201,6 +202,43 @@ describe('EPG/XMLTV Integration', () => { }); describe('EPG Merging Logic', () => { + it('deduplicates channel and programme conflicts deterministically while retaining language-tagged titles', () => { + const englishFirst = { + '@_channel': 'merged.1', + '@_start': '20250101000000 +0000', + '@_stop': '20250101010000 +0000', + title: [ + { '#text': 'Shared Show', '@_lang': 'en' }, + { '#text': 'Programme partage', '@_lang': 'fr' }, + ], + desc: 'First source wins', + }; + const conflictingSecond = { + '@_channel': 'merged.1', + '@_start': '20250101000000 +0000', + '@_stop': '20250101013000 +0000', + title: 'Shared Show', + desc: 'Later source must not replace the first record', + }; + + const merged = deduplicateXmltvRecords({ + tv: { + channel: [ + { '@_id': 'merged.1', 'display-name': 'First Source Name' }, + { '@_id': 'merged.1', 'display-name': 'Later Source Name' }, + ], + programme: [englishFirst, conflictingSecond], + }, + }); + + expect(merged.tv.channel).to.deep.equal([ + { '@_id': 'merged.1', 'display-name': 'First Source Name' }, + ]); + expect(merged.tv.programme).to.have.lengthOf(1); + expect(merged.tv.programme[0]).to.equal(englishFirst); + expect(merged.tv.programme[0].title).to.deep.equal(englishFirst.title); + }); + it('should merge multiple EPG sources correctly', () => { const epg1 = generateXMLTV( [{ id: 'ch1', name: 'Channel 1' }], diff --git a/test/integration/improvements.test.js b/test/integration/improvements.test.js index de0a810..54dbd31 100644 --- a/test/integration/improvements.test.js +++ b/test/integration/improvements.test.js @@ -173,6 +173,76 @@ describe('Config Backup API', () => { expect(content).to.include('base_url: https://db-backed.example.com'); }); + it('restores canonical bindings and output settings from a database snapshot', async () => { + databaseModule.initDatabase(); + const db = databaseModule.getDatabase(); + const timestamp = new Date().toISOString(); + const sourceId = 'restore-source'; + const channelId = 'restore-source-channel'; + const canonicalId = 'restore-canonical'; + const profileId = 'restore-profile'; + + db.prepare( + `INSERT INTO sources (id, name, type, playlist_url, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(sourceId, 'Restore Source', 'm3u', 'http://restore.example/playlist.m3u', 1, timestamp, timestamp); + db.prepare( + `INSERT INTO source_channels (id, source_id, name, last_seen_at) + VALUES (?, ?, ?, ?)` + ).run(channelId, sourceId, 'Restore Channel', timestamp); + db.prepare( + `INSERT INTO canonical_channels (id, slug, name, published, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(canonicalId, 'restore-canonical', 'Restore Canonical', 1, timestamp, timestamp); + db.prepare( + `INSERT INTO channel_bindings ( + id, source_channel_id, canonical_channel_id, binding_type, priority, is_preferred_stream, resolution_state + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run('restore-binding', channelId, canonicalId, 'source', 0, 1, 'resolved'); + db.prepare( + `INSERT INTO guide_bindings (id, canonical_channel_id, source_id, epg_channel_id, priority) + VALUES (?, ?, ?, ?, ?)` + ).run('restore-guide-binding', canonicalId, sourceId, 'restore.guide', 0); + db.prepare( + `INSERT INTO output_profiles (id, name, slug, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(profileId, 'Restore Profile', 'restore-profile', 1, timestamp, timestamp); + db.prepare( + `INSERT INTO output_profile_channels ( + id, output_profile_id, canonical_channel_id, position, guide_number_override, enabled + ) VALUES (?, ?, ?, ?, ?, ?)` + ).run('restore-output-entry', profileId, canonicalId, 7, '707', 0); + + const createRes = await axios.post(`${baseUrl}/api/config/backup`); + db.prepare('DELETE FROM sources WHERE id = ?').run(sourceId); + db.prepare('DELETE FROM canonical_channels WHERE id = ?').run(canonicalId); + databaseModule.closeDatabase(); + + const restoreRes = await axios.post(`${baseUrl}/api/config/backups/${createRes.data.name}/restore`); + expect(restoreRes.status).to.equal(200); + + expect( + databaseModule.get('SELECT id FROM source_channels WHERE id = ?', [channelId]) + ).to.deep.equal({ id: channelId }); + expect( + databaseModule.get( + 'SELECT source_channel_id, canonical_channel_id FROM channel_bindings WHERE id = ?', + ['restore-binding'] + ) + ).to.deep.equal({ source_channel_id: channelId, canonical_channel_id: canonicalId }); + expect( + databaseModule.get('SELECT epg_channel_id FROM guide_bindings WHERE id = ?', [ + 'restore-guide-binding', + ]) + ).to.deep.equal({ epg_channel_id: 'restore.guide' }); + expect( + databaseModule.get( + 'SELECT position, guide_number_override, enabled FROM output_profile_channels WHERE id = ?', + ['restore-output-entry'] + ) + ).to.deep.equal({ position: 7, guide_number_override: '707', enabled: 0 }); + }); + it('DELETE /api/config/backups/:name deletes a backup', async () => { const createRes = await axios.post(`${baseUrl}/api/config/backup`); const { name } = createRes.data; diff --git a/test/integration/legacy-config-compat.test.js b/test/integration/legacy-config-compat.test.js index 70bb287..4d2ade9 100644 --- a/test/integration/legacy-config-compat.test.js +++ b/test/integration/legacy-config-compat.test.js @@ -141,22 +141,55 @@ describe('legacy config compatibility routes', () => { expect(epgResponse.status).to.equal(200); const providersResponse = await axios.get(`${baseUrl}/api/config/providers`); - expect(providersResponse.data).to.deep.equal({ - providers: [ - { - name: 'Source One', - url: 'http://updated.example/one.m3u', - type: 'm3u', - epg: 'http://updated.example/one.xml', - }, - { - name: 'Source Three', - url: 'http://updated.example/three.m3u', - type: 'm3u', - epg: 'http://updated.example/three.xml', - }, - ], + expect( + providersResponse.data.providers.map(({ id: _id, ...provider }) => provider) + ).to.deep.equal([ + { + name: 'Source One', + url: 'http://updated.example/one.m3u', + type: 'm3u', + epg: 'http://updated.example/one.xml', + }, + { + name: 'Source Three', + url: 'http://updated.example/three.m3u', + type: 'm3u', + epg: 'http://updated.example/three.xml', + }, + ]); + expect(providersResponse.data.providers.every(provider => typeof provider.id === 'string')).to.equal( + true + ); + }); + + it('keeps source relationships when the primary provider configuration is saved', async () => { + const initial = await axios.get(`${baseUrl}/api/config/providers`); + const provider = initial.data.providers.find(item => item.name === 'Source One'); + const db = databaseModule.getDatabase(); + const timestamp = new Date().toISOString(); + + db.prepare('INSERT INTO source_channels (id, source_id, name, last_seen_at) VALUES (?, ?, ?, ?)').run( + 'primary-config-channel', + provider.id, + 'Saved Channel', + timestamp + ); + + await axios.put(`${baseUrl}/api/config/providers`, initial.data); + await axios.put(`${baseUrl}/api/config/providers`, { + providers: [{ ...provider, epg: 'http://updated.example/one.xml' }], }); + + const updated = await axios.get(`${baseUrl}/api/config/providers`); + expect(updated.data.providers).to.deep.equal([ + { + ...provider, + epg: 'http://updated.example/one.xml', + }, + ]); + expect(db.prepare('SELECT id, source_id FROM source_channels').all()).to.deep.equal([ + { id: 'primary-config-channel', source_id: provider.id }, + ]); }); it('rejects legacy EPG updates for unknown source names', async () => { diff --git a/test/integration/lineup.test.js b/test/integration/lineup.test.js index 6c3ab7e..ad5e5e3 100644 --- a/test/integration/lineup.test.js +++ b/test/integration/lineup.test.js @@ -18,8 +18,13 @@ describe('Lineup Route Integration', () => { let setupLineupRoutes; let errorHandler; let databaseModule; + let originalAxiosProxy; before(async () => { + // The route and its mocks address upstream hosts directly. Do not inherit a + // developer-machine HTTP proxy while exercising those local test doubles. + originalAxiosProxy = axios.defaults.proxy; + axios.defaults.proxy = false; configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iptv-lineup-config-')); dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'iptv-lineup-data-')); process.env.CONFIG_PATH = configDir; @@ -107,6 +112,7 @@ describe('Lineup Route Integration', () => { } cleanupCache(); databaseModule.closeDatabase(); + axios.defaults.proxy = originalAxiosProxy; delete process.env.CONFIG_PATH; delete process.env.DATA_PATH; await fs.rm(configDir, { recursive: true, force: true }); diff --git a/test/integration/mcp.test.js b/test/integration/mcp.test.js index 06988c6..0f97d4f 100644 --- a/test/integration/mcp.test.js +++ b/test/integration/mcp.test.js @@ -278,7 +278,7 @@ describe('MCP Route Integration', () => { }); const providers = expectToolSuccess(msg, 'list_providers').data; expect(providers).to.have.lengthOf(2); - expect(providers[0]).to.have.all.keys('name', 'type', 'hasEpg'); + expect(providers[0]).to.have.all.keys('id', 'name', 'type', 'hasEpg', 'enabled'); expect(providers.map(p => p.name)).to.include.members(['TestProvider', 'OtherProvider']); }); diff --git a/test/integration/output-profile-routes.test.js b/test/integration/output-profile-routes.test.js index 7836b89..cf14a80 100644 --- a/test/integration/output-profile-routes.test.js +++ b/test/integration/output-profile-routes.test.js @@ -8,6 +8,33 @@ import express from 'express'; import axios from 'axios'; import nock from 'nock'; +function parseSSE(text) { + return String(text) + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice(6))); +} + +async function callMcpTool(baseUrl, name, args, id = 1) { + const response = await axios.post( + `${baseUrl}/mcp`, + { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name, arguments: args }, + }, + { + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + }, + responseType: 'text', + } + ); + return parseSSE(response.data).find(message => message.id === id); +} + async function startServer(app) { return new Promise(resolve => { const server = app.listen(0, '127.0.0.1', () => { @@ -119,6 +146,8 @@ describe('output profile routes', () => { parseM3UModule = await import(`../../scripts/parseM3U.js?test=${Date.now()}`); databaseModule = await import('../../libs/database.js'); await parseM3UModule.parseAll(); + const { initChannelsCache } = await import('../../libs/channels-cache.js'); + await initChannelsCache(); const app = express(); app.use(express.json()); @@ -131,9 +160,11 @@ describe('output profile routes', () => { const epgModule = await import(`../../server/epg.js?test=${Date.now()}`); const errorHandlerModule = await import(`../../server/error-handler.js?test=${Date.now()}`); const canonicalRouter = (await import(`../../server/canonical.js?test=${Date.now()}`)).default; + const { setupMCPRoutes } = await import(`../../server/mcp.js?test=${Date.now()}`); lineupModule.setupLineupRoutes(app, {}); await epgModule.setupEPGRoutes(app); app.use(canonicalRouter); + setupMCPRoutes(app); app.use(errorHandlerModule.errorHandler); ({ server, baseUrl } = await startServer(app)); @@ -331,6 +362,154 @@ describe('output profile routes', () => { expect(xmltvResponse.data).not.to.include('Channel One Show'); }); + it('isolates XMLTV by profile and intersects source and channel filters with profile membership', async () => { + await axios.post(`${baseUrl}/api/output-profiles`, { + name: 'Bedroom TV', + copyFromSlug: 'default', + enabled: true, + }); + const bedroomEntries = await axios.get(`${baseUrl}/api/output-profiles/bedroom-tv/entries`); + await axios.patch(`${baseUrl}/api/output-profiles/bedroom-tv/channels`, { + channels: bedroomEntries.data.channels.map((entry, position) => ({ + canonicalId: entry.canonical.id, + position, + enabled: entry.canonical.tvg_id === 'output.2', + guideNumberOverride: entry.guideNumberOverride, + })), + }); + + // Fetch the named response first to prove it cannot poison the default + // response cache with its narrower membership. + const bedroomGuide = await axios.get(`${baseUrl}/profiles/bedroom-tv/xmltv.xml`); + expect(bedroomGuide.data).to.include(''); + expect(bedroomGuide.data).to.not.include(''); + + const defaultGuide = await axios.get(`${baseUrl}/xmltv.xml?channels=output.1,output.2`); + expect(defaultGuide.data).to.include(''); + expect(defaultGuide.data).to.include(''); + + const mismatchedFilters = await axios.get( + `${baseUrl}/xmltv.xml?source=IPTV%20One&channels=output.2` + ); + expect(mismatchedFilters.data).to.not.include(''); + expect(mismatchedFilters.data).to.not.include(''); + + const matchingFilters = await axios.get( + `${baseUrl}/xmltv.xml?source=IPTV%20One&channels=output.1` + ); + expect(matchingFilters.data).to.include(''); + expect(matchingFilters.data).to.not.include(''); + + const unknownSource = await axios.get(`${baseUrl}/xmltv.xml?source=not-configured`); + expect(unknownSource.data).to.not.include(''); + expect(unknownSource.data).to.not.include(''); + + await axios.post(`${baseUrl}/api/output-profiles`, { + name: 'Empty TV', + copyFromSlug: 'default', + enabled: true, + }); + const emptyEntries = await axios.get(`${baseUrl}/api/output-profiles/empty-tv/entries`); + await axios.patch(`${baseUrl}/api/output-profiles/empty-tv/channels`, { + channels: emptyEntries.data.channels.map((entry, position) => ({ + canonicalId: entry.canonical.id, + position, + enabled: false, + guideNumberOverride: entry.guideNumberOverride, + })), + }); + + const emptyGuide = await axios.get(`${baseUrl}/profiles/empty-tv/xmltv.xml`); + expect(emptyGuide.data).to.not.include(''); + expect(emptyGuide.data).to.not.include(''); + expect(emptyGuide.data).to.not.include(''); + expect(emptyDefaultGuide.data).to.not.include(''); + const stillPublishedBedroomGuide = await axios.get(`${baseUrl}/profiles/bedroom-tv/xmltv.xml`); + expect(stillPublishedBedroomGuide.data).to.include(''); + }); + + it('keeps default aliases empty after an MCP update disables every profile entry', async () => { + const entriesResponse = await axios.get(`${baseUrl}/api/output-profiles/default/entries`); + const channels = entriesResponse.data.channels.map((entry, position) => ({ + canonicalId: entry.canonical.id, + position, + enabled: false, + guideNumberOverride: entry.guideNumberOverride, + })); + + const message = await callMcpTool(baseUrl, 'update_output_profile_channels', { + slug: 'default', + channels, + }); + expect(message?.result?.isError).to.not.equal(true); + + const [defaultJson, namedJson, defaultM3u, namedM3u] = await Promise.all([ + axios.get(`${baseUrl}/lineup.json`), + axios.get(`${baseUrl}/profiles/default/lineup.json`), + axios.get(`${baseUrl}/lineup.m3u`), + axios.get(`${baseUrl}/profiles/default/lineup.m3u`), + ]); + + expect(defaultJson.data).to.deep.equal([]); + expect(namedJson.data).to.deep.equal([]); + expect(defaultM3u.data).to.not.include('Canonical One'); + expect(defaultM3u.data).to.not.include('Canonical Two'); + expect(namedM3u.data).to.not.include('Canonical One'); + expect(namedM3u.data).to.not.include('Canonical Two'); + + const includeUnmapped = await axios.get(`${baseUrl}/lineup.json?include_unmapped=true`); + expect(includeUnmapped.data).to.have.lengthOf(2); + }); + + it('keeps default aliases empty after an MCP update removes every effective guide number', async () => { + databaseModule + .getDatabase() + .prepare("UPDATE canonical_channels SET guide_number = ''") + .run(); + + const entriesResponse = await axios.get(`${baseUrl}/api/output-profiles/default/entries`); + const channels = entriesResponse.data.channels.map((entry, position) => ({ + canonicalId: entry.canonical.id, + position, + enabled: true, + guideNumberOverride: null, + })); + + const message = await callMcpTool(baseUrl, 'update_output_profile_channels', { + slug: 'default', + channels, + }); + expect(message?.result?.isError).to.not.equal(true); + + const [defaultJson, namedJson, defaultM3u, namedM3u] = await Promise.all([ + axios.get(`${baseUrl}/lineup.json`), + axios.get(`${baseUrl}/profiles/default/lineup.json`), + axios.get(`${baseUrl}/lineup.m3u`), + axios.get(`${baseUrl}/profiles/default/lineup.m3u`), + ]); + + expect(defaultJson.data).to.deep.equal([]); + expect(namedJson.data).to.deep.equal([]); + expect(defaultM3u.data).to.not.include('Canonical One'); + expect(defaultM3u.data).to.not.include('Canonical Two'); + expect(namedM3u.data).to.not.include('Canonical One'); + expect(namedM3u.data).to.not.include('Canonical Two'); + }); + it('uses a custom canonical name in published outputs without changing the stream lookup path', async () => { const canonicalChannelsResponse = await axios.get(`${baseUrl}/api/canonical/channels`); const canonicalChannel = canonicalChannelsResponse.data.channels.find( @@ -355,17 +534,19 @@ describe('output profile routes', () => { name: 'Living Room One', streamName: 'Source One', }); + const publicBaseUrl = baseUrl.replace('127.0.0.1', 'localhost'); + expect(renamedChannel.sourceChannelId).to.be.a('string').and.not.empty; + const stableStreamUrl = `${publicBaseUrl}/stream/channel/${encodeURIComponent(renamedChannel.sourceChannelId)}`; const lineupJsonResponse = await axios.get(`${baseUrl}/lineup.json`); - const publicBaseUrl = baseUrl.replace('127.0.0.1', 'localhost'); expect(lineupJsonResponse.data.find(channel => channel.GuideName === 'Living Room One')).to.include({ GuideName: 'Living Room One', - URL: `${publicBaseUrl}/stream/IPTV%20One/Source%20One`, + URL: stableStreamUrl, }); const lineupM3uResponse = await axios.get(`${baseUrl}/lineup.m3u`); expect(lineupM3uResponse.data).to.include('Living Room One'); - expect(lineupM3uResponse.data).to.include(`${publicBaseUrl}/stream/IPTV%20One/Source%20One`); + expect(lineupM3uResponse.data).to.include(stableStreamUrl); expect(lineupM3uResponse.data).not.to.include( `${publicBaseUrl}/stream/IPTV%20One/Living%20Room%20One` ); diff --git a/test/integration/output-profiles.test.js b/test/integration/output-profiles.test.js index 4ede223..baab2d8 100644 --- a/test/integration/output-profiles.test.js +++ b/test/integration/output-profiles.test.js @@ -303,6 +303,135 @@ describe('output profile persistence', () => { } }); + it('fans one selected source guide channel out to every canonical output and deduplicates it', async () => { + const epgPath = path.join(configDir, 'fanout.xml'); + await fs.writeFile( + epgPath, + [ + '', + '', + ' Shared Guide', + ' ', + ' Shared ShowProgramme partageFirst copy', + ' ', + ' ', + ' Shared ShowDuplicate must not win', + ' ', + '', + ].join('\n'), + 'utf8' + ); + + nock('http://output.example') + .get('/one.m3u') + .reply( + 200, + ['#EXTM3U', '#EXTINF:-1 tvg-id="raw.one",Source One', 'http://streams.example/one'].join( + '\n' + ) + ); + nock('http://output.example') + .get('/two.m3u') + .reply( + 200, + ['#EXTM3U', '#EXTINF:-1 tvg-id="raw.two",Source Two', 'http://streams.example/two'].join( + '\n' + ) + ); + await parseM3UModule.parseAll(); + + const db = databaseModule.getDatabase(); + const source = db.prepare("SELECT id FROM sources WHERE name = 'IPTV One'").get(); + const sourceChannel = db + .prepare( + `SELECT sc.id + FROM source_channels sc + JOIN sources s ON s.id = sc.source_id + WHERE s.name = 'IPTV One' + LIMIT 1` + ) + .get(); + const firstCanonical = canonicalService.listCanonicalChannels()[0]; + const defaultProfile = db.prepare("SELECT id FROM output_profiles WHERE slug = 'default'").get(); + const now = new Date().toISOString(); + const secondCanonicalId = 'fanout-canonical-two'; + + db.prepare('UPDATE sources SET epg_url = ? WHERE id = ?').run(pathToFileURL(epgPath).href, source.id); + db.prepare('UPDATE channel_bindings SET is_preferred_stream = 0 WHERE canonical_channel_id = ?').run( + firstCanonical.id + ); + db.prepare( + `UPDATE channel_bindings + SET is_preferred_stream = 1 + WHERE canonical_channel_id = ? AND source_channel_id = ?` + ).run(firstCanonical.id, sourceChannel.id); + db.prepare('DELETE FROM guide_bindings').run(); + db.prepare( + `INSERT INTO canonical_channels ( + id, slug, name, custom_name, tvg_id, guide_number, logo, group_name, published, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + secondCanonicalId, + 'fanout-two', + 'Canonical Fanout Two', + null, + 'output.fanout.two', + '902', + null, + null, + 1, + now, + now + ); + db.prepare( + `INSERT INTO channel_bindings ( + id, source_channel_id, canonical_channel_id, binding_type, priority, is_preferred_stream, confidence, resolution_state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + 'fanout-channel-binding', + sourceChannel.id, + secondCanonicalId, + 'mapping', + 0, + 1, + 1, + 'resolved' + ); + for (const canonicalId of [firstCanonical.id, secondCanonicalId]) { + db.prepare( + `INSERT INTO guide_bindings (id, canonical_channel_id, source_id, epg_channel_id, priority) + VALUES (?, ?, ?, ?, ?)` + ).run(`fanout-guide-${canonicalId}`, canonicalId, source.id, 'shared.guide', 0); + } + db.prepare( + `INSERT INTO output_profile_channels ( + id, output_profile_id, canonical_channel_id, position, guide_number_override, enabled + ) VALUES (?, ?, ?, ?, ?, ?)` + ).run('fanout-output-entry', defaultProfile.id, secondCanonicalId, 1, null, 1); + + const app = express(); + const epgModule = await import(`../../server/epg.js?test=${Date.now()}`); + const { errorHandler } = await import(`../../server/error-handler.js?test=${Date.now()}`); + await epgModule.setupEPGRoutes(app); + app.use(errorHandler); + const server = await new Promise(resolve => { + const instance = app.listen(0, '127.0.0.1', () => resolve(instance)); + }); + + try { + const response = await axios.get(`http://127.0.0.1:${server.address().port}/xmltv.xml`); + expect(response.data.match(//g)).to.have.lengthOf(1); + expect(response.data.match(//g)).to.have.lengthOf(1); + expect(response.data.match(/Shared Show'); + expect(response.data).to.include('Programme partage'); + expect(response.data).to.not.include('Duplicate must not win'); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + it('preserves output profile entry settings across reloads', async () => { await fs.writeFile( path.join(configDir, 'channel-map.yaml'), diff --git a/test/unit/cache-manager.test.js b/test/unit/cache-manager.test.js index 6aeea10..357e974 100644 --- a/test/unit/cache-manager.test.js +++ b/test/unit/cache-manager.test.js @@ -12,6 +12,17 @@ describe('Cache Manager', () => { }); describe('Basic Operations', () => { + it('evicts the least recently used entry when maxEntries is reached', () => { + const bounded = new Cache('bounded', 0, 2); + bounded.set('first', 1); + bounded.set('second', 2); + bounded.get('first'); + bounded.set('third', 3); + + expect(bounded.get('first')).to.equal(1); + expect(bounded.get('second')).to.be.undefined; + expect(bounded.get('third')).to.equal(3); + }); it('should set and get values', () => { cache.set('key1', 'value1'); const result = cache.get('key1'); diff --git a/test/unit/source-service.test.js b/test/unit/source-service.test.js index e3ca039..1e306f4 100644 --- a/test/unit/source-service.test.js +++ b/test/unit/source-service.test.js @@ -72,25 +72,104 @@ describe('source service', () => { }); const storedProviders = sourceService.loadProvidersConfigFromStore(); - expect(storedProviders).to.deep.equal({ - providers: [ - { - name: 'Antenna', - url: 'http://hdhr.local', - type: 'hdhomerun', - }, - { - name: 'Remote IPTV', - url: 'https://remote.example/playlist.m3u', - type: 'm3u', - epg: 'https://remote.example/guide.xml', - }, - ], - }); + expect(storedProviders.providers).to.have.lengthOf(2); + expect( + storedProviders.providers.map(({ id: _id, ...provider }) => provider) + ).to.deep.equal([ + { + name: 'Antenna', + url: 'http://hdhr.local', + type: 'hdhomerun', + }, + { + name: 'Remote IPTV', + url: 'https://remote.example/playlist.m3u', + type: 'm3u', + epg: 'https://remote.example/guide.xml', + }, + ]); + expect(storedProviders.providers.every(provider => typeof provider.id === 'string')).to.equal(true); const providersYaml = await fs.readFile(path.join(configDir, 'providers.yaml'), 'utf8'); expect(providersYaml).to.include('name: Antenna'); + expect(providersYaml).to.include('id:'); expect(providersYaml).to.include('type: hdhomerun'); expect(providersYaml).to.include('epg: https://remote.example/guide.xml'); }); + + it('preserves source relationships for unchanged and EPG-only provider saves', () => { + const [source] = sourceService.listSources(); + const db = databaseModule.getDatabase(); + const timestamp = new Date().toISOString(); + const canonicalId = 'canonical-preserved'; + const channelId = 'source-channel-preserved'; + + db.prepare( + `INSERT INTO canonical_channels ( + id, slug, name, published, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ).run(canonicalId, 'preserved', 'Preserved', 1, timestamp, timestamp); + db.prepare( + `INSERT INTO source_channels ( + id, source_id, external_key, name, last_seen_at + ) VALUES (?, ?, ?, ?, ?)` + ).run(channelId, source.id, 'preserved-key', 'Preserved Source Channel', timestamp); + db.prepare( + `INSERT INTO channel_bindings ( + id, source_channel_id, canonical_channel_id, binding_type, priority, is_preferred_stream, resolution_state + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run('binding-preserved', channelId, canonicalId, 'source', 0, 1, 'resolved'); + db.prepare( + `INSERT INTO guide_bindings (id, canonical_channel_id, source_id, epg_channel_id, priority) + VALUES (?, ?, ?, ?, ?)` + ).run('guide-preserved', canonicalId, source.id, 'preserved.guide', 0); + db.prepare( + `INSERT INTO source_sync_runs (id, source_id, kind, status, started_at) + VALUES (?, ?, ?, ?, ?)` + ).run('sync-preserved', source.id, 'channels', 'success', timestamp); + + const savedConfig = sourceService.loadProvidersConfigFromStore(); + sourceService.replaceProvidersConfig(savedConfig); + sourceService.replaceProvidersConfig({ + providers: [{ ...savedConfig.providers[0], epg: 'http://seed.example/updated.xml' }], + }); + + expect(sourceService.listSources()).to.deep.include({ + id: source.id, + name: 'Seeded IPTV', + url: 'http://seed.example/playlist.m3u', + type: 'm3u', + epg: 'http://seed.example/updated.xml', + enabled: true, + }); + expect(db.prepare('SELECT id, source_id FROM source_channels').all()).to.deep.equal([ + { id: channelId, source_id: source.id }, + ]); + expect(db.prepare('SELECT source_channel_id, canonical_channel_id FROM channel_bindings').all()).to.deep.equal([ + { source_channel_id: channelId, canonical_channel_id: canonicalId }, + ]); + expect(db.prepare('SELECT source_id FROM guide_bindings').all()).to.deep.equal([ + { source_id: source.id }, + ]); + expect(db.prepare('SELECT source_id FROM source_sync_runs').all()).to.deep.equal([ + { source_id: source.id }, + ]); + }); + + it('deletes relationships only for providers omitted from the saved configuration', () => { + const [source] = sourceService.listSources(); + const db = databaseModule.getDatabase(); + const timestamp = new Date().toISOString(); + db.prepare('INSERT INTO source_channels (id, source_id, name, last_seen_at) VALUES (?, ?, ?, ?)').run( + 'removed-source-channel', + source.id, + 'Will Be Removed', + timestamp + ); + + sourceService.replaceProvidersConfig({ providers: [] }); + + expect(db.prepare('SELECT COUNT(*) AS count FROM sources').get()).to.deep.equal({ count: 0 }); + expect(db.prepare('SELECT COUNT(*) AS count FROM source_channels').get()).to.deep.equal({ count: 0 }); + }); });