diff --git a/public/css/main.css b/public/css/main.css index 932f0ce..5a702f7 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -537,6 +537,82 @@ h4 { margin-top: var(--space-sm); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.language-filter { + position: relative; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +.language-filter-label { + width: 100%; + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.language-chip { + min-height: 32px; + padding: 4px 9px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); + cursor: pointer; +} + +.language-chip[aria-pressed="true"] { + border-color: var(--color-accent); + background: var(--color-accent-dim); + color: var(--color-text-primary); +} + +.language-more-panel { + position: absolute; + z-index: 20; + top: 100%; + left: 0; + right: 0; + padding: var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + box-shadow: var(--shadow-lg); +} + +.language-search { + box-sizing: border-box; + width: 100%; + min-height: 40px; + margin-bottom: var(--space-sm); + padding: var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.language-options { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + max-height: 180px; + overflow: auto; +} + .source-select { flex: 1; padding: var(--space-xs) var(--space-sm); @@ -647,6 +723,28 @@ h4 { object-fit: contain; } +.channel-logo-wrap { + position: relative; + flex: 0 0 40px; + height: 40px; +} + +.channel-language-badge { + position: absolute; + top: -4px; + left: -4px; + min-width: 20px; + padding: 1px 4px; + border-radius: 999px; + background: var(--color-accent); + color: #fff; + font-size: 0.625rem; + font-weight: 700; + line-height: 18px; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); +} + .channel-info { flex: 1; min-width: 0; @@ -1520,6 +1618,32 @@ video::cue { word-break: break-all; } +.source-meta { + margin-top: 2px; + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.source-badge { + display: inline-block; + margin-left: var(--space-xs); + padding: 2px 6px; + border-radius: 999px; + background: var(--color-accent-dim); + color: var(--color-accent); + font-size: 0.6875rem; + font-weight: 600; +} + +@media (hover: none) and (pointer: coarse) { + .language-chip, + .variant-toggle, + .source-actions .btn { + min-height: 44px; + min-width: 44px; + } +} + .source-actions { display: flex; gap: var(--space-xs); diff --git a/public/index.html b/public/index.html index 5bba3ea..afbaf13 100644 --- a/public/index.html +++ b/public/index.html @@ -126,8 +126,8 @@
@@ -421,7 +423,15 @@
-

EPG Sources

+

Built-in EPG sources

+
+

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

+
+
+ +
+
+

Custom EPG sources

@@ -1200,19 +1210,19 @@ - + - + - - - + + + - + diff --git a/public/js/api.js b/public/js/api.js index e7aa057..585461e 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -78,10 +78,12 @@ const API = { }, live: { - getChannels: ({ sourceId = null, includeHidden = false } = {}) => { + getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => { const params = new URLSearchParams(); if (sourceId) params.set('sourceId', sourceId); params.set('includeHidden', String(Boolean(includeHidden))); + if (q) params.set('q', q); + if (languages.length) params.set('languages', languages.join(',')); return API.request('GET', `/live/channels?${params.toString()}`); }, rebuildProjection: () => API.request('POST', '/live/projection/rebuild'), diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index a2962b7..5696985 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -11,6 +11,8 @@ class ChannelList { this.showHiddenCheckbox = document.getElementById('show-hidden'); this.toggleGroupsBtn = document.getElementById('toggle-groups'); this.contextMenu = document.getElementById('context-menu'); + this.languageFilter = document.getElementById('channel-language-filter'); + this.listStatus = document.getElementById('channel-list-status'); this.channels = []; this.groups = []; @@ -25,6 +27,9 @@ class ChannelList { this.renderedChannels = []; this.listMode = 'grouped'; this.logicalChannels = []; + this.projectedLogicalChannels = null; + this.availableLanguages = []; + this.selectedLanguages = new Set(); this.selectedVariants = new Map(); this.expandedVariants = new Set(); this.preferredLanguageKey = 'nodecast_tv_preferred_language'; @@ -38,6 +43,7 @@ class ChannelList { this.projectionNotice = ''; this.loadCollapsedState(); + this.restoreLanguageSelection(); this.init(); } @@ -188,12 +194,19 @@ class ChannelList { this.searchInput.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { - this.render(); + this.loadChannels({ refreshLanguages: false }); }, 300); }); // Source filter handler - this.sourceSelect.addEventListener('change', () => this.loadChannels()); + this.sourceSelect.addEventListener('change', () => { + this.restoreLanguageSelection(); + this.loadChannels(); + }); + + document.addEventListener('keydown', event => { + if (event.key === 'Escape') this.closeLanguagePanel(); + }); this.toggleGroupsBtn?.addEventListener('click', () => { if (this.listMode === 'grouped') this.toggleAllGroups(); @@ -263,10 +276,9 @@ class ChannelList { const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId); if (channel) { - const programInfo = this.getProgramInfo(channel); const programElement = item.querySelector('.channel-program'); if (programElement) { - programElement.textContent = programInfo || ''; + programElement.textContent = this.getProgramStatusText(channel); } } }); @@ -313,6 +325,14 @@ class ChannelList { } } + getProgramStatusText(channel) { + const title = this.getProgramInfo(channel); + if (title) return `Live: ${title}`; + return window.app?.epgGuide?.loadError + ? 'Program information unavailable' + : 'No program information'; + } + /** * Clear program info cache * Useful when EPG data has been updated @@ -430,7 +450,7 @@ class ChannelList { this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat'); } - this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, { + this.logicalChannels = this.projectedLogicalChannels || ChannelVariantGrouping.groupChannels(this.channels, { showHidden, selectedVariants: this.selectedVariants, preferredLanguage: this.preferredLanguage, @@ -477,7 +497,7 @@ class ChannelList { // 1. Filter this.filteredChannels = this.logicalChannels; - if (searchTerm) { + if (searchTerm && !this.projectedLogicalChannels) { this.filteredChannels = this.logicalChannels.filter(ch => String(ch.displayName ?? '').toLowerCase().includes(searchTerm) || ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) || @@ -487,6 +507,7 @@ class ChannelList { } let filteredChannels = this.filteredChannels; + if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`; this.showHidden = showHidden; if (this.listMode === 'flat') { @@ -819,15 +840,21 @@ class ChannelList { ${this.escapeHtml(variant.name)} `; }).join(''); + const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' }; + const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code); + const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase()); + const languageAccessibleName = isUnknownLanguage ? 'Unknown language' : channelLanguage.label; + const programLabel = this.getProgramStatusText(channel); return `
- +
+ ${this.escapeHtml(languageBadge)}
${this.escapeHtml(channel.displayName)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
- ${channel.variants.length > 1 ? `` : ''} +
${this.escapeHtml(programLabel)}
+ ${channel.variants.length > 1 ? `` : ''}
${channel.variants.length > 1 ? `
${variants}
` : ''} @@ -1030,24 +1057,124 @@ class ChannelList { async loadProjectedChannels(sourceId = null) { try { - const response = await API.live.getChannels({ sourceId, includeHidden: true }); + let response = await API.live.getChannels({ + sourceId, + includeHidden: true, + q: this.searchInput?.value?.trim() || '', + languages: [...this.selectedLanguages] + }); if (!LiveChannelProjection.canUse(response)) { + this.projectedLogicalChannels = null; this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.'; return false; } - const mapped = LiveChannelProjection.mapResponse(response, this.sources); + let mapped = LiveChannelProjection.mapResponse(response, this.sources); this.channels = mapped.channels; + this.projectedLogicalChannels = mapped.logicalChannels; + this.availableLanguages = mapped.availableLanguages; + if (this.reconcileLanguageSelection()) { + response = await API.live.getChannels({ + sourceId, + includeHidden: true, + q: this.searchInput?.value?.trim() || '', + languages: [...this.selectedLanguages] + }); + mapped = LiveChannelProjection.mapResponse(response, this.sources); + this.channels = mapped.channels; + this.projectedLogicalChannels = mapped.logicalChannels; + this.availableLanguages = mapped.availableLanguages; + } + this.renderLanguageFilter(); this.visibleFavorites = mapped.favorites; this.hiddenItems = mapped.hiddenItems; this.projectionNotice = ''; return true; } catch (err) { + this.projectedLogicalChannels = null; console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message); this.projectionNotice = ''; return false; } } + languageStorageKey() { + return `nodecast_tv_live_languages:${this.sourceSelect?.value || 'all'}`; + } + + restoreLanguageSelection() { + try { + this.selectedLanguages = new Set(JSON.parse(sessionStorage.getItem(this.languageStorageKey()) || '[]')); + } catch { + this.selectedLanguages = new Set(); + } + } + + reconcileLanguageSelection() { + const previous = [...this.selectedLanguages]; + const available = new Set(this.availableLanguages.map(language => language.code)); + this.selectedLanguages = new Set([...this.selectedLanguages].filter(code => available.has(code))); + try { + sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages])); + } catch {} + return previous.length !== this.selectedLanguages.size; + } + + setLanguageSelection(code) { + if (code === 'all') this.selectedLanguages.clear(); + else if (this.selectedLanguages.has(code)) this.selectedLanguages.delete(code); + else this.selectedLanguages.add(code); + try { + sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages])); + } catch {} + this.renderLanguageFilter(); + this.loadChannels({ refreshLanguages: false }); + } + + closeLanguagePanel() { + const panel = this.languageFilter?.querySelector('.language-more-panel'); + const toggle = this.languageFilter?.querySelector('.language-more-toggle'); + const wasOpen = panel && !panel.hidden; + if (panel) panel.hidden = true; + toggle?.setAttribute('aria-expanded', 'false'); + if (wasOpen) toggle?.focus(); + } + + renderLanguageFilter() { + if (!this.languageFilter) return; + const quick = this.availableLanguages.slice(0, 3); + const selected = this.selectedLanguages; + const button = language => { + const unknown = language.code === 'und'; + const label = unknown ? 'Unknown' : language.label; + const text = unknown ? '?' : (language.short || language.code.toUpperCase()); + return ``; + }; + this.languageFilter.innerHTML = ` + Languages + + ${quick.map(button).join('')} + ${this.availableLanguages.length > 3 ? ` + ` : ''}`; + this.languageFilter.querySelectorAll('[data-language]').forEach(element => { + element.addEventListener('click', () => this.setLanguageSelection(element.dataset.language)); + }); + const toggle = this.languageFilter.querySelector('.language-more-toggle'); + const panel = this.languageFilter.querySelector('.language-more-panel'); + toggle?.addEventListener('click', () => { + panel.hidden = !panel.hidden; + toggle.setAttribute('aria-expanded', String(!panel.hidden)); + if (!panel.hidden) panel.querySelector('input')?.focus(); + }); + panel?.querySelector('.language-search')?.addEventListener('input', event => { + const query = event.target.value.toLowerCase(); + panel.querySelectorAll('[data-language]').forEach(element => { + const language = this.availableLanguages.find(item => item.code === element.dataset.language); + element.hidden = !`${language?.code} ${language?.label}`.toLowerCase().includes(query); + }); + }); + } + /** * Load Xtream channels */ diff --git a/public/js/components/EpgGuide.js b/public/js/components/EpgGuide.js index ce4a01d..11e0527 100644 --- a/public/js/components/EpgGuide.js +++ b/public/js/components/EpgGuide.js @@ -19,6 +19,7 @@ class EpgGuide { this.timeOffset = 0; // Hours offset from now this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px) this.favorites = new Set(); // Set<"sourceId:channelId"> + this.loadError = null; this.selectedGroup = 'Favorites'; // Default to Favorites // Virtual scrolling properties @@ -175,6 +176,7 @@ class EpgGuide { try { this.container.innerHTML = '
'; await this.fetchEpgData(forceRefresh); + this.loadError = null; this.lastRefreshTime = new Date(); this.render(); @@ -182,6 +184,7 @@ class EpgGuide { // This ensures EPG data stays fresh while the app is open this.startBackgroundRefresh(); } catch (err) { + this.loadError = err; console.error('Error loading EPG:', err); this.container.innerHTML = `
diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index e5e00f9..29f236f 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -8,6 +8,7 @@ class SourceManager { this.xtreamList = document.getElementById('xtream-list'); this.m3uList = document.getElementById('m3u-list'); this.epgList = document.getElementById('epg-list'); + this.builtinEpgList = document.getElementById('builtin-epg-list'); // Content browser state this.contentType = 'channels'; // 'channels' or 'movies' @@ -114,7 +115,9 @@ class SourceManager { this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream'); this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u'); - this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg'); + const epgSources = sources.filter(s => s.type === 'epg'); + this.renderBuiltinEpgSources(epgSources.filter(source => source.builtinKey)); + this.renderSourceList(this.epgList, epgSources.filter(source => !source.builtinKey), 'epg'); } catch (err) { console.error('Error loading sources:', err); } @@ -162,6 +165,33 @@ class SourceManager { }); } + renderBuiltinEpgSources(sources) { + if (!this.builtinEpgList) return; + if (!sources.length) { + this.builtinEpgList.innerHTML = '

No built-in EPG sources available

'; + return; + } + this.builtinEpgList.innerHTML = sources.map(source => { + const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · '); + return `
+ ${Icons.series} +
+
${source.name} Built-in
+
Free EPG data${coverage ? ` · ${coverage}` : ''}
+
+
+ + +
+
`; + }).join(''); + this.builtinEpgList.querySelectorAll('.source-item').forEach(item => { + const id = Number(item.dataset.id); + item.querySelector('[data-action="refresh"]')?.addEventListener('click', () => this.refreshSource(id, 'epg')); + item.querySelector('[data-action="toggle"]')?.addEventListener('click', () => this.toggleSource(id)); + }); + } + /** * Show add source modal */ diff --git a/public/js/pages/LivePage.js b/public/js/pages/LivePage.js index b61238e..19e1934 100644 --- a/public/js/pages/LivePage.js +++ b/public/js/pages/LivePage.js @@ -23,6 +23,7 @@ class LivePage { // Update program info in existing DOM elements without re-rendering this.updateProgramInfo(); } catch (err) { + this.app.epgGuide.loadError = err; console.warn('Background EPG fetch failed:', err); } } @@ -52,8 +53,7 @@ class LivePage { if (channel) { const programDiv = item.querySelector('.channel-program'); if (programDiv) { - const programTitle = this.app.channelList.getProgramInfo(channel); - programDiv.textContent = programTitle || ''; + programDiv.textContent = this.app.channelList.getProgramStatusText(channel); } } } diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index a213a1b..62adb9b 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -12,9 +12,12 @@ const favorites = new Set(); const hiddenItems = new Set(); const channels = []; + const logicalChannels = []; + const hasLanguageViews = response.channels.every(channel => channel?.language && channel?.logicalChannelId); response.channels.forEach(logicalChannel => { if (!logicalChannel || !Array.isArray(logicalChannel.variants)) return; + const mappedVariants = []; logicalChannel.variants.forEach(variant => { const source = sourceById.get(String(variant.sourceId)); const id = String(variant.id); @@ -32,14 +35,44 @@ sourceType: variant.sourceType ?? source?.type ?? 'xtream', sourceName: variant.sourceName ?? source?.name ?? '' }; + if (variant.language || logicalChannel.language) { + mapped.language = variant.language || logicalChannel.language; + } channels.push(mapped); + mappedVariants.push(mapped); const key = `${mapped.sourceId}:${id}`; if (variant.isFavorite) favorites.add(key); if (variant.isHidden) hiddenItems.add(`channel:${mapped.sourceId}:${mapped.streamId || id}`); }); + if (!mappedVariants.length) return; + const preferredVariant = mappedVariants.find(variant => + `${variant.sourceId}:${variant.id}` === String(logicalChannel.selectedVariantId) + ) || mappedVariants.find(variant => String(variant.id) === String(logicalChannel.preferredVariant?.id)) + || mappedVariants[0]; + const language = logicalChannel.language || preferredVariant.language; + if (hasLanguageViews) logicalChannels.push({ + ...logicalChannel, + fingerprint: logicalChannel.id, + displayName: logicalChannel.displayName || logicalChannel.standardName || logicalChannel.name, + name: logicalChannel.displayName || logicalChannel.standardName || logicalChannel.name, + variants: mappedVariants, + visibleVariants: mappedVariants.filter(variant => !variant.isHidden), + preferredVariant, + language, + selectedVariantId: `${preferredVariant.sourceId}:${preferredVariant.id}`, + groupTitle: preferredVariant.groupTitle, + tvgLogo: preferredVariant.tvgLogo, + tvgId: logicalChannel.epg?.channelId || preferredVariant.tvgId, + isFavorite: mappedVariants.some(variant => variant.isFavorite), + isHidden: mappedVariants.every(variant => variant.isHidden) + }); }); - return { channels, favorites, hiddenItems, projection: response.projection || {} }; + return { + channels, logicalChannels: hasLanguageViews ? logicalChannels : null, favorites, hiddenItems, + availableLanguages: response.filters?.availableLanguages || [], + projection: response.projection || {} + }; } function canUse(response) { diff --git a/server/db.js b/server/db.js index ba8e752..8186e2a 100644 --- a/server/db.js +++ b/server/db.js @@ -1,6 +1,7 @@ const fs = require('fs/promises'); const path = require('path'); const { existsSync, mkdirSync } = require('fs'); +const { ensureBuiltinSources } = require('./services/builtinEpgRegistry'); // Ensure data directory exists (sync is fine for startup) const dataDir = path.join(__dirname, '..', 'data'); @@ -17,25 +18,25 @@ async function loadDb() { try { const fileContent = await fs.readFile(dbPath, 'utf-8'); const data = JSON.parse(fileContent); - return { + return ensureBuiltinSources({ sources: data.sources || [], hiddenItems: data.hiddenItems || [], favorites: data.favorites || [], settings: data.settings || getDefaultSettings(), users: data.users || [], nextId: data.nextId || 1 - }; + }); } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist, return default - return { + return ensureBuiltinSources({ sources: [], hiddenItems: [], favorites: [], settings: getDefaultSettings(), users: [], nextId: 1 - }; + }); } throw error; } @@ -168,6 +169,11 @@ const sources = { const index = db.sources.findIndex(s => s.id === parseInt(id)); if (index === -1) return null; + const existing = db.sources[index]; + if (existing.builtinKey) { + const allowed = Object.keys(updates).every(key => key === 'enabled'); + if (!allowed) throw Object.assign(new Error('Built-in sources can only be enabled or disabled'), { code: 'BUILTIN_SOURCE_READONLY' }); + } db.sources[index] = { ...db.sources[index], ...updates, @@ -179,6 +185,8 @@ const sources = { async delete(id) { const db = await loadDb(); + const existing = db.sources.find(s => s.id === parseInt(id)); + if (existing?.builtinKey) throw Object.assign(new Error('Built-in sources cannot be removed'), { code: 'BUILTIN_SOURCE_PROTECTED' }); db.sources = db.sources.filter(s => s.id !== parseInt(id)); // Also delete related hidden items and favorites db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); diff --git a/server/db/liveProjectionSchema.js b/server/db/liveProjectionSchema.js index c42a6be..1d5f52c 100644 --- a/server/db/liveProjectionSchema.js +++ b/server/db/liveProjectionSchema.js @@ -36,6 +36,24 @@ const LIVE_PROJECTION_SCHEMA_SQL = ` ); CREATE INDEX IF NOT EXISTS idx_logical_channels_fingerprint ON logical_channels(fingerprint); + CREATE TABLE IF NOT EXISTS iptv_org_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + alt_names JSON NOT NULL DEFAULT '[]', + country TEXT, + languages JSON NOT NULL DEFAULT '[]', + data JSON, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_iptv_org_channels_name ON iptv_org_channels(normalized_name); + + CREATE TABLE IF NOT EXISTS iptv_org_cache_state ( + resource TEXT PRIMARY KEY, + refreshed_at INTEGER NOT NULL, + item_count INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS catalog_projection_state ( projection TEXT PRIMARY KEY, status TEXT NOT NULL, diff --git a/server/routes/live.js b/server/routes/live.js index a367cbd..0f5774c 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -1,6 +1,8 @@ const express = require('express'); const { requireAuth, requireAdmin } = require('../auth'); const liveProjection = require('../services/liveProjectionService'); +const iptvOrgCatalog = require('../services/iptvOrgCatalogService'); +const { getDb } = require('../db/sqlite'); const router = express.Router(); @@ -31,14 +33,25 @@ router.get('/channels', requireAuth, async (req, res) => { return res.status(400).json({ error: 'includeHidden must be true or false' }); } const includeHidden = req.query.includeHidden === 'true'; + const q = String(req.query.q || '').slice(0, 200); + const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean); + if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) { + return res.status(400).json({ error: 'languages must be comma-separated language codes' }); + } try { let state = liveProjection.projectionState(); if (state.status !== 'success' && state.status !== 'building') { state = await liveProjection.startRebuild().promise; } - const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id }); - return res.json({ projection: serializeState(state), channels }); + const allChannels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id }); + const availableLanguages = [...new Map(allChannels.map(channel => [channel.language.code, channel.language])).values()] + .map(language => ({ code: language.code, short: language.short, label: language.label })) + .sort((left, right) => left.label.localeCompare(right.label)); + const channels = allChannels.filter(channel => + (!languages.length || languages.includes(channel.language.code)) && liveProjection.matchesSearch(channel, q) + ); + return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels }); } catch (error) { console.error('[LiveProjection] Failed to serve projected channels:', error.message); const state = liveProjection.projectionState(); @@ -74,5 +87,16 @@ router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => { } }); +router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => { + try { + const result = await iptvOrgCatalog.refresh(getDb()); + await liveProjection.queueRebuild(); + return res.json(result); + } catch (error) { + console.error('[LiveProjection] iptv-org refresh failed:', error.message); + return res.status(502).json({ error: 'Unable to refresh the iptv-org catalog' }); + } +}); + module.exports = router; module.exports.serializeState = serializeState; diff --git a/server/routes/sources.js b/server/routes/sources.js index c27cdd0..a5a1549 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -93,6 +93,9 @@ router.put('/:id', async (req, res) => { } const { name, url, username, password } = req.body; + if (existing.builtinKey) { + return res.status(409).json({ error: 'Built-in sources can only be enabled or disabled', code: 'BUILTIN_SOURCE_READONLY' }); + } const updated = await sources.update(req.params.id, { name: name || existing.name, url: url || existing.url, @@ -116,6 +119,9 @@ router.delete('/:id', async (req, res) => { if (!existing) { return res.status(404).json({ error: 'Source not found' }); } + if (existing.builtinKey) { + return res.status(409).json({ error: 'Built-in sources cannot be removed', code: 'BUILTIN_SOURCE_PROTECTED' }); + } // Cascade delete: Clean up SQLite data for this source const db = getDb(); diff --git a/server/services/builtinEpgRegistry.js b/server/services/builtinEpgRegistry.js new file mode 100644 index 0000000..e276e77 --- /dev/null +++ b/server/services/builtinEpgRegistry.js @@ -0,0 +1,20 @@ +const BUILTIN_EPG_SOURCES = Object.freeze([ + Object.freeze({ builtinKey: 'epgpw-fr', type: 'epg', name: 'EPG.PW France', url: 'https://epg.pw/xmltv/epg_FR.xml.gz', provider: 'EPG.PW', language: 'fr', country: 'FR', priority: 100 }), + Object.freeze({ builtinKey: 'epgpw-us', type: 'epg', name: 'EPG.PW United States', url: 'https://epg.pw/xmltv/epg_US.xml.gz', provider: 'EPG.PW', language: 'en', country: 'US', priority: 90 }) +]); + +function protectedFields(source) { + return source?.builtinKey ? { readonly: true, removable: false } : { readonly: false, removable: true }; +} + +function ensureBuiltinSources(database) { + database.sources ||= []; + for (const definition of BUILTIN_EPG_SOURCES) { + const existing = database.sources.find(source => source.builtinKey === definition.builtinKey); + if (existing) Object.assign(existing, definition, protectedFields(definition)); + else database.sources.push({ id: database.nextId++, ...definition, ...protectedFields(definition), enabled: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); + } + return database; +} + +module.exports = { BUILTIN_EPG_SOURCES, ensureBuiltinSources, protectedFields }; diff --git a/server/services/channelLanguage.js b/server/services/channelLanguage.js new file mode 100644 index 0000000..e5e2763 --- /dev/null +++ b/server/services/channelLanguage.js @@ -0,0 +1,50 @@ +const LANGUAGES = Object.freeze({ + fr: { code: 'fr', short: 'FR', label: 'French' }, + en: { code: 'en', short: 'EN', label: 'English' }, + es: { code: 'es', short: 'ES', label: 'Spanish' }, + de: { code: 'de', short: 'DE', label: 'German' }, + it: { code: 'it', short: 'IT', label: 'Italian' }, + pt: { code: 'pt', short: 'PT', label: 'Portuguese' } +}); + +const CODE_ALIASES = Object.freeze({ + fr: 'fr', fra: 'fr', fre: 'fr', french: 'fr', francais: 'fr', france: 'fr', + en: 'en', eng: 'en', english: 'en', anglais: 'en', uk: 'en', us: 'en', + es: 'es', spa: 'es', spanish: 'es', espanol: 'es', spain: 'es', + de: 'de', deu: 'de', ger: 'de', german: 'de', deutsch: 'de', + it: 'it', ita: 'it', italian: 'it', italiano: 'it', + pt: 'pt', por: 'pt', portuguese: 'pt', portugues: 'pt' +}); + +function normalize(value) { + return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); +} + +function detectLanguage(item) { + const data = item.data && typeof item.data === 'object' ? item.data : {}; + const values = [item.name, item.category_name, data.groupTitle, data.tvgId, data.tvgName].filter(Boolean); + const scores = {}; + const evidence = []; + const prefix = String(item.name || '').trim().match(/^(?:\|\s*([a-z-]{2,8})\s*\||\[\s*([a-z-]{2,8})\s*\]|([a-z-]{2,8})\s*\|)/i); + if (prefix) add(prefix[1] || prefix[2] || prefix[3], 70, 'name-prefix'); + values.forEach((value, index) => { + const tokens = normalize(value).split(/[^a-z]+/).filter(Boolean); + for (const token of tokens) add(token, index === 0 ? 45 : 25, index === 0 ? 'name-token' : 'metadata'); + }); + function add(token, weight, type) { + const code = CODE_ALIASES[normalize(token)]; + if (!code) return; + scores[code] = (scores[code] || 0) + weight; + evidence.push({ code, weight, type, value: String(token) }); + } + const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]); + if (!ranked.length) return { code: 'und', short: 'UND', label: 'Unknown', confidence: 0, scores, evidence }; + const total = ranked.reduce((sum, [, score]) => sum + score, 0); + const confidence = Math.round(ranked[0][1] / total * 100); + if (confidence < 55 || (ranked[1] && ranked[0][1] - ranked[1][1] < 15)) { + return { code: 'und', short: 'UND', label: 'Unknown', confidence: 0, scores, evidence }; + } + return { ...LANGUAGES[ranked[0][0]], confidence, scores, evidence }; +} + +module.exports = { CODE_ALIASES, LANGUAGES, detectLanguage, normalize }; diff --git a/server/services/flexibleChannelSearch.js b/server/services/flexibleChannelSearch.js new file mode 100644 index 0000000..54f519a --- /dev/null +++ b/server/services/flexibleChannelSearch.js @@ -0,0 +1,19 @@ +function normalizeSearch(value) { + return String(value || '') + .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + .split(/\s+/).filter(Boolean) + .map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token); +} + +function matchesSearch(channel, query) { + const wanted = normalizeSearch(query); + if (!wanted.length) return true; + const corpus = normalizeSearch([ + channel.displayName, channel.standardName, channel.fingerprint, + ...(channel.variants || []).flatMap(variant => [variant.name, variant.tvgId, variant.categoryName]) + ].filter(Boolean).join(' ')); + return wanted.every(token => corpus.some(candidate => candidate.includes(token))); +} + +module.exports = { matchesSearch, normalizeSearch }; diff --git a/server/services/iptvOrgCatalogService.js b/server/services/iptvOrgCatalogService.js new file mode 100644 index 0000000..aa3be9f --- /dev/null +++ b/server/services/iptvOrgCatalogService.js @@ -0,0 +1,65 @@ +const API_ROOT = 'https://iptv-org.github.io/api'; + +function normalize(value) { + return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +function parseJson(value, fallback = []) { + try { return JSON.parse(value); } catch { return fallback; } +} + +async function refresh(database, fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== 'function') throw new Error('A fetch implementation is required'); + const [channelsResponse, feedsResponse] = await Promise.all([ + fetchImpl(`${API_ROOT}/channels.json`), fetchImpl(`${API_ROOT}/feeds.json`) + ]); + if (!channelsResponse.ok || !feedsResponse.ok) throw new Error('iptv-org API refresh failed'); + const channels = await channelsResponse.json(); + const feeds = await feedsResponse.json(); + const feedLanguages = new Map(); + for (const feed of feeds) { + const list = feedLanguages.get(feed.channel) || new Set(); + for (const language of feed.languages || []) list.add(String(language).toLowerCase()); + feedLanguages.set(feed.channel, list); + } + const write = database.transaction(() => { + database.prepare('DELETE FROM iptv_org_channels').run(); + const insert = database.prepare(` + INSERT INTO iptv_org_channels + (id, name, normalized_name, alt_names, country, languages, data, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const now = Date.now(); + for (const channel of channels) { + insert.run(channel.id, channel.name, normalize(channel.name), JSON.stringify(channel.alt_names || []), + channel.country || null, JSON.stringify([...feedLanguages.get(channel.id) || []]), JSON.stringify(channel), now); + } + database.prepare(` + INSERT INTO iptv_org_cache_state (resource, refreshed_at, item_count) VALUES ('catalog', ?, ?) + ON CONFLICT(resource) DO UPDATE SET refreshed_at = excluded.refreshed_at, item_count = excluded.item_count + `).run(now, channels.length); + }); + write(); + return { refreshedAt: Date.now(), itemCount: channels.length }; +} + +function findMatch(database, channel) { + const names = new Set([channel.displayName, channel.fingerprint, ...(channel.variants || []).map(variant => variant.name)].map(normalize).filter(Boolean)); + if (!names.size) return null; + const direct = database.prepare(`SELECT * FROM iptv_org_channels WHERE normalized_name IN (${[...names].map(() => '?').join(',')})`).all(...names); + const candidates = direct.length ? direct : database.prepare('SELECT * FROM iptv_org_channels').all().filter(row => + parseJson(row.alt_names).some(name => names.has(normalize(name))) + ); + const language = channel.language?.code; + const compatible = candidates.find(row => language === 'und' || parseJson(row.languages).includes(language)) || candidates[0]; + if (!compatible) return null; + return { + channelId: compatible.id, + name: compatible.name, + country: compatible.country, + confidence: names.has(compatible.normalized_name) ? 100 : 90 + }; +} + +module.exports = { API_ROOT, findMatch, normalize, refresh }; diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js index 37787b0..dcd80e1 100644 --- a/server/services/liveProjectionService.js +++ b/server/services/liveProjectionService.js @@ -1,5 +1,8 @@ const crypto = require('crypto'); const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring'); +const { detectLanguage } = require('./channelLanguage'); +const { matchesSearch } = require('./flexibleChannelSearch'); +const iptvOrgCatalog = require('./iptvOrgCatalogService'); const QUALITY_TOKENS = new Set([ '4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264', @@ -76,6 +79,11 @@ function projectCatalog(items, options = {}) { qualityLabel: quality.label, qualityRank: quality.rank }; + const language = detectLanguage({ ...projected, data: parseData(projected.data) }); + projected.languageCode = language.code; + projected.languageConfidence = language.confidence; + projected.languageScores = language.scores; + projected.languageEvidence = language.evidence; const itemIdentity = identity(fingerprint); const candidateKey = itemIdentity.family && itemIdentity.number ? `${itemIdentity.family}:${itemIdentity.number}` @@ -241,7 +249,9 @@ function rebuild(database = null, options = {}) { database.prepare('DELETE FROM logical_channels').run(); database.prepare(` UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL, - grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL + grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL, + language_code = NULL, language_confidence = NULL, language_scores = NULL, + language_evidence = NULL, language_detector = NULL, language_input_hash = NULL WHERE type = 'live' `).run(); const insertLogical = database.prepare(` @@ -251,7 +261,9 @@ function rebuild(database = null, options = {}) { `); const updateVariant = database.prepare(` UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?, - grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ? + grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?, + language_code = ?, language_confidence = ?, language_scores = ?, + language_evidence = ?, language_detector = 'algorithm', language_input_hash = ? WHERE id = ? AND type = 'live' `); const now = Date.now(); @@ -259,7 +271,10 @@ function rebuild(database = null, options = {}) { insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now); for (const variant of group.variants) { updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash, - variant.qualityLabel, variant.qualityRank, variant.variantRank, variant.id); + variant.qualityLabel, variant.qualityRank, variant.variantRank, + variant.languageCode, variant.languageConfidence, + JSON.stringify(variant.languageScores), JSON.stringify(variant.languageEvidence), + stableHash(`${variant.name || ''}\0${variant.category_name || ''}\0${variant.tvg_id || ''}`), variant.id); } } database.prepare(` @@ -330,7 +345,7 @@ function parseData(value) { try { return value ? JSON.parse(value) : {}; } catch { return {}; } } -function getChannels({ sourceId = null, includeHidden = false, userId = null }, database = null) { +function getChannels({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [] }, database = null) { database ||= defaultDatabase(); const params = []; let sourceFilter = ''; @@ -350,7 +365,16 @@ function getChannels({ sourceId = null, includeHidden = false, userId = null }, WHERE p.type = 'live' ${sourceFilter} ORDER BY l.display_name, p.variant_rank `).all(userId ?? -1, ...params); - return serializeChannels(rows, includeHidden); + return serializeChannels(rows, includeHidden).map(channel => { + const match = iptvOrgCatalog.findMatch(database, channel); + if (match) { + channel.standardName = match.name; + channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence }; + } + return channel; + }).filter(channel => + (!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q) + ); } function serializeChannels(rows, includeHidden = false) { @@ -376,22 +400,33 @@ function serializeChannels(rows, includeHidden = false) { isHidden: hidden, isFavorite: Boolean(row.user_favorite) }; - if (!groups.has(row.logical_channel_id)) { - groups.set(row.logical_channel_id, { - id: row.logical_channel_id, + const languageCode = row.language_code || 'und'; + const languageKey = `${row.logical_channel_id}:${languageCode}`; + if (!groups.has(languageKey)) { + groups.set(languageKey, { + id: languageKey, + logicalChannelId: row.logical_channel_id, fingerprint: row.grouping_fingerprint, displayName: row.display_name, + standardName: row.display_name, + standard: { provider: 'local', channelId: null, confidence: 0 }, + language: { + code: languageCode, + short: languageCode === 'und' ? 'UND' : languageCode.toUpperCase(), + label: languageCode === 'und' ? 'Unknown' : new Intl.DisplayNames(['en'], { type: 'language' }).of(languageCode), + confidence: Number(row.language_confidence || 0) + }, name: row.display_name, variants: [], isFavorite: false, isHidden: true }); } - const group = groups.get(row.logical_channel_id); + const group = groups.get(languageKey); group.variants.push(variant); group.isFavorite ||= variant.isFavorite; group.isHidden &&= variant.isHidden; - if (row.id === row.preferred_variant_id) group.preferredVariant = variant; + if (!group.preferredVariant || Number(variant.variantRank) < Number(group.preferredVariant.variantRank)) group.preferredVariant = variant; } return Array.from(groups.values()).map(group => { group.preferredVariant ||= group.variants[0]; @@ -401,6 +436,13 @@ function serializeChannels(rows, includeHidden = false) { group.selectedVariantId = group.preferredVariant ? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}` : null; + const epgChannelId = group.preferredVariant?.tvgId || group.preferredVariant?.epg_channel_id || null; + group.epg = { + sourceId: null, + channelId: epgChannelId, + language: group.language.code, + matchConfidence: epgChannelId ? 100 : 0 + }; return group; }); } @@ -414,6 +456,7 @@ module.exports = { identityCandidatePairs, normalizeChannelName, markProjectionBuilding, + matchesSearch, projectCatalog, projectionState, qualityForName, diff --git a/test/liveDetectionFunctionalChain.test.js b/test/liveDetectionFunctionalChain.test.js index 5583419..5f08c1f 100644 --- a/test/liveDetectionFunctionalChain.test.js +++ b/test/liveDetectionFunctionalChain.test.js @@ -125,10 +125,10 @@ test('functional live detection chain keeps categories and language selection co assert.deepEqual([...new Set(rawRows.map(row => row.grouping_fingerprint))], ['sports one']); const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db); - const sportsOne = apiChannels.find(channel => channel.fingerprint === 'sports one'); - assert.ok(sportsOne); + const sportsOneViews = apiChannels.filter(channel => channel.fingerprint === 'sports one'); + assert.deepEqual(sportsOneViews.map(channel => channel.language.code).sort(), ['en', 'fr']); assert.deepEqual( - sportsOne.variants + sportsOneViews.flatMap(channel => channel.variants) .map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite]) .sort((left, right) => left[0].localeCompare(right[0])), [ diff --git a/test/t23Backend.test.js b/test/t23Backend.test.js new file mode 100644 index 0000000..689fae7 --- /dev/null +++ b/test/t23Backend.test.js @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { detectLanguage } = require('../server/services/channelLanguage'); +const { matchesSearch, normalizeSearch } = require('../server/services/flexibleChannelSearch'); +const { ensureBuiltinSources } = require('../server/services/builtinEpgRegistry'); +const iptvOrg = require('../server/services/iptvOrgCatalogService'); +const { LIVE_PROJECTION_SCHEMA_SQL } = require('../server/db/liveProjectionSchema'); + +test('flexible search ignores accents, punctuation, case and simple plurals', () => { + const channel = { displayName: 'beIN Sports 1', variants: [{ name: 'BéIN SPORTS 1 HD' }] }; + for (const query of ['Bein Sport 1', 'bein sports 1', 'beIN Sport 1']) assert.equal(matchesSearch(channel, query), true); + assert.deepEqual(normalizeSearch('Chaînes-Sports'), ['chaine', 'sport']); +}); + +test('backend language detection uses und and separates explicit languages', () => { + assert.equal(detectLanguage({ name: 'Generic Sports' }).code, 'und'); + assert.equal(detectLanguage({ name: '|FR| Sports One' }).code, 'fr'); + assert.equal(detectLanguage({ name: '[UK] Sports One' }).code, 'en'); +}); + +test('built-in EPG registry is idempotent and protected while remaining disabled by default', () => { + const data = ensureBuiltinSources({ sources: [], nextId: 1 }); + ensureBuiltinSources(data); + assert.equal(data.sources.length, 2); + assert.ok(data.sources.every(source => source.type === 'epg' && source.readonly && !source.removable && !source.enabled)); +}); + +test('iptv-org adapter refreshes a persistent cache and matches aliases by language', async () => { + const db = new Database(':memory:'); + db.exec(`CREATE TABLE playlist_items (id TEXT PRIMARY KEY, logical_channel_id TEXT, grouping_fingerprint TEXT);`); + db.exec(LIVE_PROJECTION_SCHEMA_SQL); + const payloads = { + 'channels.json': [{ id: 'BeInSports1.fr', name: 'beIN Sports 1', alt_names: ['Bein Sport 1'], country: 'FR' }], + 'feeds.json': [{ channel: 'BeInSports1.fr', languages: ['fr'] }] + }; + await iptvOrg.refresh(db, async url => ({ ok: true, json: async () => payloads[url.split('/').pop()] })); + const match = iptvOrg.findMatch(db, { displayName: 'Bein Sport 1', language: { code: 'fr' }, variants: [] }); + assert.equal(match.channelId, 'BeInSports1.fr'); + assert.equal(match.name, 'beIN Sports 1'); + assert.equal(db.prepare("SELECT item_count FROM iptv_org_cache_state WHERE resource = 'catalog'").get().item_count, 1); +}); diff --git a/test/t23Frontend.test.js b/test/t23Frontend.test.js new file mode 100644 index 0000000..1ff606b --- /dev/null +++ b/test/t23Frontend.test.js @@ -0,0 +1,71 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const root = path.resolve(__dirname, '..'); +const projection = require('../public/js/services/liveChannelProjection.js'); + +function script(relativePath) { + return fs.readFileSync(path.join(root, 'public', relativePath), 'utf8'); +} + +test('projection adapter preserves backend channel-language views and their variants', () => { + const mapped = projection.mapResponse({ + projection: { stale: false }, + filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] }, + channels: [{ + id: 'logical-1:fr', logicalChannelId: 'logical-1', displayName: 'beIN Sports 1', + language: { code: 'fr', short: 'FR', label: 'French', confidence: 95 }, + selectedVariantId: '7:hd', epg: { channelId: 'BeInSports1.fr', language: 'fr' }, + variants: [ + { id: 'sd', sourceId: 7, name: 'beIN 1 SD', categoryName: 'Sports', isHidden: false }, + { id: 'hd', sourceId: 7, name: 'beIN 1 HD', categoryName: 'Sports', isHidden: false } + ] + }] + }, [{ id: 7, type: 'm3u', name: 'Provider' }]); + + assert.equal(mapped.logicalChannels.length, 1); + assert.equal(mapped.logicalChannels[0].fingerprint, 'logical-1:fr'); + assert.equal(mapped.logicalChannels[0].language.code, 'fr'); + assert.equal(mapped.logicalChannels[0].preferredVariant.id, 'hd'); + assert.equal(mapped.logicalChannels[0].variants.length, 2); + assert.equal(mapped.logicalChannels[0].tvgId, 'BeInSports1.fr'); + assert.deepEqual(mapped.availableLanguages.map(language => language.code), ['fr']); +}); + +test('live API sends flexible search and selected language filters', async () => { + const dom = new JSDOM('', { url: 'http://nodecast.test/', runScripts: 'outside-only' }); + let requestedUrl = ''; + dom.window.fetch = async url => { + requestedUrl = url; + return { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ channels: [] }) }; + }; + dom.window.eval(script('js/api.js')); + await dom.window.API.live.getChannels({ q: 'Bein Sport 1', languages: ['fr', 'en'] }); + const params = new URL(requestedUrl, 'http://nodecast.test').searchParams; + assert.equal(params.get('q'), 'Bein Sport 1'); + assert.equal(params.get('languages'), 'fr,en'); +}); + +test('built-in EPG sources expose only refresh and enable controls', () => { + const dom = new JSDOM('
', { runScripts: 'outside-only' }); + dom.window.Icons = { series: '', refresh: '' }; + dom.window.API = { sources: {} }; + dom.window.eval(`${script('js/components/SourceManager.js')}\nwindow.SourceManager = SourceManager;`); + const manager = Object.create(dom.window.SourceManager.prototype); + manager.builtinEpgList = dom.window.document.getElementById('builtin-epg-list'); + manager.refreshSource = () => {}; + manager.toggleSource = () => {}; + manager.renderBuiltinEpgSources([{ + id: 4, name: 'EPG.PW France', builtinKey: 'epgpw-fr', enabled: false, + language: 'fr', country: 'FR', readonly: true, removable: false + }]); + + assert.match(manager.builtinEpgList.textContent, /Built-in/); + assert.match(manager.builtinEpgList.textContent, /Free EPG data/); + assert.equal(manager.builtinEpgList.querySelector('[data-action="delete"]'), null); + assert.equal(manager.builtinEpgList.querySelector('[data-action="edit"]'), null); + assert.equal(manager.builtinEpgList.querySelector('[data-action="toggle"]').getAttribute('aria-pressed'), 'false'); +});