Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/examples/app.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
# - Custom port: base_url: "https://iptv.example.com:8443"
# - Subdirectory: base_url: "https://example.com/iptv"

# Optional: Trust forwarded client-IP, host, and protocol headers only from
# your reverse proxy. Leave empty for direct LAN access (the secure default).
# trusted_proxies:
# - "127.0.0.1"
# - "::1"
# - "192.168.1.10"

# ============================================================================
# Admin Authentication Configuration
# ============================================================================
Expand Down
10 changes: 9 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ const appConfig = loadConfig('app');

const config = { ...appConfig, host: 'localhost' };

app.set('trust proxy', true);
// Forwarded headers are trusted only when the direct peer is explicitly
// configured as a reverse proxy. A direct LAN client must not be able to
// forge X-Forwarded-For/Host/Proto to impersonate loopback or HTTPS.
app.set(
'trust proxy',
Array.isArray(appConfig.trusted_proxies) && appConfig.trusted_proxies.length
? appConfig.trusted_proxies
: false
);
app.use(express.json({ limit: '1mb' }));

// Session middleware — secret is read from config or generated and saved on first run
Expand Down
24 changes: 20 additions & 4 deletions libs/cache-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ class CacheManager {
* @param {number} ttl - Time to live in milliseconds (0 = no expiration)
* @returns {Cache} Cache instance
*/
createCache(name, ttl = 0) {
const cache = new Cache(name, ttl);
createCache(name, ttl = 0, maxEntries = Infinity) {
const cache = new Cache(name, ttl, maxEntries);
this.caches.set(name, cache);
return cache;
}
Expand Down Expand Up @@ -52,9 +52,10 @@ class CacheManager {
}

class Cache {
constructor(name, ttl = 0) {
constructor(name, ttl = 0, maxEntries = Infinity) {
this.name = name;
this.ttl = ttl; // TTL in milliseconds
this.maxEntries = maxEntries;
this.data = new Map();
this.timestamps = new Map();
this.hits = 0;
Expand All @@ -67,8 +68,18 @@ class Cache {
* @param {*} value - Value to cache
*/
set(key, value) {
// Map insertion order gives us a small, dependency-free LRU cache. Refresh
// an existing entry so frequently used variants are not evicted first.
if (this.data.has(key)) {
this.data.delete(key);
this.timestamps.delete(key);
}
this.data.set(key, value);
this.timestamps.set(key, Date.now());
while (this.data.size > this.maxEntries) {
const oldestKey = this.data.keys().next().value;
this.delete(oldestKey);
}
}

/**
Expand Down Expand Up @@ -96,7 +107,11 @@ class Cache {
}

this.hits++;
return this.data.get(key);
const value = this.data.get(key);
// Promote a hit to most recently used.
this.data.delete(key);
this.data.set(key, value);
return value;
}

/**
Expand Down Expand Up @@ -170,6 +185,7 @@ class Cache {
name: this.name,
size,
ttl: this.ttl,
maxEntries: this.maxEntries,
hits: this.hits,
misses: this.misses,
hitRate: `${hitRate}%`,
Expand Down
218 changes: 136 additions & 82 deletions libs/canonical-channel-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,29 +112,26 @@ export function rebuildCanonicalChannels() {
const mapping = loadChannelMapFromStore();
const reverseIndex = buildReverseIndex(mapping);
const db = getDatabase();
const existingCanonicalRows = db
.prepare(
`SELECT id, slug, name, custom_name, tvg_id, guide_number, published, created_at
FROM canonical_channels`
)
.all();
const existingCanonicalById = new Map(existingCanonicalRows.map(row => [row.id, row]));
const existingCanonicalByIdentity = new Map(
db
.prepare(
`SELECT id, slug, name, custom_name, tvg_id, guide_number, published, created_at
FROM canonical_channels`
)
.all()
.map(row => [
getCanonicalIdentity({
name: row.name,
tvg_id: row.tvg_id,
guideNumber: row.guide_number,
}),
row,
])
);
const existingCanonicalIdentityById = new Map(
Array.from(existingCanonicalByIdentity.entries()).map(([identity, row]) => [row.id, identity])
existingCanonicalRows.map(row => [
getCanonicalIdentity({
name: row.name,
tvg_id: row.tvg_id,
guideNumber: row.guide_number,
}),
row,
])
);
const existingBindingsByKey = new Map(
db
.prepare(
`SELECT
const existingBindingRows = db
.prepare(
`SELECT
cb.id,
cb.source_channel_id,
cb.canonical_channel_id,
Expand All @@ -144,10 +141,21 @@ export function rebuildCanonicalChannels() {
cb.confidence,
cb.resolution_state
FROM channel_bindings cb`
)
.all()
.map(row => [`${row.canonical_channel_id}|${row.source_channel_id}`, hydrateBindingRow(row)])
)
.all()
.map(hydrateBindingRow);
const existingBindingsByKey = new Map(
existingBindingRows.map(row => [`${row.canonical_channel_id}|${row.source_channel_id}`, row])
);
const existingCanonicalIdsBySourceChannelId = new Map();
for (const binding of existingBindingRows) {
if (!existingCanonicalIdsBySourceChannelId.has(binding.source_channel_id)) {
existingCanonicalIdsBySourceChannelId.set(binding.source_channel_id, []);
}
existingCanonicalIdsBySourceChannelId
.get(binding.source_channel_id)
.push(binding.canonical_channel_id);
}
const existingGuideBindingsByKey = new Map(
db
.prepare(
Expand Down Expand Up @@ -187,12 +195,7 @@ export function rebuildCanonicalChannels() {
.map(row => row.id)
);

const canonicalByIdentity = new Map();
const canonicalRows = [];
const bindingRows = [];
const guideBindingRows = [];
const seenGuideBindings = new Set();
const usedSlugs = new Set();
const canonicalGroupsByIdentity = new Map();

for (const sourceChannel of sourceChannels) {
const hydrated = hydrateSourceChannel(sourceChannel);
Expand All @@ -203,58 +206,110 @@ export function rebuildCanonicalChannels() {
continue;
}

let canonicalId = canonicalByIdentity.get(identity);
if (!canonicalId) {
const preserved = existingCanonicalByIdentity.get(identity);
canonicalId = preserved?.id || crypto.randomUUID();
const now = new Date().toISOString();
canonicalByIdentity.set(identity, canonicalId);
canonicalRows.push({
id: canonicalId,
slug: allocateUniqueSlug(
preserved?.slug || slugify(canonicalChannel.tvg_id || canonicalChannel.name),
usedSlugs,
canonicalRows.length + 1
),
name: canonicalChannel.name,
custom_name: preserved?.custom_name || null,
tvg_id: canonicalChannel.tvg_id || null,
guide_number: canonicalChannel.guideNumber || null,
logo: canonicalChannel.logo || null,
group_name: canonicalChannel.group || null,
published: preserved?.published ?? 1,
created_at: preserved?.created_at || now,
updated_at: now,
});
if (!canonicalGroupsByIdentity.has(identity)) {
canonicalGroupsByIdentity.set(identity, { identity, canonicalChannel, channels: [] });
}
canonicalGroupsByIdentity.get(identity).channels.push({ sourceChannel, hydrated, resolution });
}

// Source-channel IDs survive mapping-label and guide-number edits. Prefer an
// existing canonical ID associated with those stable IDs over the editable
// display identity. If mappings merge, the group with the largest overlap
// retains its ID (then identity order breaks ties); if they split, that same
// group retains the old settings and newly split groups start with defaults.
const canonicalGroups = Array.from(canonicalGroupsByIdentity.values()).map(group => {
const candidateCounts = new Map();
for (const { sourceChannel } of group.channels) {
for (const canonicalId of existingCanonicalIdsBySourceChannelId.get(sourceChannel.id) || []) {
candidateCounts.set(canonicalId, (candidateCounts.get(canonicalId) || 0) + 1);
}
}
return { ...group, candidateCounts };
});
canonicalGroups.sort((left, right) => {
const leftMax = Math.max(0, ...left.candidateCounts.values());
const rightMax = Math.max(0, ...right.candidateCounts.values());
return rightMax - leftMax || left.identity.localeCompare(right.identity);
});

const claimedCanonicalIds = new Set();
for (const group of canonicalGroups) {
const candidates = Array.from(group.candidateCounts.entries())
.filter(([canonicalId]) => !claimedCanonicalIds.has(canonicalId))
.sort(([leftId, leftCount], [rightId, rightCount]) => {
const left = existingCanonicalById.get(leftId);
const right = existingCanonicalById.get(rightId);
return (
rightCount - leftCount ||
String(left?.created_at || '').localeCompare(String(right?.created_at || '')) ||
leftId.localeCompare(rightId)
);
});
const identityMatch = existingCanonicalByIdentity.get(group.identity);
group.canonicalId =
candidates[0]?.[0] ||
(identityMatch && !claimedCanonicalIds.has(identityMatch.id) ? identityMatch.id : null) ||
crypto.randomUUID();
claimedCanonicalIds.add(group.canonicalId);
}

const existingBinding = existingBindingsByKey.get(`${canonicalId}|${sourceChannel.id}`);
bindingRows.push({
id: existingBinding?.id || crypto.randomUUID(),
source_channel_id: sourceChannel.id,
canonical_channel_id: canonicalId,
binding_type:
existingBinding?.binding_type || (resolution.matched ? 'mapping' : 'source'),
priority: existingBinding?.priority || 0,
is_preferred_stream: existingBinding?.is_preferred_stream || 0,
confidence: existingBinding?.confidence ?? 1,
resolution_state:
existingBinding?.resolution_state || (resolution.matched ? 'resolved' : 'discovered'),
const canonicalRows = [];
const bindingRows = [];
const guideBindingRows = [];
const seenGuideBindings = new Set();
const usedSlugs = new Set();

for (const group of canonicalGroups) {
const { canonicalChannel, canonicalId } = group;
const preserved = existingCanonicalById.get(canonicalId);
const now = new Date().toISOString();
canonicalRows.push({
id: canonicalId,
slug: allocateUniqueSlug(
preserved?.slug || slugify(canonicalChannel.tvg_id || canonicalChannel.name),
usedSlugs,
canonicalRows.length + 1
),
name: canonicalChannel.name,
custom_name: preserved?.custom_name || null,
tvg_id: canonicalChannel.tvg_id || null,
guide_number: canonicalChannel.guideNumber || null,
logo: canonicalChannel.logo || null,
group_name: canonicalChannel.group || null,
published: preserved?.published ?? 1,
created_at: preserved?.created_at || now,
updated_at: now,
});

const defaultGuideBindingId = pickGuideBindingId(canonicalChannel, hydrated);
if (epgEnabledSourceIds.has(sourceChannel.source_id) && defaultGuideBindingId) {
const guideKey = `${canonicalId}|${sourceChannel.source_id}`;
if (!seenGuideBindings.has(guideKey)) {
seenGuideBindings.add(guideKey);
const preservedGuideBinding = existingGuideBindingsByKey.get(guideKey);
guideBindingRows.push({
id: preservedGuideBinding?.id || crypto.randomUUID(),
canonical_channel_id: canonicalId,
source_id: sourceChannel.source_id,
epg_channel_id: preservedGuideBinding?.epg_channel_id || defaultGuideBindingId,
priority: preservedGuideBinding?.priority ?? 1,
});
for (const { sourceChannel, hydrated, resolution } of group.channels) {
const existingBinding = existingBindingsByKey.get(`${canonicalId}|${sourceChannel.id}`);
bindingRows.push({
id: existingBinding?.id || crypto.randomUUID(),
source_channel_id: sourceChannel.id,
canonical_channel_id: canonicalId,
binding_type:
existingBinding?.binding_type || (resolution.matched ? 'mapping' : 'source'),
priority: existingBinding?.priority || 0,
is_preferred_stream: existingBinding?.is_preferred_stream || 0,
confidence: existingBinding?.confidence ?? 1,
resolution_state:
existingBinding?.resolution_state || (resolution.matched ? 'resolved' : 'discovered'),
});

const defaultGuideBindingId = pickGuideBindingId(canonicalChannel, hydrated);
if (epgEnabledSourceIds.has(sourceChannel.source_id) && defaultGuideBindingId) {
const guideKey = `${canonicalId}|${sourceChannel.source_id}`;
if (!seenGuideBindings.has(guideKey)) {
seenGuideBindings.add(guideKey);
const preservedGuideBinding = existingGuideBindingsByKey.get(guideKey);
guideBindingRows.push({
id: preservedGuideBinding?.id || crypto.randomUUID(),
canonical_channel_id: canonicalId,
source_id: sourceChannel.source_id,
epg_channel_id: preservedGuideBinding?.epg_channel_id || defaultGuideBindingId,
priority: preservedGuideBinding?.priority ?? 1,
});
}
}
}
}
Expand Down Expand Up @@ -352,17 +407,16 @@ export function rebuildCanonicalChannels() {
);
}

const retainedCanonicalIds = new Set(canonicalRows.map(row => row.id));
for (const row of existingOutputProfileRows) {
const identity = existingCanonicalIdentityById.get(row.canonical_channel_id);
const nextCanonicalId = identity ? canonicalByIdentity.get(identity) : null;
if (!nextCanonicalId) {
if (!retainedCanonicalIds.has(row.canonical_channel_id)) {
continue;
}

insertOutputProfileChannel.run(
row.id,
row.output_profile_id,
nextCanonicalId,
row.canonical_channel_id,
row.position,
row.guide_number_override,
row.enabled
Expand Down
Loading
Loading