Merge branch 'feature/t23-flexible-channel-search-language-epg' into develop (#23)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -537,6 +537,82 @@ h4 {
|
|||||||
margin-top: var(--space-sm);
|
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 {
|
.source-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
@ -647,6 +723,28 @@ h4 {
|
|||||||
object-fit: contain;
|
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 {
|
.channel-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -1520,6 +1618,32 @@ video::cue {
|
|||||||
word-break: break-all;
|
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 {
|
.source-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|||||||
@ -126,8 +126,8 @@
|
|||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="sidebar-header-row">
|
<div class="sidebar-header-row">
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
|
<input type="text" id="channel-search" placeholder="Search channels…" class="search-input">
|
||||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
<button type="button" class="search-clear" title="Clear search" aria-label="Clear channel search">×</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
||||||
title="Collapse sidebar">
|
title="Collapse sidebar">
|
||||||
@ -148,6 +148,8 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</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>
|
||||||
<div id="channel-list" class="channel-list">
|
<div id="channel-list" class="channel-list">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@ -421,7 +423,15 @@
|
|||||||
|
|
||||||
<div class="source-section">
|
<div class="source-section">
|
||||||
<div class="section-header">
|
<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>
|
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="epg-list" class="source-list"></div>
|
<div id="epg-list" class="source-list"></div>
|
||||||
@ -1200,19 +1210,19 @@
|
|||||||
|
|
||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="/js/icons.js"></script>
|
<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/components/VideoPlayer.js?v=5"></script>
|
||||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||||
<script src="/js/services/channelLanguageFusion.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/layaStatusPresentation.js?v=5"></script>
|
||||||
<script src="/js/services/liveAnalysisPresentation.js?v=1"></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/services/channelVariantGrouping.js?v=7"></script>
|
||||||
<script src="/js/components/ChannelList.js?v=11"></script>
|
<script src="/js/components/ChannelList.js?v=12"></script>
|
||||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
<script src="/js/components/SourceManager.js?v=3"></script>
|
||||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
<script src="/js/components/EpgGuide.js?v=3"></script>
|
||||||
<script src="/js/pages/HomePage.js?v=2"></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/Guide.js?v=2"></script>
|
||||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||||
|
|||||||
@ -78,10 +78,12 @@ const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
live: {
|
live: {
|
||||||
getChannels: ({ sourceId = null, includeHidden = false } = {}) => {
|
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (sourceId) params.set('sourceId', sourceId);
|
if (sourceId) params.set('sourceId', sourceId);
|
||||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
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()}`);
|
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||||
},
|
},
|
||||||
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||||
|
|||||||
@ -11,6 +11,8 @@ class ChannelList {
|
|||||||
this.showHiddenCheckbox = document.getElementById('show-hidden');
|
this.showHiddenCheckbox = document.getElementById('show-hidden');
|
||||||
this.toggleGroupsBtn = document.getElementById('toggle-groups');
|
this.toggleGroupsBtn = document.getElementById('toggle-groups');
|
||||||
this.contextMenu = document.getElementById('context-menu');
|
this.contextMenu = document.getElementById('context-menu');
|
||||||
|
this.languageFilter = document.getElementById('channel-language-filter');
|
||||||
|
this.listStatus = document.getElementById('channel-list-status');
|
||||||
|
|
||||||
this.channels = [];
|
this.channels = [];
|
||||||
this.groups = [];
|
this.groups = [];
|
||||||
@ -25,6 +27,9 @@ class ChannelList {
|
|||||||
this.renderedChannels = [];
|
this.renderedChannels = [];
|
||||||
this.listMode = 'grouped';
|
this.listMode = 'grouped';
|
||||||
this.logicalChannels = [];
|
this.logicalChannels = [];
|
||||||
|
this.projectedLogicalChannels = null;
|
||||||
|
this.availableLanguages = [];
|
||||||
|
this.selectedLanguages = new Set();
|
||||||
this.selectedVariants = new Map();
|
this.selectedVariants = new Map();
|
||||||
this.expandedVariants = new Set();
|
this.expandedVariants = new Set();
|
||||||
this.preferredLanguageKey = 'nodecast_tv_preferred_language';
|
this.preferredLanguageKey = 'nodecast_tv_preferred_language';
|
||||||
@ -38,6 +43,7 @@ class ChannelList {
|
|||||||
this.projectionNotice = '';
|
this.projectionNotice = '';
|
||||||
|
|
||||||
this.loadCollapsedState();
|
this.loadCollapsedState();
|
||||||
|
this.restoreLanguageSelection();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,12 +194,19 @@ class ChannelList {
|
|||||||
this.searchInput.addEventListener('input', () => {
|
this.searchInput.addEventListener('input', () => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
this.render();
|
this.loadChannels({ refreshLanguages: false });
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Source filter handler
|
// 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', () => {
|
this.toggleGroupsBtn?.addEventListener('click', () => {
|
||||||
if (this.listMode === 'grouped') this.toggleAllGroups();
|
if (this.listMode === 'grouped') this.toggleAllGroups();
|
||||||
@ -263,10 +276,9 @@ class ChannelList {
|
|||||||
const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId);
|
const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId);
|
||||||
|
|
||||||
if (channel) {
|
if (channel) {
|
||||||
const programInfo = this.getProgramInfo(channel);
|
|
||||||
const programElement = item.querySelector('.channel-program');
|
const programElement = item.querySelector('.channel-program');
|
||||||
if (programElement) {
|
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
|
* Clear program info cache
|
||||||
* Useful when EPG data has been updated
|
* Useful when EPG data has been updated
|
||||||
@ -430,7 +450,7 @@ class ChannelList {
|
|||||||
this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat');
|
this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logicalChannels = ChannelVariantGrouping.groupChannels(this.channels, {
|
this.logicalChannels = this.projectedLogicalChannels || ChannelVariantGrouping.groupChannels(this.channels, {
|
||||||
showHidden,
|
showHidden,
|
||||||
selectedVariants: this.selectedVariants,
|
selectedVariants: this.selectedVariants,
|
||||||
preferredLanguage: this.preferredLanguage,
|
preferredLanguage: this.preferredLanguage,
|
||||||
@ -477,7 +497,7 @@ class ChannelList {
|
|||||||
|
|
||||||
// 1. Filter
|
// 1. Filter
|
||||||
this.filteredChannels = this.logicalChannels;
|
this.filteredChannels = this.logicalChannels;
|
||||||
if (searchTerm) {
|
if (searchTerm && !this.projectedLogicalChannels) {
|
||||||
this.filteredChannels = this.logicalChannels.filter(ch =>
|
this.filteredChannels = this.logicalChannels.filter(ch =>
|
||||||
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
|
String(ch.displayName ?? '').toLowerCase().includes(searchTerm) ||
|
||||||
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
|
ch.variants.some(variant => String(variant.name ?? '').toLowerCase().includes(searchTerm)) ||
|
||||||
@ -487,6 +507,7 @@ class ChannelList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let filteredChannels = this.filteredChannels;
|
let filteredChannels = this.filteredChannels;
|
||||||
|
if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`;
|
||||||
|
|
||||||
this.showHidden = showHidden;
|
this.showHidden = showHidden;
|
||||||
if (this.listMode === 'flat') {
|
if (this.listMode === 'flat') {
|
||||||
@ -819,15 +840,21 @@ class ChannelList {
|
|||||||
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('');
|
}).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' : ''}">
|
return `<div class="logical-channel ${expanded ? 'expanded' : ''}">
|
||||||
<div class="channel-item ${isActive ? 'active' : ''} ${isRenderActive ? 'nav-active' : ''} ${channel.isHidden ? 'hidden' : ''}"
|
<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-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-source-id="${selected.sourceId}" data-stream-id="${selected.streamId || ''}"
|
||||||
data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
|
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-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
|
||||||
<div class="channel-program">${this.escapeHtml(this.getProgramInfo(channel) || '')}</div></div>
|
<div class="channel-program">${this.escapeHtml(programLabel)}</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>` : ''}
|
${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>
|
<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>
|
</div>
|
||||||
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
|
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
|
||||||
@ -1030,24 +1057,124 @@ class ChannelList {
|
|||||||
|
|
||||||
async loadProjectedChannels(sourceId = null) {
|
async loadProjectedChannels(sourceId = null) {
|
||||||
try {
|
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)) {
|
if (!LiveChannelProjection.canUse(response)) {
|
||||||
|
this.projectedLogicalChannels = null;
|
||||||
this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.';
|
this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
let mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||||
this.channels = mapped.channels;
|
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.visibleFavorites = mapped.favorites;
|
||||||
this.hiddenItems = mapped.hiddenItems;
|
this.hiddenItems = mapped.hiddenItems;
|
||||||
this.projectionNotice = '';
|
this.projectionNotice = '';
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.projectedLogicalChannels = null;
|
||||||
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
|
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
|
||||||
this.projectionNotice = '';
|
this.projectionNotice = '';
|
||||||
return false;
|
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
|
* Load Xtream channels
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class EpgGuide {
|
|||||||
this.timeOffset = 0; // Hours offset from now
|
this.timeOffset = 0; // Hours offset from now
|
||||||
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
|
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
|
||||||
this.favorites = new Set(); // Set<"sourceId:channelId">
|
this.favorites = new Set(); // Set<"sourceId:channelId">
|
||||||
|
this.loadError = null;
|
||||||
this.selectedGroup = 'Favorites'; // Default to Favorites
|
this.selectedGroup = 'Favorites'; // Default to Favorites
|
||||||
|
|
||||||
// Virtual scrolling properties
|
// Virtual scrolling properties
|
||||||
@ -175,6 +176,7 @@ class EpgGuide {
|
|||||||
try {
|
try {
|
||||||
this.container.innerHTML = '<div class="loading"></div>';
|
this.container.innerHTML = '<div class="loading"></div>';
|
||||||
await this.fetchEpgData(forceRefresh);
|
await this.fetchEpgData(forceRefresh);
|
||||||
|
this.loadError = null;
|
||||||
this.lastRefreshTime = new Date();
|
this.lastRefreshTime = new Date();
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
@ -182,6 +184,7 @@ class EpgGuide {
|
|||||||
// This ensures EPG data stays fresh while the app is open
|
// This ensures EPG data stays fresh while the app is open
|
||||||
this.startBackgroundRefresh();
|
this.startBackgroundRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.loadError = err;
|
||||||
console.error('Error loading EPG:', err);
|
console.error('Error loading EPG:', err);
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class SourceManager {
|
|||||||
this.xtreamList = document.getElementById('xtream-list');
|
this.xtreamList = document.getElementById('xtream-list');
|
||||||
this.m3uList = document.getElementById('m3u-list');
|
this.m3uList = document.getElementById('m3u-list');
|
||||||
this.epgList = document.getElementById('epg-list');
|
this.epgList = document.getElementById('epg-list');
|
||||||
|
this.builtinEpgList = document.getElementById('builtin-epg-list');
|
||||||
|
|
||||||
// Content browser state
|
// Content browser state
|
||||||
this.contentType = 'channels'; // 'channels' or 'movies'
|
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.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
|
||||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading sources:', 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
|
* Show add source modal
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class LivePage {
|
|||||||
// Update program info in existing DOM elements without re-rendering
|
// Update program info in existing DOM elements without re-rendering
|
||||||
this.updateProgramInfo();
|
this.updateProgramInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.app.epgGuide.loadError = err;
|
||||||
console.warn('Background EPG fetch failed:', err);
|
console.warn('Background EPG fetch failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,8 +53,7 @@ class LivePage {
|
|||||||
if (channel) {
|
if (channel) {
|
||||||
const programDiv = item.querySelector('.channel-program');
|
const programDiv = item.querySelector('.channel-program');
|
||||||
if (programDiv) {
|
if (programDiv) {
|
||||||
const programTitle = this.app.channelList.getProgramInfo(channel);
|
programDiv.textContent = this.app.channelList.getProgramStatusText(channel);
|
||||||
programDiv.textContent = programTitle || '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,9 +12,12 @@
|
|||||||
const favorites = new Set();
|
const favorites = new Set();
|
||||||
const hiddenItems = new Set();
|
const hiddenItems = new Set();
|
||||||
const channels = [];
|
const channels = [];
|
||||||
|
const logicalChannels = [];
|
||||||
|
const hasLanguageViews = response.channels.every(channel => channel?.language && channel?.logicalChannelId);
|
||||||
|
|
||||||
response.channels.forEach(logicalChannel => {
|
response.channels.forEach(logicalChannel => {
|
||||||
if (!logicalChannel || !Array.isArray(logicalChannel.variants)) return;
|
if (!logicalChannel || !Array.isArray(logicalChannel.variants)) return;
|
||||||
|
const mappedVariants = [];
|
||||||
logicalChannel.variants.forEach(variant => {
|
logicalChannel.variants.forEach(variant => {
|
||||||
const source = sourceById.get(String(variant.sourceId));
|
const source = sourceById.get(String(variant.sourceId));
|
||||||
const id = String(variant.id);
|
const id = String(variant.id);
|
||||||
@ -32,14 +35,44 @@
|
|||||||
sourceType: variant.sourceType ?? source?.type ?? 'xtream',
|
sourceType: variant.sourceType ?? source?.type ?? 'xtream',
|
||||||
sourceName: variant.sourceName ?? source?.name ?? ''
|
sourceName: variant.sourceName ?? source?.name ?? ''
|
||||||
};
|
};
|
||||||
|
if (variant.language || logicalChannel.language) {
|
||||||
|
mapped.language = variant.language || logicalChannel.language;
|
||||||
|
}
|
||||||
channels.push(mapped);
|
channels.push(mapped);
|
||||||
|
mappedVariants.push(mapped);
|
||||||
const key = `${mapped.sourceId}:${id}`;
|
const key = `${mapped.sourceId}:${id}`;
|
||||||
if (variant.isFavorite) favorites.add(key);
|
if (variant.isFavorite) favorites.add(key);
|
||||||
if (variant.isHidden) hiddenItems.add(`channel:${mapped.sourceId}:${mapped.streamId || id}`);
|
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) {
|
function canUse(response) {
|
||||||
|
|||||||
16
server/db.js
16
server/db.js
@ -1,6 +1,7 @@
|
|||||||
const fs = require('fs/promises');
|
const fs = require('fs/promises');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { existsSync, mkdirSync } = require('fs');
|
const { existsSync, mkdirSync } = require('fs');
|
||||||
|
const { ensureBuiltinSources } = require('./services/builtinEpgRegistry');
|
||||||
|
|
||||||
// Ensure data directory exists (sync is fine for startup)
|
// Ensure data directory exists (sync is fine for startup)
|
||||||
const dataDir = path.join(__dirname, '..', 'data');
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
@ -17,25 +18,25 @@ async function loadDb() {
|
|||||||
try {
|
try {
|
||||||
const fileContent = await fs.readFile(dbPath, 'utf-8');
|
const fileContent = await fs.readFile(dbPath, 'utf-8');
|
||||||
const data = JSON.parse(fileContent);
|
const data = JSON.parse(fileContent);
|
||||||
return {
|
return ensureBuiltinSources({
|
||||||
sources: data.sources || [],
|
sources: data.sources || [],
|
||||||
hiddenItems: data.hiddenItems || [],
|
hiddenItems: data.hiddenItems || [],
|
||||||
favorites: data.favorites || [],
|
favorites: data.favorites || [],
|
||||||
settings: data.settings || getDefaultSettings(),
|
settings: data.settings || getDefaultSettings(),
|
||||||
users: data.users || [],
|
users: data.users || [],
|
||||||
nextId: data.nextId || 1
|
nextId: data.nextId || 1
|
||||||
};
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
// File doesn't exist, return default
|
// File doesn't exist, return default
|
||||||
return {
|
return ensureBuiltinSources({
|
||||||
sources: [],
|
sources: [],
|
||||||
hiddenItems: [],
|
hiddenItems: [],
|
||||||
favorites: [],
|
favorites: [],
|
||||||
settings: getDefaultSettings(),
|
settings: getDefaultSettings(),
|
||||||
users: [],
|
users: [],
|
||||||
nextId: 1
|
nextId: 1
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -168,6 +169,11 @@ const sources = {
|
|||||||
const index = db.sources.findIndex(s => s.id === parseInt(id));
|
const index = db.sources.findIndex(s => s.id === parseInt(id));
|
||||||
if (index === -1) return null;
|
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] = {
|
||||||
...db.sources[index],
|
...db.sources[index],
|
||||||
...updates,
|
...updates,
|
||||||
@ -179,6 +185,8 @@ const sources = {
|
|||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const db = await loadDb();
|
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));
|
db.sources = db.sources.filter(s => s.id !== parseInt(id));
|
||||||
// Also delete related hidden items and favorites
|
// Also delete related hidden items and favorites
|
||||||
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
|
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
|
||||||
|
|||||||
@ -36,6 +36,24 @@ const LIVE_PROJECTION_SCHEMA_SQL = `
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_logical_channels_fingerprint ON logical_channels(fingerprint);
|
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 (
|
CREATE TABLE IF NOT EXISTS catalog_projection_state (
|
||||||
projection TEXT PRIMARY KEY,
|
projection TEXT PRIMARY KEY,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { requireAuth, requireAdmin } = require('../auth');
|
const { requireAuth, requireAdmin } = require('../auth');
|
||||||
const liveProjection = require('../services/liveProjectionService');
|
const liveProjection = require('../services/liveProjectionService');
|
||||||
|
const iptvOrgCatalog = require('../services/iptvOrgCatalogService');
|
||||||
|
const { getDb } = require('../db/sqlite');
|
||||||
|
|
||||||
const router = express.Router();
|
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' });
|
return res.status(400).json({ error: 'includeHidden must be true or false' });
|
||||||
}
|
}
|
||||||
const includeHidden = req.query.includeHidden === 'true';
|
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 {
|
try {
|
||||||
let state = liveProjection.projectionState();
|
let state = liveProjection.projectionState();
|
||||||
if (state.status !== 'success' && state.status !== 'building') {
|
if (state.status !== 'success' && state.status !== 'building') {
|
||||||
state = await liveProjection.startRebuild().promise;
|
state = await liveProjection.startRebuild().promise;
|
||||||
}
|
}
|
||||||
const channels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id });
|
const allChannels = liveProjection.getChannels({ sourceId, includeHidden, userId: req.user.id });
|
||||||
return res.json({ projection: serializeState(state), channels });
|
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) {
|
} catch (error) {
|
||||||
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
||||||
const state = liveProjection.projectionState();
|
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 = router;
|
||||||
module.exports.serializeState = serializeState;
|
module.exports.serializeState = serializeState;
|
||||||
|
|||||||
@ -93,6 +93,9 @@ router.put('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name, url, username, password } = req.body;
|
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, {
|
const updated = await sources.update(req.params.id, {
|
||||||
name: name || existing.name,
|
name: name || existing.name,
|
||||||
url: url || existing.url,
|
url: url || existing.url,
|
||||||
@ -116,6 +119,9 @@ router.delete('/:id', async (req, res) => {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
return res.status(404).json({ error: 'Source not found' });
|
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
|
// Cascade delete: Clean up SQLite data for this source
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
20
server/services/builtinEpgRegistry.js
Normal file
20
server/services/builtinEpgRegistry.js
Normal file
@ -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 };
|
||||||
50
server/services/channelLanguage.js
Normal file
50
server/services/channelLanguage.js
Normal file
@ -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 };
|
||||||
19
server/services/flexibleChannelSearch.js
Normal file
19
server/services/flexibleChannelSearch.js
Normal file
@ -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 };
|
||||||
65
server/services/iptvOrgCatalogService.js
Normal file
65
server/services/iptvOrgCatalogService.js
Normal file
@ -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 };
|
||||||
@ -1,5 +1,8 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring');
|
const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring');
|
||||||
|
const { detectLanguage } = require('./channelLanguage');
|
||||||
|
const { matchesSearch } = require('./flexibleChannelSearch');
|
||||||
|
const iptvOrgCatalog = require('./iptvOrgCatalogService');
|
||||||
|
|
||||||
const QUALITY_TOKENS = new Set([
|
const QUALITY_TOKENS = new Set([
|
||||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||||
@ -76,6 +79,11 @@ function projectCatalog(items, options = {}) {
|
|||||||
qualityLabel: quality.label,
|
qualityLabel: quality.label,
|
||||||
qualityRank: quality.rank
|
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 itemIdentity = identity(fingerprint);
|
||||||
const candidateKey = itemIdentity.family && itemIdentity.number
|
const candidateKey = itemIdentity.family && itemIdentity.number
|
||||||
? `${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('DELETE FROM logical_channels').run();
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL,
|
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'
|
WHERE type = 'live'
|
||||||
`).run();
|
`).run();
|
||||||
const insertLogical = database.prepare(`
|
const insertLogical = database.prepare(`
|
||||||
@ -251,7 +261,9 @@ function rebuild(database = null, options = {}) {
|
|||||||
`);
|
`);
|
||||||
const updateVariant = database.prepare(`
|
const updateVariant = database.prepare(`
|
||||||
UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?,
|
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'
|
WHERE id = ? AND type = 'live'
|
||||||
`);
|
`);
|
||||||
const now = Date.now();
|
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);
|
insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now);
|
||||||
for (const variant of group.variants) {
|
for (const variant of group.variants) {
|
||||||
updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash,
|
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(`
|
database.prepare(`
|
||||||
@ -330,7 +345,7 @@ function parseData(value) {
|
|||||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
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();
|
database ||= defaultDatabase();
|
||||||
const params = [];
|
const params = [];
|
||||||
let sourceFilter = '';
|
let sourceFilter = '';
|
||||||
@ -350,7 +365,16 @@ function getChannels({ sourceId = null, includeHidden = false, userId = null },
|
|||||||
WHERE p.type = 'live' ${sourceFilter}
|
WHERE p.type = 'live' ${sourceFilter}
|
||||||
ORDER BY l.display_name, p.variant_rank
|
ORDER BY l.display_name, p.variant_rank
|
||||||
`).all(userId ?? -1, ...params);
|
`).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) {
|
function serializeChannels(rows, includeHidden = false) {
|
||||||
@ -376,22 +400,33 @@ function serializeChannels(rows, includeHidden = false) {
|
|||||||
isHidden: hidden,
|
isHidden: hidden,
|
||||||
isFavorite: Boolean(row.user_favorite)
|
isFavorite: Boolean(row.user_favorite)
|
||||||
};
|
};
|
||||||
if (!groups.has(row.logical_channel_id)) {
|
const languageCode = row.language_code || 'und';
|
||||||
groups.set(row.logical_channel_id, {
|
const languageKey = `${row.logical_channel_id}:${languageCode}`;
|
||||||
id: row.logical_channel_id,
|
if (!groups.has(languageKey)) {
|
||||||
|
groups.set(languageKey, {
|
||||||
|
id: languageKey,
|
||||||
|
logicalChannelId: row.logical_channel_id,
|
||||||
fingerprint: row.grouping_fingerprint,
|
fingerprint: row.grouping_fingerprint,
|
||||||
displayName: row.display_name,
|
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,
|
name: row.display_name,
|
||||||
variants: [],
|
variants: [],
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isHidden: true
|
isHidden: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const group = groups.get(row.logical_channel_id);
|
const group = groups.get(languageKey);
|
||||||
group.variants.push(variant);
|
group.variants.push(variant);
|
||||||
group.isFavorite ||= variant.isFavorite;
|
group.isFavorite ||= variant.isFavorite;
|
||||||
group.isHidden &&= variant.isHidden;
|
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 => {
|
return Array.from(groups.values()).map(group => {
|
||||||
group.preferredVariant ||= group.variants[0];
|
group.preferredVariant ||= group.variants[0];
|
||||||
@ -401,6 +436,13 @@ function serializeChannels(rows, includeHidden = false) {
|
|||||||
group.selectedVariantId = group.preferredVariant
|
group.selectedVariantId = group.preferredVariant
|
||||||
? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}`
|
? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}`
|
||||||
: null;
|
: 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;
|
return group;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -414,6 +456,7 @@ module.exports = {
|
|||||||
identityCandidatePairs,
|
identityCandidatePairs,
|
||||||
normalizeChannelName,
|
normalizeChannelName,
|
||||||
markProjectionBuilding,
|
markProjectionBuilding,
|
||||||
|
matchesSearch,
|
||||||
projectCatalog,
|
projectCatalog,
|
||||||
projectionState,
|
projectionState,
|
||||||
qualityForName,
|
qualityForName,
|
||||||
|
|||||||
@ -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']);
|
assert.deepEqual([...new Set(rawRows.map(row => row.grouping_fingerprint))], ['sports one']);
|
||||||
|
|
||||||
const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db);
|
const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db);
|
||||||
const sportsOne = apiChannels.find(channel => channel.fingerprint === 'sports one');
|
const sportsOneViews = apiChannels.filter(channel => channel.fingerprint === 'sports one');
|
||||||
assert.ok(sportsOne);
|
assert.deepEqual(sportsOneViews.map(channel => channel.language.code).sort(), ['en', 'fr']);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
sportsOne.variants
|
sportsOneViews.flatMap(channel => channel.variants)
|
||||||
.map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite])
|
.map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite])
|
||||||
.sort((left, right) => left[0].localeCompare(right[0])),
|
.sort((left, right) => left[0].localeCompare(right[0])),
|
||||||
[
|
[
|
||||||
|
|||||||
42
test/t23Backend.test.js
Normal file
42
test/t23Backend.test.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
71
test/t23Frontend.test.js
Normal file
71
test/t23Frontend.test.js
Normal 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user