feat(xtream): add catch-up playback to favorites and recent tabs (#1166)

Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays.

Closes #1138.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
StefanandClaude authored and GitHub committed 2026-07-19 06:38:19 +02:00
1 parent c266eaa680
commit 2f8aee72df
25 files changed
+459 -48

No files matched your search

@@ -43,7 +43,7 @@ function createEpgDatabaseMock() {
}
describe('EpgDatabase', () => {
it('refreshes a source without cascading shared channel programs from other sources', () => {
it('refreshes a source selectively, keeping the recent past-programme archive', () => {
const sourceUrl = 'https://example.com/playlist-guide.xml';
const { Database, database, statements } = createEpgDatabaseMock();
const epgDb = new EpgDatabase(Database);
@@ -62,8 +62,11 @@ describe('EpgDatabase', () => {
const preparedSql = database.prepare.mock.calls.map(([sql]) =>
normalizeSql(sql)
);
const deleteProgramsSql =
'DELETE FROM epg_programs WHERE source_url = ?';
const deleteTodayAndFutureSql = normalizeSql(`
DELETE FROM epg_programs
WHERE source_url = ?
AND (start >= date('now') OR start < date('now', '-7 days'))
`);
const deleteOrphanChannelsSql = normalizeSql(`
DELETE FROM epg_channels
WHERE source_url = ?
@@ -74,12 +77,18 @@ describe('EpgDatabase', () => {
)
`);
expect(preparedSql).toContain(deleteProgramsSql);
expect(preparedSql).toContain(deleteTodayAndFutureSql);
expect(preparedSql).toContain(deleteOrphanChannelsSql);
// A refresh must not wipe the whole source: the last 7 days of
// programmes stay behind so catch-up remains browsable (#1138),
// and other sources' shared channels must not cascade away.
expect(preparedSql).not.toContain(
'DELETE FROM epg_programs WHERE source_url = ?'
);
expect(preparedSql).not.toContain(
'DELETE FROM epg_channels WHERE source_url = ?'
);
expect(statements.get(deleteProgramsSql)).toHaveBeenCalledWith(
expect(statements.get(deleteTodayAndFutureSql)).toHaveBeenCalledWith(
sourceUrl
);
expect(statements.get(deleteOrphanChannelsSql)).toHaveBeenCalledWith(
@@ -11,8 +11,8 @@ export class EpgDatabase {
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
@@ -35,10 +35,6 @@ export class EpgDatabase {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
@@ -48,21 +44,28 @@ export class EpgDatabase {
WHERE epg_programs.channel_id = epg_channels.id
)
`);
this.deleteTodayAndFutureStmt = this.db.prepare(`
DELETE FROM epg_programs
WHERE source_url = ?
AND (start >= date('now') OR start < date('now', '-7 days'))
`);
}
/**
* Insert a batch of channels. When `clearFirst` is true, the existing rows
* for `sourceUrl` are deleted inside the same transaction as the insert so
* old data is preserved if the fetch/parse never produces any channels.
* Insert a batch of channels. When `clearTodayAndFuture` is true, the
* selective delete (today+future and older-than-7-days) runs inside the
* same transaction so a parse failure after the delete atomically rolls
* back both — no gap left in the schedule.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearFirst = false
clearTodayAndFuture = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearFirst) {
this.deleteProgramsForSourceStmt.run(sourceUrl);
if (clearTodayAndFuture) {
this.deleteTodayAndFutureStmt.run(sourceUrl);
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
this.knownChannelIds.clear();
}