diff --git a/AGENTS.md b/AGENTS.md index f5824dff2..e0f6c1be3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7ca7bbf56..3dfcdc5b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index a5cf6930a..9b96075a1 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -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'); diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index c87b2a089..213aa389f 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -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); } diff --git a/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts b/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts index bd5f8f102..bce4a0eea 100644 --- a/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-source-metadata.spec.ts @@ -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(` diff --git a/apps/electron-backend/src/app/workers/epg-source-metadata.ts b/apps/electron-backend/src/app/workers/epg-source-metadata.ts index c4b803bd9..fdf078ab9 100644 --- a/apps/electron-backend/src/app/workers/epg-source-metadata.ts +++ b/apps/electron-backend/src/app/workers/epg-source-metadata.ts @@ -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( ` diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 197f3a6ae..47af52777 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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. diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index a6b9ef017..6e8e72488 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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 diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index ae9af0ee6..242ccd843 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -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] }),