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:
blomios
2026-10-06 11:42:06 +02:00
parent a6759b116d
commit 2a1c745a26
11 changed files with 314 additions and 19 deletions

View 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 };