Merge branch 'feature/t43-live-variant-accordion-epg' into develop (#43)
This commit is contained in:
@ -824,6 +824,17 @@ h4 {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.variant-selected-badge {
|
||||||
|
align-self: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.variant-language {
|
.variant-language {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
|
|||||||
@ -101,13 +101,14 @@ const API = {
|
|||||||
params.set('page', String(Math.floor(offset / limit) + 1));
|
params.set('page', String(Math.floor(offset / limit) + 1));
|
||||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||||
},
|
},
|
||||||
getChannelVariants: (logicalChannelId, { includeHidden = false, limit = 100, offset = 0 } = {}) => {
|
getChannelVariants: (logicalChannelId, { includeHidden = false, language = '', limit = 100, offset = 0 } = {}) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
includeHidden: String(Boolean(includeHidden)),
|
includeHidden: String(Boolean(includeHidden)),
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
offset: String(offset),
|
offset: String(offset),
|
||||||
page: String(Math.floor(offset / limit) + 1)
|
page: String(Math.floor(offset / limit) + 1)
|
||||||
});
|
});
|
||||||
|
if (language) params.set('language', language);
|
||||||
return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`);
|
return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`);
|
||||||
},
|
},
|
||||||
checkNewStreams: () => API.request('POST', '/live/projection/check-new'),
|
checkNewStreams: () => API.request('POST', '/live/projection/check-new'),
|
||||||
|
|||||||
@ -311,6 +311,14 @@ class ChannelList {
|
|||||||
*/
|
*/
|
||||||
getProgramInfo(channel) {
|
getProgramInfo(channel) {
|
||||||
try {
|
try {
|
||||||
|
const projectedProgram = channel?.epg?.currentProgram;
|
||||||
|
if (projectedProgram?.title) {
|
||||||
|
return {
|
||||||
|
...projectedProgram,
|
||||||
|
start: projectedProgram.start ?? projectedProgram.startTime,
|
||||||
|
end: projectedProgram.end ?? projectedProgram.endTime
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!window.app || !window.app.epgGuide) return null;
|
if (!window.app || !window.app.epgGuide) return null;
|
||||||
|
|
||||||
// Cache key: channel_id + current_minute (invalidate every minute)
|
// Cache key: channel_id + current_minute (invalidate every minute)
|
||||||
@ -335,7 +343,11 @@ class ChannelList {
|
|||||||
if (program) break;
|
if (program) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = program ? program.title : null;
|
const result = program ? {
|
||||||
|
...program,
|
||||||
|
start: program.start ?? program.startTime,
|
||||||
|
end: program.end ?? program.endTime
|
||||||
|
} : null;
|
||||||
|
|
||||||
this._programInfoCache.set(cacheKey, result);
|
this._programInfoCache.set(cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
@ -346,8 +358,8 @@ class ChannelList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getProgramStatusText(channel) {
|
getProgramStatusText(channel) {
|
||||||
const title = this.getProgramInfo(channel);
|
const program = this.getProgramInfo(channel);
|
||||||
if (title) return `Live: ${title}`;
|
if (program?.title) return `Live: ${program.title}`;
|
||||||
return window.app?.epgGuide?.loadError
|
return window.app?.epgGuide?.loadError
|
||||||
? 'Program information unavailable'
|
? 'Program information unavailable'
|
||||||
: 'No program information';
|
: 'No program information';
|
||||||
@ -895,13 +907,13 @@ class ChannelList {
|
|||||||
const playerVariant = window.app?.player?.currentChannel;
|
const playerVariant = window.app?.player?.currentChannel;
|
||||||
const currentVariant = this.currentChannel?.preferredVariant;
|
const currentVariant = this.currentChannel?.preferredVariant;
|
||||||
const playingKeys = new Set([
|
const playingKeys = new Set([
|
||||||
channel.selectedVariantId,
|
|
||||||
playerVariant?.sourceId !== undefined && playerVariant?.id !== undefined
|
playerVariant?.sourceId !== undefined && playerVariant?.id !== undefined
|
||||||
? `${playerVariant.sourceId}:${playerVariant.id}` : '',
|
? `${playerVariant.sourceId}:${playerVariant.id}` : '',
|
||||||
currentVariant?.sourceId !== undefined && currentVariant?.id !== undefined
|
currentVariant?.sourceId !== undefined && currentVariant?.id !== undefined
|
||||||
? `${currentVariant.sourceId}:${currentVariant.id}` : ''
|
? `${currentVariant.sourceId}:${currentVariant.id}` : ''
|
||||||
].filter(Boolean).map(String));
|
].filter(Boolean).map(String));
|
||||||
const isPlaying = playingKeys.has(variantKey);
|
const isPlaying = playingKeys.has(variantKey);
|
||||||
|
const isSelected = !isPlaying && String(channel.selectedVariantId) === variantKey;
|
||||||
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''} ${isPlaying ? 'is-playing' : ''}" type="button"
|
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''} ${isPlaying ? 'is-playing' : ''}" type="button"
|
||||||
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
|
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
|
||||||
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
|
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
|
||||||
@ -910,6 +922,7 @@ class ChannelList {
|
|||||||
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
||||||
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
||||||
${isPlaying ? '<span class="variant-playing-badge">Playing</span>' : ''}
|
${isPlaying ? '<span class="variant-playing-badge">Playing</span>' : ''}
|
||||||
|
${isSelected ? '<span class="variant-selected-badge">Selected</span>' : ''}
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join('') : '';
|
}).join('') : '';
|
||||||
const variantCount = Number(channel.variantCount) || channel.variants.length;
|
const variantCount = Number(channel.variantCount) || channel.variants.length;
|
||||||
@ -928,7 +941,7 @@ class ChannelList {
|
|||||||
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
|
<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(programLabel)}</div></div>
|
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
|
||||||
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${variantCount} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
|
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-label="${this.escapeHtml(expanded ? `Hide streams for ${channel.displayName}` : `Show ${variantCount} streams for ${channel.displayName}`)}" title="${expanded ? 'Hide streams' : 'Show streams'}"><span>${variantCount} streams</span><span class="variant-chevron" aria-hidden="true">⌄</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>
|
||||||
${variantCount > 1 && expanded ? `<div class="channel-variants">${variants}${hasMoreVariants ? `<button type="button" class="btn btn-ghost load-more-variants" data-logical-id="${this.escapeHtml(channel.fingerprint)}">Load more streams</button>` : ''}</div>` : ''}
|
${variantCount > 1 && expanded ? `<div class="channel-variants">${variants}${hasMoreVariants ? `<button type="button" class="btn btn-ghost load-more-variants" data-logical-id="${this.escapeHtml(channel.fingerprint)}">Load more streams</button>` : ''}</div>` : ''}
|
||||||
@ -944,6 +957,7 @@ class ChannelList {
|
|||||||
const offset = append ? (current?.page?.nextOffset || 0) : 0;
|
const offset = append ? (current?.page?.nextOffset || 0) : 0;
|
||||||
const response = await API.live.getChannelVariants(channel.logicalChannelId || logicalId, {
|
const response = await API.live.getChannelVariants(channel.logicalChannelId || logicalId, {
|
||||||
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||||
|
language: channel.language?.code || '',
|
||||||
limit: 100,
|
limit: 100,
|
||||||
offset
|
offset
|
||||||
});
|
});
|
||||||
|
|||||||
@ -67,12 +67,19 @@ router.get('/channels', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => {
|
router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => {
|
||||||
|
const language = req.query.language === undefined
|
||||||
|
? null
|
||||||
|
: String(req.query.language).trim().toLowerCase();
|
||||||
|
if (language !== null && !/^(?:[a-z]{2,3}|und)$/.test(language)) {
|
||||||
|
return res.status(400).json({ error: 'language must be a valid language code' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const page = Number.parseInt(req.query.page, 10) || 1;
|
const page = Number.parseInt(req.query.page, 10) || 1;
|
||||||
const limit = Number.parseInt(req.query.limit, 10) || 100;
|
const limit = Number.parseInt(req.query.limit, 10) || 100;
|
||||||
const includeHidden = req.query.includeHidden === 'true';
|
const includeHidden = req.query.includeHidden === 'true';
|
||||||
return res.json(liveProjection.getChannelVariants({
|
return res.json(liveProjection.getChannelVariants({
|
||||||
logicalChannelId: req.params.logicalChannelId,
|
logicalChannelId: req.params.logicalChannelId,
|
||||||
|
language,
|
||||||
includeHidden,
|
includeHidden,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@ -625,8 +625,12 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannelVariants({ logicalChannelId, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) {
|
function getChannelVariants({ logicalChannelId, language = null, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) {
|
||||||
database ||= defaultDatabase();
|
database ||= defaultDatabase();
|
||||||
|
const languageFilter = language === null
|
||||||
|
? ''
|
||||||
|
: "AND COALESCE(p.language_code, 'und') = ?";
|
||||||
|
const params = language === null ? [] : [language];
|
||||||
const rows = database.prepare(`
|
const rows = database.prepare(`
|
||||||
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
||||||
l.display_name, l.preferred_variant_id, c.name AS category_name,
|
l.display_name, l.preferred_variant_id, c.name AS category_name,
|
||||||
@ -636,9 +640,9 @@ function getChannelVariants({ logicalChannelId, includeHidden = false, userId =
|
|||||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||||
LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id
|
LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id
|
||||||
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
||||||
WHERE p.type = 'live' AND p.logical_channel_id = ?
|
WHERE p.type = 'live' AND p.logical_channel_id = ? ${languageFilter}
|
||||||
ORDER BY p.variant_rank, p.id
|
ORDER BY p.variant_rank, p.id
|
||||||
`).all(userId ?? -1, logicalChannelId);
|
`).all(userId ?? -1, logicalChannelId, ...params);
|
||||||
const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants);
|
const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants);
|
||||||
const bounds = pageBounds(page, limit);
|
const bounds = pageBounds(page, limit);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -28,6 +28,8 @@ test('live channels route accepts pagination and forwards page bounds to the pro
|
|||||||
test('live variants route is paginated and authenticated', () => {
|
test('live variants route is paginated and authenticated', () => {
|
||||||
assert.ok(/router\.get\('\/channels\/:logicalChannelId\/variants', requireAuth,/.test(routeSource), 'GET /channels/:logicalChannelId/variants should exist and require auth');
|
assert.ok(/router\.get\('\/channels\/:logicalChannelId\/variants', requireAuth,/.test(routeSource), 'GET /channels/:logicalChannelId/variants should exist and require auth');
|
||||||
assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*page[^}]*limit/s.test(routeSource), 'variants route should call getChannelVariants with page and limit');
|
assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*page[^}]*limit/s.test(routeSource), 'variants route should call getChannelVariants with page and limit');
|
||||||
|
assert.ok(/language[^\n]*valid language code/.test(routeSource), 'variants route should reject invalid language codes');
|
||||||
|
assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*language[^}]*page[^}]*limit/s.test(routeSource), 'variants route should forward language to getChannelVariants');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('projection check-new route exists separately from complete rebuild', () => {
|
test('projection check-new route exists separately from complete rebuild', () => {
|
||||||
|
|||||||
@ -47,6 +47,11 @@ test('live API sends flexible search and selected language filters', async () =>
|
|||||||
const params = new URL(requestedUrl, 'http://nodecast.test').searchParams;
|
const params = new URL(requestedUrl, 'http://nodecast.test').searchParams;
|
||||||
assert.equal(params.get('q'), 'Bein Sport 1');
|
assert.equal(params.get('q'), 'Bein Sport 1');
|
||||||
assert.equal(params.get('languages'), 'fr,en');
|
assert.equal(params.get('languages'), 'fr,en');
|
||||||
|
|
||||||
|
await dom.window.API.live.getChannelVariants('logical:bein-1', { language: 'fr' });
|
||||||
|
const variantUrl = new URL(requestedUrl, 'http://nodecast.test');
|
||||||
|
assert.equal(variantUrl.pathname, '/api/live/channels/logical%3Abein-1/variants');
|
||||||
|
assert.equal(variantUrl.searchParams.get('language'), 'fr');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('built-in EPG sources expose only refresh and enable controls', () => {
|
test('built-in EPG sources expose only refresh and enable controls', () => {
|
||||||
|
|||||||
@ -152,9 +152,10 @@ test('full refresh analyzeAll runs Laya language detection after identity and pe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search result variant expansion uses logicalChannelId and lists the currently selected stream', async () => {
|
test('search result variant expansion keeps language and distinguishes selected from playing stream', async () => {
|
||||||
const { dom, window, channelList } = createChannelListDom();
|
const { dom, window, channelList } = createChannelListDom();
|
||||||
let variantsRequestedFor = null;
|
let variantsRequestedFor = null;
|
||||||
|
let variantRequestOptions = null;
|
||||||
|
|
||||||
window.API = {
|
window.API = {
|
||||||
live: {
|
live: {
|
||||||
@ -181,8 +182,9 @@ test('search result variant expansion uses logicalChannelId and lists the curren
|
|||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getChannelVariants: async logicalChannelId => {
|
getChannelVariants: async (logicalChannelId, options) => {
|
||||||
variantsRequestedFor = logicalChannelId;
|
variantsRequestedFor = logicalChannelId;
|
||||||
|
variantRequestOptions = options;
|
||||||
if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } };
|
if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } };
|
||||||
return {
|
return {
|
||||||
page: { hasMore: false },
|
page: { hasMore: false },
|
||||||
@ -202,14 +204,26 @@ test('search result variant expansion uses logicalChannelId and lists the curren
|
|||||||
|
|
||||||
const toggle = window.document.querySelector('.variant-toggle');
|
const toggle = window.document.querySelector('.variant-toggle');
|
||||||
assert.ok(toggle, 'search result should expose a stream list toggle');
|
assert.ok(toggle, 'search result should expose a stream list toggle');
|
||||||
|
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||||
|
assert.equal(toggle.getAttribute('aria-label'), 'Show 3 streams for beIN Sports 1');
|
||||||
toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||||
await wait(20);
|
await wait(20);
|
||||||
|
|
||||||
assert.equal(variantsRequestedFor, 'logical:bein-sports-1');
|
assert.equal(variantsRequestedFor, 'logical:bein-sports-1');
|
||||||
|
assert.equal(variantRequestOptions.language, 'fr');
|
||||||
assert.match(channelList.container.textContent, /beIN Sports 1 4K/);
|
assert.match(channelList.container.textContent, /beIN Sports 1 4K/);
|
||||||
|
assert.equal(channelList.container.querySelector('.variant-playing-badge'), null);
|
||||||
|
const selected = channelList.container.querySelector('.variant-selected-badge');
|
||||||
|
assert.equal(selected?.textContent, 'Selected');
|
||||||
|
assert.equal(selected?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
|
||||||
|
|
||||||
|
window.app.player.currentChannel = { id: 'bein-4k', sourceId: 1 };
|
||||||
|
channelList.render();
|
||||||
const playing = channelList.container.querySelector('.variant-playing-badge');
|
const playing = channelList.container.querySelector('.variant-playing-badge');
|
||||||
assert.equal(playing?.textContent, 'Playing');
|
assert.equal(playing?.textContent, 'Playing');
|
||||||
assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
|
assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
|
||||||
|
assert.equal(channelList.container.querySelector('.variant-selected-badge'), null);
|
||||||
|
assert.equal(channelList.container.querySelector('.variant-toggle').getAttribute('aria-label'), 'Hide streams for beIN Sports 1');
|
||||||
} finally {
|
} finally {
|
||||||
dom.window.close();
|
dom.window.close();
|
||||||
}
|
}
|
||||||
|
|||||||
229
test/t43LiveListVariantsEpgRegression.test.js
Normal file
229
test/t43LiveListVariantsEpgRegression.test.js
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const liveProjection = require('../server/services/liveProjectionService');
|
||||||
|
const {
|
||||||
|
ensureColumns,
|
||||||
|
LIVE_PROJECTION_COLUMNS,
|
||||||
|
LIVE_PROJECTION_SCHEMA_SQL
|
||||||
|
} = require('../server/db/liveProjectionSchema');
|
||||||
|
|
||||||
|
function createProjectionDb() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
category_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
data JSON
|
||||||
|
);
|
||||||
|
CREATE TABLE playlist_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category_id TEXT,
|
||||||
|
parent_id TEXT,
|
||||||
|
stream_icon TEXT,
|
||||||
|
stream_url TEXT,
|
||||||
|
container_extension TEXT,
|
||||||
|
rating REAL,
|
||||||
|
year TEXT,
|
||||||
|
added_at TEXT,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
is_favorite INTEGER DEFAULT 0,
|
||||||
|
data JSON
|
||||||
|
);
|
||||||
|
CREATE TABLE favorites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
item_type TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||||
|
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO categories (id, source_id, category_id, type, name)
|
||||||
|
VALUES ('sports', 1, 'sports', 'live', 'Sports')
|
||||||
|
`).run();
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedVariant(db, { itemId, name, language, rank, tvgId }) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO playlist_items
|
||||||
|
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||||
|
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||||
|
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||||
|
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, ?, 'logical:bein-sports-1',
|
||||||
|
'bein sports 1', 'group-hash', 'HD', 2, ?, ?, 0.97, 'laya', ?)
|
||||||
|
`).run(
|
||||||
|
`1:${itemId}`,
|
||||||
|
itemId,
|
||||||
|
name,
|
||||||
|
`https://streams.test/${itemId}.m3u8`,
|
||||||
|
JSON.stringify({ tvgId, groupTitle: 'Sports' }),
|
||||||
|
rank,
|
||||||
|
language,
|
||||||
|
`lang-${itemId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedBeinProjection(db) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO logical_channels
|
||||||
|
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||||
|
VALUES ('logical:bein-sports-1', 'bein sports 1', 'beIN Sports 1', '1:fr-hd', 3, 1, 1)
|
||||||
|
`).run();
|
||||||
|
seedVariant(db, { itemId: 'fr-hd', name: 'beIN Sports 1 HD FR', language: 'fr', rank: 0, tvgId: 'bein1.fr' });
|
||||||
|
seedVariant(db, { itemId: 'fr-backup', name: 'beIN Sports 1 Backup FR', language: 'fr', rank: 1, tvgId: 'bein1.fr.backup' });
|
||||||
|
seedVariant(db, { itemId: 'en-hd', name: 'beIN Sports 1 HD EN', language: 'en', rank: 2, tvgId: 'bein1.en' });
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO live_epg_matches
|
||||||
|
(logical_channel_id, language_code, source_id, channel_id, match_confidence, data, updated_at)
|
||||||
|
VALUES ('logical:bein-sports-1', 'fr', 9, 'bein1.fr', 100, ?, 1)
|
||||||
|
`).run(JSON.stringify({
|
||||||
|
sourceId: 9,
|
||||||
|
channelId: 'bein1.fr',
|
||||||
|
matchConfidence: 100,
|
||||||
|
currentProgram: {
|
||||||
|
title: 'Live Ligue 1',
|
||||||
|
description: 'Current match',
|
||||||
|
startTime: 1000,
|
||||||
|
endTime: 2000
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(__dirname, '..', 'public', relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChannelListDom() {
|
||||||
|
const dom = new JSDOM(`<!doctype html>
|
||||||
|
<input id="channel-search">
|
||||||
|
<select id="source-select"><option value="">All</option></select>
|
||||||
|
<input id="show-hidden" type="checkbox">
|
||||||
|
<button id="toggle-groups"></button>
|
||||||
|
<div id="context-menu"></div>
|
||||||
|
<div id="channel-language-filter"></div>
|
||||||
|
<div id="channel-list-status"></div>
|
||||||
|
<div id="channel-list"></div>`, { url: 'http://localhost/' });
|
||||||
|
const { window } = dom;
|
||||||
|
global.window = window;
|
||||||
|
global.document = window.document;
|
||||||
|
global.localStorage = window.localStorage;
|
||||||
|
global.IntersectionObserver = class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
window.Icons = { favorite: 'fav', favoriteOutline: 'fav-outline', collapseAll: '-', expandAll: '+', chevronDown: 'v' };
|
||||||
|
global.Icons = window.Icons;
|
||||||
|
window.eval(loadScript('js/services/channelLanguageDetection.js'));
|
||||||
|
window.eval(loadScript('js/services/channelLanguageFusion.js'));
|
||||||
|
window.eval(loadScript('js/services/channelVariantGrouping.js'));
|
||||||
|
window.eval(loadScript('js/services/liveChannelProjection.js'));
|
||||||
|
window.eval(loadScript('js/components/ChannelList.js'));
|
||||||
|
window.app = {
|
||||||
|
player: { settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' } },
|
||||||
|
epgGuide: { getCurrentProgram: () => null, loadError: null }
|
||||||
|
};
|
||||||
|
return { dom, window, channelList: new window.ChannelList() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('variant endpoint should keep the selected language view when expanding a FR live channel', () => {
|
||||||
|
const db = createProjectionDb();
|
||||||
|
seedBeinProjection(db);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = liveProjection.getChannelPage({
|
||||||
|
q: 'bein sports',
|
||||||
|
languages: ['fr'],
|
||||||
|
page: 1,
|
||||||
|
limit: 100
|
||||||
|
}, db);
|
||||||
|
assert.equal(page.channels.length, 1);
|
||||||
|
assert.equal(page.channels[0].language.code, 'fr');
|
||||||
|
assert.equal(page.channels[0].variantCount, 2);
|
||||||
|
|
||||||
|
const variants = liveProjection.getChannelVariants({
|
||||||
|
logicalChannelId: page.channels[0].logicalChannelId,
|
||||||
|
language: page.channels[0].language.code,
|
||||||
|
includeHidden: false,
|
||||||
|
page: 1,
|
||||||
|
limit: 100
|
||||||
|
}, db);
|
||||||
|
assert.deepEqual(
|
||||||
|
variants.variants.map(variant => variant.id),
|
||||||
|
['fr-hd', 'fr-backup'],
|
||||||
|
'expanding a FR channel should not mix EN variants into the FR stream list'
|
||||||
|
);
|
||||||
|
|
||||||
|
const allLanguages = liveProjection.getChannelVariants({
|
||||||
|
logicalChannelId: page.channels[0].logicalChannelId,
|
||||||
|
includeHidden: false,
|
||||||
|
page: 1,
|
||||||
|
limit: 100
|
||||||
|
}, db);
|
||||||
|
assert.deepEqual(
|
||||||
|
allLanguages.variants.map(variant => variant.id),
|
||||||
|
['fr-hd', 'fr-backup', 'en-hd'],
|
||||||
|
'omitting language should preserve the legacy all-language response'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live list should display persisted current EPG programme when the guide cache is empty', () => {
|
||||||
|
const db = createProjectionDb();
|
||||||
|
seedBeinProjection(db);
|
||||||
|
const page = liveProjection.getChannelPage({
|
||||||
|
q: 'bein sports',
|
||||||
|
languages: ['fr'],
|
||||||
|
page: 1,
|
||||||
|
limit: 100
|
||||||
|
}, db);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const { dom, window, channelList } = createChannelListDom();
|
||||||
|
try {
|
||||||
|
const projectionService = window.LiveChannelProjection || global.LiveChannelProjection;
|
||||||
|
const mapped = projectionService.mapResponse({
|
||||||
|
projection: { stale: false },
|
||||||
|
filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] },
|
||||||
|
channels: page.channels
|
||||||
|
}, [{ id: 1, type: 'xtream', name: 'Provider' }]);
|
||||||
|
channelList.projectedLogicalChannels = mapped.logicalChannels;
|
||||||
|
channelList.channels = mapped.channels;
|
||||||
|
channelList._hasCollapsedState = true;
|
||||||
|
channelList.collapsedGroups.clear();
|
||||||
|
channelList._userExpandedGroups.add('Sports');
|
||||||
|
channelList.render();
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
channelList.container.textContent,
|
||||||
|
/Live: Live Ligue 1/,
|
||||||
|
'channel row should use the persisted currentProgram from live_epg_matches before falling back to No program information'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearInterval(channelList._epgRefreshTimer);
|
||||||
|
window.clearInterval(channelList._epgRefreshTimer);
|
||||||
|
dom.window.close();
|
||||||
|
delete global.window;
|
||||||
|
delete global.document;
|
||||||
|
delete global.localStorage;
|
||||||
|
delete global.IntersectionObserver;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user