feat(live): add language-aware flexible channel search (#23)
Add backend language detection, flexible live channel search, iptv-org catalog caching, and protected built-in EPG source registration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,5 +1,8 @@
|
||||
const crypto = require('crypto');
|
||||
const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring');
|
||||
const { detectLanguage } = require('./channelLanguage');
|
||||
const { matchesSearch } = require('./flexibleChannelSearch');
|
||||
const iptvOrgCatalog = require('./iptvOrgCatalogService');
|
||||
|
||||
const QUALITY_TOKENS = new Set([
|
||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||
@ -76,6 +79,11 @@ function projectCatalog(items, options = {}) {
|
||||
qualityLabel: quality.label,
|
||||
qualityRank: quality.rank
|
||||
};
|
||||
const language = detectLanguage({ ...projected, data: parseData(projected.data) });
|
||||
projected.languageCode = language.code;
|
||||
projected.languageConfidence = language.confidence;
|
||||
projected.languageScores = language.scores;
|
||||
projected.languageEvidence = language.evidence;
|
||||
const itemIdentity = identity(fingerprint);
|
||||
const candidateKey = itemIdentity.family && itemIdentity.number
|
||||
? `${itemIdentity.family}:${itemIdentity.number}`
|
||||
@ -241,7 +249,9 @@ function rebuild(database = null, options = {}) {
|
||||
database.prepare('DELETE FROM logical_channels').run();
|
||||
database.prepare(`
|
||||
UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL,
|
||||
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL
|
||||
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL,
|
||||
language_code = NULL, language_confidence = NULL, language_scores = NULL,
|
||||
language_evidence = NULL, language_detector = NULL, language_input_hash = NULL
|
||||
WHERE type = 'live'
|
||||
`).run();
|
||||
const insertLogical = database.prepare(`
|
||||
@ -251,7 +261,9 @@ function rebuild(database = null, options = {}) {
|
||||
`);
|
||||
const updateVariant = database.prepare(`
|
||||
UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?,
|
||||
grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?
|
||||
grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?,
|
||||
language_code = ?, language_confidence = ?, language_scores = ?,
|
||||
language_evidence = ?, language_detector = 'algorithm', language_input_hash = ?
|
||||
WHERE id = ? AND type = 'live'
|
||||
`);
|
||||
const now = Date.now();
|
||||
@ -259,7 +271,10 @@ function rebuild(database = null, options = {}) {
|
||||
insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now);
|
||||
for (const variant of group.variants) {
|
||||
updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash,
|
||||
variant.qualityLabel, variant.qualityRank, variant.variantRank, variant.id);
|
||||
variant.qualityLabel, variant.qualityRank, variant.variantRank,
|
||||
variant.languageCode, variant.languageConfidence,
|
||||
JSON.stringify(variant.languageScores), JSON.stringify(variant.languageEvidence),
|
||||
stableHash(`${variant.name || ''}\0${variant.category_name || ''}\0${variant.tvg_id || ''}`), variant.id);
|
||||
}
|
||||
}
|
||||
database.prepare(`
|
||||
@ -330,7 +345,7 @@ function parseData(value) {
|
||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function getChannels({ sourceId = null, includeHidden = false, userId = null }, database = null) {
|
||||
function getChannels({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [] }, database = null) {
|
||||
database ||= defaultDatabase();
|
||||
const params = [];
|
||||
let sourceFilter = '';
|
||||
@ -350,7 +365,16 @@ function getChannels({ sourceId = null, includeHidden = false, userId = null },
|
||||
WHERE p.type = 'live' ${sourceFilter}
|
||||
ORDER BY l.display_name, p.variant_rank
|
||||
`).all(userId ?? -1, ...params);
|
||||
return serializeChannels(rows, includeHidden);
|
||||
return serializeChannels(rows, includeHidden).map(channel => {
|
||||
const match = iptvOrgCatalog.findMatch(database, channel);
|
||||
if (match) {
|
||||
channel.standardName = match.name;
|
||||
channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence };
|
||||
}
|
||||
return channel;
|
||||
}).filter(channel =>
|
||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
||||
);
|
||||
}
|
||||
|
||||
function serializeChannels(rows, includeHidden = false) {
|
||||
@ -376,22 +400,33 @@ function serializeChannels(rows, includeHidden = false) {
|
||||
isHidden: hidden,
|
||||
isFavorite: Boolean(row.user_favorite)
|
||||
};
|
||||
if (!groups.has(row.logical_channel_id)) {
|
||||
groups.set(row.logical_channel_id, {
|
||||
id: row.logical_channel_id,
|
||||
const languageCode = row.language_code || 'und';
|
||||
const languageKey = `${row.logical_channel_id}:${languageCode}`;
|
||||
if (!groups.has(languageKey)) {
|
||||
groups.set(languageKey, {
|
||||
id: languageKey,
|
||||
logicalChannelId: row.logical_channel_id,
|
||||
fingerprint: row.grouping_fingerprint,
|
||||
displayName: row.display_name,
|
||||
standardName: row.display_name,
|
||||
standard: { provider: 'local', channelId: null, confidence: 0 },
|
||||
language: {
|
||||
code: languageCode,
|
||||
short: languageCode === 'und' ? 'UND' : languageCode.toUpperCase(),
|
||||
label: languageCode === 'und' ? 'Unknown' : new Intl.DisplayNames(['en'], { type: 'language' }).of(languageCode),
|
||||
confidence: Number(row.language_confidence || 0)
|
||||
},
|
||||
name: row.display_name,
|
||||
variants: [],
|
||||
isFavorite: false,
|
||||
isHidden: true
|
||||
});
|
||||
}
|
||||
const group = groups.get(row.logical_channel_id);
|
||||
const group = groups.get(languageKey);
|
||||
group.variants.push(variant);
|
||||
group.isFavorite ||= variant.isFavorite;
|
||||
group.isHidden &&= variant.isHidden;
|
||||
if (row.id === row.preferred_variant_id) group.preferredVariant = variant;
|
||||
if (!group.preferredVariant || Number(variant.variantRank) < Number(group.preferredVariant.variantRank)) group.preferredVariant = variant;
|
||||
}
|
||||
return Array.from(groups.values()).map(group => {
|
||||
group.preferredVariant ||= group.variants[0];
|
||||
@ -401,6 +436,13 @@ function serializeChannels(rows, includeHidden = false) {
|
||||
group.selectedVariantId = group.preferredVariant
|
||||
? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}`
|
||||
: null;
|
||||
const epgChannelId = group.preferredVariant?.tvgId || group.preferredVariant?.epg_channel_id || null;
|
||||
group.epg = {
|
||||
sourceId: null,
|
||||
channelId: epgChannelId,
|
||||
language: group.language.code,
|
||||
matchConfidence: epgChannelId ? 100 : 0
|
||||
};
|
||||
return group;
|
||||
});
|
||||
}
|
||||
@ -414,6 +456,7 @@ module.exports = {
|
||||
identityCandidatePairs,
|
||||
normalizeChannelName,
|
||||
markProjectionBuilding,
|
||||
matchesSearch,
|
||||
projectCatalog,
|
||||
projectionState,
|
||||
qualityForName,
|
||||
|
||||
Reference in New Issue
Block a user