Preserve the guide until a complete EPG refresh can activate
Adapted from MikaelKW/nodecast-tv-plus commits c267d98d734753a09b08d267c8ec2c3fa11c33f5, b761690bac7626a3cc5ddf754d96b14a02f506fe, and 5524fdaffed5c6a44ae69474e7eaa2f18d428c75. Preserve every parser batch, decode gzip once, and retain live channel metadata. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -3,9 +3,12 @@ const { sources, settings } = require('../db'); // For source config and setting
|
||||
const xtreamApi = require('./xtreamApi');
|
||||
const m3uParser = require('./m3uParser');
|
||||
const epgParser = require('./epgParser');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
|
||||
// Sync tracking
|
||||
const activeSyncs = new Set(); // sourceId
|
||||
const activeEpgSyncs = new Set();
|
||||
const epgSourceGenerations = new Map();
|
||||
|
||||
class SyncService {
|
||||
constructor() {
|
||||
@ -14,6 +17,11 @@ class SyncService {
|
||||
this._currentInterval = null;
|
||||
}
|
||||
|
||||
invalidateEpgSource(sourceId) {
|
||||
const key = String(sourceId);
|
||||
epgSourceGenerations.set(key, (epgSourceGenerations.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get when the last global sync completed
|
||||
*/
|
||||
@ -389,107 +397,216 @@ class SyncService {
|
||||
* Processes EPG files in batches to avoid OOM on large EPG data
|
||||
*/
|
||||
async syncEpgFromUrl(sourceId, url) {
|
||||
console.log(`[Sync] Fetching EPG from: ${url.substring(0, 60)}...`);
|
||||
console.log(`[Sync] Fetching EPG from: ${new URL(url).origin}`);
|
||||
|
||||
// Temporary memory logging for verification
|
||||
const logMemory = () => {
|
||||
const used = process.memoryUsage();
|
||||
console.log(`[Sync] Memory: ${Math.round(used.heapUsed / 1024 / 1024)}MB heap`);
|
||||
};
|
||||
|
||||
logMemory();
|
||||
|
||||
const db = getDb();
|
||||
let allChannels = [];
|
||||
let totalProgrammes = 0;
|
||||
let batchCount = 0;
|
||||
|
||||
// Clear old programmes first
|
||||
db.prepare('DELETE FROM epg_programs WHERE source_id = ?').run(sourceId);
|
||||
|
||||
const programmeStmt = db.prepare(`
|
||||
INSERT INTO epg_programs (channel_id, source_id, start_time, end_time, title, description, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertProgrammes = db.transaction((progs) => {
|
||||
for (const p of progs) {
|
||||
programmeStmt.run(
|
||||
p.channelId,
|
||||
sourceId,
|
||||
p.start ? p.start.getTime() : 0,
|
||||
p.stop ? p.stop.getTime() : 0,
|
||||
p.title,
|
||||
p.description || p.desc,
|
||||
JSON.stringify(p)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Stream and process in batches (default 1000 programmes per batch)
|
||||
for await (const batch of epgParser.fetchAndParseStreaming(url)) {
|
||||
batchCount++;
|
||||
|
||||
// Collect channels from first batch
|
||||
if (batch.channels) {
|
||||
allChannels = batch.channels;
|
||||
}
|
||||
|
||||
// Save this batch of programmes immediately
|
||||
if (batch.programmes.length > 0) {
|
||||
insertProgrammes(batch.programmes);
|
||||
totalProgrammes += batch.programmes.length;
|
||||
}
|
||||
|
||||
// Log progress every 10 batches
|
||||
if (batchCount % 10 === 0) {
|
||||
console.log(`[Sync] Processed ${totalProgrammes} programmes so far...`);
|
||||
logMemory();
|
||||
}
|
||||
|
||||
// Yield to event loop
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const sourceKey = String(sourceId);
|
||||
if (activeEpgSyncs.has(sourceKey)) {
|
||||
throw new Error(`EPG refresh already in progress for source ${sourceId}`);
|
||||
}
|
||||
activeEpgSyncs.add(sourceKey);
|
||||
const sourceGeneration = epgSourceGenerations.get(sourceKey) || 0;
|
||||
let cleanupRun = null;
|
||||
let refreshError = null;
|
||||
|
||||
console.log(`[Sync] EPG Parsed: ${allChannels.length} channels, ${totalProgrammes} programmes`);
|
||||
logMemory();
|
||||
try {
|
||||
|
||||
// Save EPG Channels
|
||||
if (allChannels.length > 0) {
|
||||
// Temporary memory logging for verification
|
||||
const logMemory = () => {
|
||||
const used = process.memoryUsage();
|
||||
console.log(`[Sync] Memory: ${Math.round(used.heapUsed / 1024 / 1024)}MB heap`);
|
||||
};
|
||||
|
||||
logMemory();
|
||||
|
||||
const db = getDb();
|
||||
const runId = randomUUID();
|
||||
let totalChannels = 0;
|
||||
let totalProgrammes = 0;
|
||||
let skippedProgrammes = 0;
|
||||
let batchCount = 0;
|
||||
let sawFinalBatch = false;
|
||||
|
||||
db.exec(`
|
||||
CREATE TEMP TABLE IF NOT EXISTS epg_programs_staging (
|
||||
run_id TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
data JSON
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS temp.idx_epg_programs_staging_run
|
||||
ON epg_programs_staging(run_id, source_id);
|
||||
|
||||
CREATE TEMP TABLE IF NOT EXISTS epg_channels_staging (
|
||||
run_id TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
stream_icon TEXT,
|
||||
data JSON,
|
||||
PRIMARY KEY (run_id, id)
|
||||
);
|
||||
`);
|
||||
|
||||
const deleteStagedProgrammes = db.prepare(
|
||||
'DELETE FROM temp.epg_programs_staging WHERE run_id = ?'
|
||||
);
|
||||
const deleteStagedChannels = db.prepare(
|
||||
'DELETE FROM temp.epg_channels_staging WHERE run_id = ?'
|
||||
);
|
||||
cleanupRun = () => {
|
||||
deleteStagedProgrammes.run(runId);
|
||||
deleteStagedChannels.run(runId);
|
||||
};
|
||||
|
||||
// A prior interrupted attempt in this process must never be reused.
|
||||
db.prepare('DELETE FROM temp.epg_programs_staging WHERE source_id = ?').run(sourceId);
|
||||
db.prepare('DELETE FROM temp.epg_channels_staging WHERE source_id = ?').run(sourceId);
|
||||
|
||||
const programmeStmt = db.prepare(`
|
||||
INSERT INTO temp.epg_programs_staging (
|
||||
run_id, source_id, channel_id, start_time, end_time, title, description, data
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const channelStmt = db.prepare(`
|
||||
INSERT INTO playlist_items (
|
||||
id, source_id, item_id, type, name, stream_icon,
|
||||
stream_url, category_id, data
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
INSERT INTO temp.epg_channels_staging (
|
||||
run_id, source_id, id, item_id, name, stream_icon, data
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id, id) DO UPDATE SET
|
||||
item_id = excluded.item_id,
|
||||
name = excluded.name,
|
||||
stream_icon = excluded.stream_icon,
|
||||
data = excluded.data
|
||||
`);
|
||||
|
||||
const insertChannels = db.transaction((chanList) => {
|
||||
for (const ch of chanList) {
|
||||
const id = `${sourceId}:${ch.id}`;
|
||||
channelStmt.run(
|
||||
id,
|
||||
const stageProgrammes = db.transaction((programmes) => {
|
||||
for (const programme of programmes) {
|
||||
programmeStmt.run(
|
||||
runId,
|
||||
sourceId,
|
||||
ch.id,
|
||||
'epg_channel',
|
||||
ch.name,
|
||||
ch.icon || null,
|
||||
null,
|
||||
null,
|
||||
JSON.stringify(ch)
|
||||
programme.channelId,
|
||||
programme.start ? programme.start.getTime() : 0,
|
||||
programme.stop ? programme.stop.getTime() : 0,
|
||||
programme.title,
|
||||
programme.description || programme.desc,
|
||||
JSON.stringify(programme)
|
||||
);
|
||||
}
|
||||
});
|
||||
const stageChannels = db.transaction((channels) => {
|
||||
for (const channel of channels) {
|
||||
// EPG channel IDs often overlap Xtream stream IDs. Keep the
|
||||
// staged row in the same dedicated namespace as the active row.
|
||||
const id = `${sourceId}:epg_channel:${channel.id}`;
|
||||
channelStmt.run(
|
||||
runId,
|
||||
sourceId,
|
||||
id,
|
||||
channel.id,
|
||||
channel.name,
|
||||
channel.icon || null,
|
||||
JSON.stringify(channel)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
insertChannels(allChannels);
|
||||
console.log(`[Sync] Saved ${allChannels.length} EPG channels`);
|
||||
}
|
||||
const activateStagedGuide = db.transaction(() => {
|
||||
db.prepare('DELETE FROM epg_programs WHERE source_id = ?').run(sourceId);
|
||||
db.prepare(`
|
||||
INSERT INTO epg_programs (
|
||||
channel_id, source_id, start_time, end_time, title, description, data
|
||||
)
|
||||
SELECT channel_id, source_id, start_time, end_time, title, description, data
|
||||
FROM temp.epg_programs_staging
|
||||
WHERE run_id = ? AND source_id = ?
|
||||
`).run(runId, sourceId);
|
||||
|
||||
console.log(`[Sync] Saved ${totalProgrammes} programmes`);
|
||||
db.prepare(`
|
||||
DELETE FROM playlist_items
|
||||
WHERE source_id = ? AND type = 'epg_channel'
|
||||
`).run(sourceId);
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items (
|
||||
id, source_id, item_id, type, name, stream_icon,
|
||||
stream_url, category_id, data
|
||||
)
|
||||
SELECT id, source_id, item_id, 'epg_channel', name, stream_icon,
|
||||
NULL, NULL, data
|
||||
FROM temp.epg_channels_staging
|
||||
WHERE run_id = ? AND source_id = ?
|
||||
`).run(runId, sourceId);
|
||||
|
||||
cleanupRun();
|
||||
});
|
||||
|
||||
const sourceAtStart = await sources.getById(sourceId);
|
||||
|
||||
// Stream into isolated staging tables. The active guide remains
|
||||
// readable throughout download and parsing.
|
||||
for await (const batch of epgParser.fetchAndParseStreaming(url)) {
|
||||
batchCount++;
|
||||
skippedProgrammes += batch.skippedProgrammes || 0;
|
||||
|
||||
if (batch.channels !== null && batch.channels !== undefined) {
|
||||
stageChannels(batch.channels);
|
||||
totalChannels += batch.channels.length;
|
||||
}
|
||||
|
||||
if (batch.programmes.length > 0) {
|
||||
stageProgrammes(batch.programmes);
|
||||
totalProgrammes += batch.programmes.length;
|
||||
}
|
||||
|
||||
if (batch.isLast) sawFinalBatch = true;
|
||||
|
||||
if (batchCount % 10 === 0) {
|
||||
console.log(`[Sync] Staged ${totalProgrammes} programmes so far...`);
|
||||
logMemory();
|
||||
}
|
||||
|
||||
// Keep requests responsive between SQLite batches.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
if (!sawFinalBatch) {
|
||||
throw new Error('EPG refresh ended before the XMLTV document completed');
|
||||
}
|
||||
|
||||
// If a real configured source disappeared while its download was
|
||||
// running, do not recreate any of its guide data.
|
||||
const sourceStillExists = !sourceAtStart || await sources.getById(sourceId);
|
||||
const sourceWasInvalidated = (epgSourceGenerations.get(sourceKey) || 0) !== sourceGeneration;
|
||||
if (sourceWasInvalidated || !sourceStillExists) {
|
||||
throw new Error(`EPG source ${sourceId} was removed during refresh`);
|
||||
}
|
||||
|
||||
console.log(`[Sync] EPG Parsed: ${totalChannels} channels, ${totalProgrammes} programmes`);
|
||||
if (skippedProgrammes > 0) {
|
||||
console.warn(`[Sync] Skipped ${skippedProgrammes} programme entries with invalid XMLTV timestamps`);
|
||||
}
|
||||
logMemory();
|
||||
|
||||
// One short transaction makes programmes and channel mapping
|
||||
// visible together. A valid empty feed intentionally clears both.
|
||||
activateStagedGuide();
|
||||
console.log(`[Sync] Activated ${totalChannels} EPG channels and ${totalProgrammes} programmes`);
|
||||
} catch (error) {
|
||||
refreshError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
if (cleanupRun) cleanupRun();
|
||||
} catch (cleanupError) {
|
||||
console.warn('[Sync] Failed to clean EPG staging data:', cleanupError.message);
|
||||
if (!refreshError) throw cleanupError;
|
||||
} finally {
|
||||
activeEpgSyncs.delete(sourceKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user