@@ -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 {
`;
}).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 `
` : ''}
@@ -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 `
+ ${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');
+});