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:
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 };
|
||||
Reference in New Issue
Block a user