beIN Sports 1 variants stayed separated after projection because names with region prefixes (C Car, C Reu, Thai), time-shift suffixes (2h/4h/8h/12h) and language markers (En, Fr, Thai) produced distinct fingerprints. Extend normalization in the server projection service and the frontend grouping fallback to fold these into one logical channel, with tests on the exact reported names. Clarify the Laya language detection warning so a language-matching failure is not mistaken for a grouping failure, and bump script versions for cache refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
12 KiB
JavaScript
301 lines
12 KiB
JavaScript
const crypto = require('crypto');
|
|
|
|
const QUALITY_TOKENS = new Set([
|
|
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
|
'1080p', '1080i', '720p', '576p', '480p'
|
|
]);
|
|
const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i;
|
|
const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']);
|
|
const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']);
|
|
|
|
function words(value) {
|
|
return String(value || '')
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(REGION_PREFIX, '')
|
|
.replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeChannelName(name) {
|
|
const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token));
|
|
const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports');
|
|
if (brandIndex >= 0) {
|
|
const branded = tokens.slice(brandIndex);
|
|
const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3])
|
|
? 4
|
|
: (/^\d+$/.test(branded[2]) ? 3 : 0);
|
|
if (numberedBaseLength) {
|
|
const suffix = branded.slice(numberedBaseLength);
|
|
const removable = suffix.every(token =>
|
|
BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token)
|
|
);
|
|
if (removable) return branded.slice(0, numberedBaseLength).join(' ');
|
|
}
|
|
return branded.join(' ').trim();
|
|
}
|
|
return tokens.join(' ').trim();
|
|
}
|
|
|
|
function displayNameForFingerprint(fingerprint) {
|
|
return fingerprint.split(' ').map(token => {
|
|
if (token === 'bein') return 'beIN';
|
|
if (/^\d+$/.test(token)) return token;
|
|
return token.charAt(0).toUpperCase() + token.slice(1);
|
|
}).join(' ');
|
|
}
|
|
|
|
function qualityForName(name) {
|
|
const tokens = words(name);
|
|
if (tokens.includes('4k') || tokens.includes('uhd')) return { label: '4K', rank: 4 };
|
|
if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) {
|
|
return { label: 'Full HD', rank: 3 };
|
|
}
|
|
if (tokens.includes('hd') || tokens.includes('720p')) return { label: 'HD', rank: 2 };
|
|
if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return { label: 'SD', rank: 0 };
|
|
return { label: 'Standard', rank: 1 };
|
|
}
|
|
|
|
function stableHash(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function projectCatalog(items) {
|
|
const buckets = new Map();
|
|
for (const item of items) {
|
|
const fallback = `channel-${item.source_id}:${item.item_id}`;
|
|
const fingerprint = normalizeChannelName(item.name) || fallback;
|
|
const quality = qualityForName(item.name);
|
|
const projected = {
|
|
...item,
|
|
fingerprint,
|
|
groupingInputHash: stableHash(`${item.name || ''}\0${item.category_id || ''}`),
|
|
qualityLabel: quality.label,
|
|
qualityRank: quality.rank
|
|
};
|
|
if (!buckets.has(fingerprint)) buckets.set(fingerprint, []);
|
|
buckets.get(fingerprint).push(projected);
|
|
}
|
|
|
|
return Array.from(buckets, ([fingerprint, variants]) => {
|
|
variants.sort((a, b) => b.qualityRank - a.qualityRank || a.source_id - b.source_id || String(a.item_id).localeCompare(String(b.item_id)));
|
|
variants.forEach((variant, index) => { variant.variantRank = index; });
|
|
return {
|
|
id: `logical:${stableHash(fingerprint).slice(0, 24)}`,
|
|
fingerprint,
|
|
displayName: displayNameForFingerprint(fingerprint),
|
|
preferredVariantId: variants[0].id,
|
|
variants
|
|
};
|
|
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
}
|
|
|
|
function catalogInputHash(items) {
|
|
return stableHash(items
|
|
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
|
|
.sort()
|
|
.join('\n'));
|
|
}
|
|
|
|
function defaultDatabase() {
|
|
return require('../db/sqlite').getDb();
|
|
}
|
|
|
|
function projectionState(database = null) {
|
|
database ||= defaultDatabase();
|
|
return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || {
|
|
projection: 'live', status: 'missing', started_at: null, completed_at: null,
|
|
input_hash: null, projected_count: 0, processed_count: 0, total_count: 0, error: null
|
|
};
|
|
}
|
|
|
|
function markProjectionBuilding(database = null) {
|
|
database ||= defaultDatabase();
|
|
const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count;
|
|
const startedAt = Date.now();
|
|
database.prepare(`
|
|
INSERT INTO catalog_projection_state
|
|
(projection, status, started_at, completed_at, input_hash, projected_count, processed_count, total_count, error)
|
|
VALUES ('live', 'building', ?, NULL, NULL, 0, 0, ?, NULL)
|
|
ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at,
|
|
completed_at = NULL, processed_count = 0, total_count = excluded.total_count, error = NULL
|
|
`).run(startedAt, total);
|
|
return projectionState(database);
|
|
}
|
|
|
|
function rebuild(database = null, options = {}) {
|
|
database ||= defaultDatabase();
|
|
const items = database.prepare(`
|
|
SELECT id, source_id, item_id, name, category_id
|
|
FROM playlist_items WHERE type = 'live'
|
|
`).all();
|
|
const groups = projectCatalog(items);
|
|
const inputHash = catalogInputHash(items);
|
|
if (!options.stateAlreadyBuilding) markProjectionBuilding(database);
|
|
|
|
try {
|
|
const apply = database.transaction(() => {
|
|
database.prepare('DELETE FROM logical_channels').run();
|
|
database.prepare(`
|
|
UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL,
|
|
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL
|
|
WHERE type = 'live'
|
|
`).run();
|
|
const insertLogical = database.prepare(`
|
|
INSERT INTO logical_channels
|
|
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const updateVariant = database.prepare(`
|
|
UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?,
|
|
grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?
|
|
WHERE id = ? AND type = 'live'
|
|
`);
|
|
const now = Date.now();
|
|
for (const group of groups) {
|
|
insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now);
|
|
for (const variant of group.variants) {
|
|
updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash,
|
|
variant.qualityLabel, variant.qualityRank, variant.variantRank, variant.id);
|
|
}
|
|
}
|
|
database.prepare(`
|
|
UPDATE catalog_projection_state SET status = 'success', completed_at = ?,
|
|
input_hash = ?, projected_count = ?, processed_count = ?, total_count = ?, error = NULL
|
|
WHERE projection = 'live'
|
|
`).run(Date.now(), inputHash, groups.length, items.length, items.length);
|
|
});
|
|
apply();
|
|
return projectionState(database);
|
|
} catch (error) {
|
|
database.prepare(`
|
|
UPDATE catalog_projection_state SET status = 'error', completed_at = ?, error = ?
|
|
WHERE projection = 'live'
|
|
`).run(Date.now(), 'Live catalog projection failed');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
let activeRebuild = null;
|
|
let activeState = null;
|
|
|
|
function startRebuild(database = null, options = {}) {
|
|
if (activeRebuild) return { alreadyRunning: true, state: activeState, promise: activeRebuild };
|
|
database ||= defaultDatabase();
|
|
const mark = options.mark || markProjectionBuilding;
|
|
const runner = options.runner || rebuild;
|
|
const schedule = options.schedule || setImmediate;
|
|
activeState = mark(database);
|
|
activeRebuild = new Promise((resolve, reject) => {
|
|
schedule(() => {
|
|
Promise.resolve()
|
|
.then(() => runner(database, { stateAlreadyBuilding: true }))
|
|
.then(resolve, reject);
|
|
});
|
|
}).finally(() => {
|
|
activeRebuild = null;
|
|
activeState = null;
|
|
});
|
|
return { alreadyRunning: false, state: activeState, promise: activeRebuild };
|
|
}
|
|
|
|
function queueRebuild(database = null) {
|
|
return startRebuild(database).promise;
|
|
}
|
|
|
|
function parseData(value) {
|
|
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
|
}
|
|
|
|
function getChannels({ sourceId = null, includeHidden = false, userId = null }, database = null) {
|
|
database ||= defaultDatabase();
|
|
const params = [];
|
|
let sourceFilter = '';
|
|
if (sourceId !== null) {
|
|
sourceFilter = 'AND p.source_id = ?';
|
|
params.push(sourceId);
|
|
}
|
|
const rows = database.prepare(`
|
|
SELECT p.*, c.name AS category_name, COALESCE(c.is_hidden, 0) AS category_hidden,
|
|
CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
|
l.display_name, l.preferred_variant_id
|
|
FROM playlist_items p
|
|
JOIN logical_channels l ON l.id = p.logical_channel_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
|
|
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
|
WHERE p.type = 'live' ${sourceFilter}
|
|
ORDER BY l.display_name, p.variant_rank
|
|
`).all(userId ?? -1, ...params);
|
|
return serializeChannels(rows, includeHidden);
|
|
}
|
|
|
|
function serializeChannels(rows, includeHidden = false) {
|
|
const groups = new Map();
|
|
for (const row of rows) {
|
|
const hidden = Boolean(row.is_hidden || row.category_hidden);
|
|
if (hidden && !includeHidden) continue;
|
|
const raw = parseData(row.data);
|
|
const categoryName = raw.groupTitle || row.category_name || 'Uncategorized';
|
|
const variant = {
|
|
...raw,
|
|
id: row.item_id,
|
|
sourceId: row.source_id,
|
|
name: row.name,
|
|
categoryId: row.category_id,
|
|
categoryName,
|
|
groupTitle: categoryName,
|
|
streamIcon: row.stream_icon,
|
|
streamUrl: row.stream_url,
|
|
qualityLabel: row.quality_label,
|
|
qualityRank: row.quality_rank,
|
|
variantRank: row.variant_rank,
|
|
isHidden: hidden,
|
|
isFavorite: Boolean(row.user_favorite)
|
|
};
|
|
if (!groups.has(row.logical_channel_id)) {
|
|
groups.set(row.logical_channel_id, {
|
|
id: row.logical_channel_id,
|
|
fingerprint: row.grouping_fingerprint,
|
|
displayName: row.display_name,
|
|
name: row.display_name,
|
|
variants: [],
|
|
isFavorite: false,
|
|
isHidden: true
|
|
});
|
|
}
|
|
const group = groups.get(row.logical_channel_id);
|
|
group.variants.push(variant);
|
|
group.isFavorite ||= variant.isFavorite;
|
|
group.isHidden &&= variant.isHidden;
|
|
if (row.id === row.preferred_variant_id) group.preferredVariant = variant;
|
|
}
|
|
return Array.from(groups.values()).map(group => {
|
|
group.preferredVariant ||= group.variants[0];
|
|
group.categoryId = group.preferredVariant?.categoryId || null;
|
|
group.categoryName = group.preferredVariant?.categoryName || 'Uncategorized';
|
|
group.groupTitle = group.preferredVariant?.groupTitle || group.categoryName;
|
|
group.selectedVariantId = group.preferredVariant
|
|
? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}`
|
|
: null;
|
|
return group;
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
catalogInputHash,
|
|
displayNameForFingerprint,
|
|
getChannels,
|
|
normalizeChannelName,
|
|
markProjectionBuilding,
|
|
projectCatalog,
|
|
projectionState,
|
|
qualityForName,
|
|
queueRebuild,
|
|
rebuild,
|
|
serializeChannels,
|
|
startRebuild
|
|
};
|