mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
fix(epg): preserve metadata writer order independently of timestamps
This commit is contained in:
1 parent
2e8441aceb
commit
bc0559900f
9 files changed
+101
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] }),
|
||||
|
||||
Reference in new issue
Block a user