fix(epg): preserve metadata writer order independently of timestamps

This commit is contained in:
4gray committed 2026-09-06 00:56:04 +02:00
1 parent 2e8441aceb
commit bc0559900f
9 files changed
+101 -12

No files matched your search

+2 -1
View File
@@ -188,7 +188,8 @@ before worker-owned deletion. Retry waits for reconciliation and rechecks its
error row, including after trust-setting writes. Shared channel IDs survive while
another source has programmes or per-source channel metadata. The additive
`epg_channel_sources` table preserves each imported source's name, logo, URL and
timestamp so removal restores a surviving snapshot; ambiguous legacy metadata
timestamp plus transaction-ordered `write_order`, so removal restores the latest
surviving snapshot even when import timestamps tie; ambiguous legacy metadata
falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
invalidation prevent late results from restoring removed programmes. Provider
+2 -1
View File
@@ -1673,7 +1673,8 @@ before worker-owned deletion. Retry waits for reconciliation and rechecks its
error row, including after trust-setting writes. Shared channel IDs survive while
another source has programmes or per-source channel metadata. The additive
`epg_channel_sources` table preserves each imported source's name, logo, URL and
timestamp so removal restores a surviving snapshot; ambiguous legacy metadata
timestamp plus transaction-ordered `write_order`, so removal restores the latest
surviving snapshot even when import timestamps tie; ambiguous legacy metadata
falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
invalidation prevent late results from restoring removed programmes. Provider
+7 -4
View File
@@ -189,10 +189,12 @@ test.describe('Electron EPG', () => {
process.stdout?.on('data', capture);
process.stderr?.on('data', capture);
try {
await app.mainWindow.evaluate(async (url) => {
await window.electron.forceFetchEpg(url);
const imported = await app.mainWindow.evaluate(async (url) => {
const result = await window.electron.forceFetchEpg(url);
await window.electron.clearEpgDataForSource(url);
return result;
}, sourceUrl);
expect(imported.success).toBe(true);
await expect
.poll(() => diagnostics)
.toContain('EPG data cleared for source via worker');
@@ -205,8 +207,9 @@ test.describe('Electron EPG', () => {
await expect
.poll(() => diagnostics)
.toContain('Request failed with status code 404');
expect(diagnostics).toContain('[EPG Worker]');
expect(diagnostics).toContain('Parsing complete:');
// Worker stdout can lose its final buffered line on termination;
// the awaited import result and main-process completion are authoritative.
expect(diagnostics).toContain('[EPG Worker] Fetching EPG source');
expect(diagnostics).toContain('EPG parsing complete:');
expect(diagnostics).not.toContain('epg-path-secret');
expect(diagnostics).not.toContain('epg-query-secret');
@@ -33,15 +33,20 @@ export class EpgDatabase {
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
// Allocated under the same SQLite write transaction as the channel
// upsert: ties and clock rollbacks cannot reorder surviving writers.
this.insertChannelSourceStmt = this.db.prepare(`
INSERT INTO epg_channel_sources
(channel_id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
(channel_id, display_name, icon_url, url, source_url, updated_at, write_order)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
(SELECT COALESCE(MAX(write_order), 0) + 1
FROM epg_channel_sources WHERE channel_id = ?))
ON CONFLICT(channel_id, source_url) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
updated_at = excluded.updated_at
updated_at = excluded.updated_at,
write_order = excluded.write_order
`);
this.deleteChannelSourcesStmt = this.db.prepare(
'DELETE FROM epg_channel_sources WHERE source_url = ?'
@@ -134,7 +139,8 @@ export class EpgDatabase {
displayName,
iconUrl,
url,
sourceUrl
sourceUrl,
channel.id
);
this.knownChannelIds.add(channel.id);
}
@@ -82,6 +82,51 @@ describe('source-owned XMLTV channel metadata', () => {
}
);
it.each(['same tick', 'clock rollback'])(
'restores the latest surviving write across %s and source upserts',
(clockMode) => {
expect(
runDatabaseScenario(`
let timestamp = '2026-09-06T10:00:00.000Z';
sqlite.function('strftime', { varargs: true }, () => timestamp);
importSource('A');
if ('${clockMode}' === 'clock rollback') timestamp = '2026-09-06T09:00:00.000Z';
importSource('Z');
importSource('M');
clear.run('M');
const first = channel();
importSource('A');
importSource('M');
clear.run('M');
const updated = channel();
importSource('Z', true, true);
importSource('M');
clear.run('M');
process.stdout.write(JSON.stringify({first, updated, refreshed: channel()}));
`)
).toEqual({
first: expect.objectContaining({
display_name: 'Z News',
icon_url: 'Z.png',
url: 'Z.website',
source_url: 'Z',
}),
updated: expect.objectContaining({
display_name: 'A News',
icon_url: 'A.png',
url: 'A.website',
source_url: 'A',
}),
refreshed: expect.objectContaining({
display_name: 'Z News',
icon_url: 'Z.png',
url: 'Z.website',
source_url: 'Z',
}),
});
}
);
it('retains metadata-only owners during another source refresh and removal', () => {
expect(
runDatabaseScenario(`
@@ -101,6 +146,31 @@ describe('source-owned XMLTV channel metadata', () => {
});
});
it('upgrades an existing ledger without losing snapshots and orders subsequent imports', () => {
expect(
runDatabaseScenario(`
importSource('A'); importSource('Z');
sqlite.exec('ALTER TABLE epg_channel_sources DROP COLUMN write_order');
const migration = __databaseConnectionTestHooks.columnMigrationStatements
.find(sql => sql.startsWith('ALTER TABLE epg_channel_sources'));
sqlite.exec(migration);
const snapshots = sqlite.prepare('SELECT source_url, write_order FROM epg_channel_sources ORDER BY source_url').all();
importSource('Z'); importSource('M');
clear.run('M');
process.stdout.write(JSON.stringify({ snapshots, surviving: channel() }));
`)
).toEqual({
snapshots: [
{ source_url: 'A', write_order: 0 },
{ source_url: 'Z', write_order: 0 },
],
surviving: expect.objectContaining({
display_name: 'Z News',
source_url: 'Z',
}),
});
});
it('removes an orphan whose original owner disappeared during refresh', () => {
expect(
runDatabaseScenario(`
@@ -10,7 +10,7 @@ export function restoreSurvivingChannelMetadata(
const survivingValue = (column: string) => `(
SELECT ${column} FROM epg_channel_sources
WHERE channel_id = epg_channels.id AND source_url != @sourceUrl
ORDER BY updated_at DESC, source_url LIMIT 1
ORDER BY write_order DESC, updated_at DESC, source_url LIMIT 1
)`;
db.prepare(
`
+5 -1
View File
@@ -1361,7 +1361,11 @@ reconciliation and rechecks the same error row; trust-setting continuations and
old dismissal timers cannot affect a removed or replacement row. Progress rows disappear without reporting routine worker termination as an import failure. Programmes are deleted by source; a globally keyed
channel is retained while another source still has programmes or channel metadata.
The additive `epg_channel_sources` table is created during database initialization
and records each imported source's name, logo, URL and timestamp. Before removing
and records each imported source's name, logo, URL and timestamp. A per-channel
`write_order` advances inside the import transaction (including upserts and
refreshes), so restoration picks the latest surviving writer even when timestamps
collide or the clock moves backwards. Freshness still uses wall-clock timestamps.
Existing ledgers gain the column with zero for unknowable historical order. Before removing
source provenance, cleanup restores affected global channels from a surviving
snapshot, including when the legacy channel owner differs from the removed
metadata writer. Metadata-only owners survive another source's refresh or removal.
@@ -295,6 +295,7 @@ const CREATE_TABLE_STATEMENTS = [
icon_url TEXT,
url TEXT,
updated_at TEXT DEFAULT (datetime('now')),
write_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (channel_id, source_url),
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
)`,
@@ -431,6 +432,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN original_title TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
// Preserve writer order independently of wall-clock precision or changes.
`ALTER TABLE epg_channel_sources ADD COLUMN write_order INTEGER NOT NULL DEFAULT 0`,
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
// Offline details: provider-neutral display metadata captured at download time
+1
View File
@@ -217,6 +217,7 @@ export const epgChannelSources = sqliteTable(
iconUrl: text('icon_url'),
url: text('url'),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
writeOrder: integer('write_order').notNull().default(0),
},
(table) => [
primaryKey({ columns: [table.channelId, table.sourceUrl] }),