feat(ui): surface live language filters and built-in EPG sources (#23)

Add language filter controls for projected live channels, preserve backend language views in the browser, and separate built-in EPG sources from custom sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 11:42:23 +02:00
parent 2a1c745a26
commit 638a31d9ef
9 changed files with 425 additions and 25 deletions

View File

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

View File

@ -126,8 +126,8 @@
<div class="sidebar-header">
<div class="sidebar-header-row">
<div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button>
<input type="text" id="channel-search" placeholder="Search channels…" class="search-input">
<button type="button" class="search-clear" title="Clear search" aria-label="Clear channel search">&times;</button>
</div>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
title="Collapse sidebar">
@ -148,6 +148,8 @@
</button>
</div>
<div class="language-filter" id="channel-language-filter" aria-label="Languages"></div>
<div id="channel-list-status" class="sr-only" role="status" aria-live="polite"></div>
</div>
<div id="channel-list" class="channel-list">
<div class="empty-state">
@ -421,7 +423,15 @@
<div class="source-section">
<div class="section-header">
<h3>EPG Sources</h3>
<h3>Built-in EPG sources</h3>
</div>
<p class="setting-hint">Built-in sources provide free EPG data. You can disable them, but they can’t be removed.</p>
<div id="builtin-epg-list" class="source-list"></div>
</div>
<div class="source-section">
<div class="section-header">
<h3>Custom EPG sources</h3>
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
</div>
<div id="epg-list" class="source-list"></div>
@ -1200,19 +1210,19 @@
<!-- JavaScript -->
<script src="/js/icons.js"></script>
<script src="/js/api.js?v=4"></script>
<script src="/js/api.js?v=5"></script>
<script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/services/layaStatusPresentation.js?v=5"></script>
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
<script src="/js/services/liveChannelProjection.js?v=1"></script>
<script src="/js/services/liveChannelProjection.js?v=2"></script>
<script src="/js/services/channelVariantGrouping.js?v=7"></script>
<script src="/js/components/ChannelList.js?v=11"></script>
<script src="/js/components/SourceManager.js?v=2"></script>
<script src="/js/components/EpgGuide.js?v=2"></script>
<script src="/js/components/ChannelList.js?v=12"></script>
<script src="/js/components/SourceManager.js?v=3"></script>
<script src="/js/components/EpgGuide.js?v=3"></script>
<script src="/js/pages/HomePage.js?v=2"></script>
<script src="/js/pages/LivePage.js?v=2"></script>
<script src="/js/pages/LivePage.js?v=3"></script>
<script src="/js/pages/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>

View File

@ -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'),

View File

@ -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 {
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
</button>`;
}).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 `<div class="logical-channel ${expanded ? 'expanded' : ''}">
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channel.isHidden ? 'hidden' : ''}"
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(selected.id))}"
data-source-id="${selected.sourceId}" data-stream-id="${selected.streamId || ''}"
data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
<img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<div class="channel-logo-wrap"><img class="channel-logo" src="${this.getProxiedImageUrl(channel.tvgLogo)}" alt="" onerror="this.onerror=null;this.src='/img/placeholder.png'">
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div></div>
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
</div>
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
@ -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 `<button type="button" class="language-chip" data-language="${this.escapeHtml(language.code)}" aria-label="${this.escapeHtml(label)}" aria-pressed="${selected.has(language.code)}">${this.escapeHtml(text)}</button>`;
};
this.languageFilter.innerHTML = `
<span class="language-filter-label">Languages</span>
<button type="button" class="language-chip" data-language="all" aria-pressed="${selected.size === 0}">All</button>
${quick.map(button).join('')}
${this.availableLanguages.length > 3 ? `<button type="button" class="language-chip language-more-toggle" aria-expanded="false">More languages</button>
<div class="language-more-panel" hidden><input class="language-search" type="search" placeholder="Search languages…" aria-label="Search languages">
<div class="language-options">${this.availableLanguages.map(button).join('')}</div></div>` : ''}`;
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
*/

View File

@ -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 = '<div class="loading"></div>';
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 = `
<div class="empty-state">

View File

@ -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 = '<p class="hint">No built-in EPG sources available</p>';
return;
}
this.builtinEpgList.innerHTML = sources.map(source => {
const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · ');
return `<div class="source-item builtin-source ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
<span class="source-icon">${Icons.series}</span>
<div class="source-info">
<div class="source-name">${source.name} <span class="source-badge">Built-in</span></div>
<div class="source-meta">Free EPG data${coverage ? ` · ${coverage}` : ''}</div>
</div>
<div class="source-actions">
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data" aria-label="Refresh ${source.name}">${Icons.refresh}</button>
<button class="btn btn-sm btn-secondary" data-action="toggle" aria-pressed="${source.enabled}" title="${source.enabled ? 'Disable' : 'Enable'}">${source.enabled ? 'Disable' : 'Enable'}</button>
</div>
</div>`;
}).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
*/

View File

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

View File

@ -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) {

71
test/t23Frontend.test.js Normal file
View File

@ -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('<div id="builtin-epg-list"></div>', { runScripts: 'outside-only' });
dom.window.Icons = { series: '<i></i>', refresh: '<i></i>' };
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');
});