From 2f8aee72df28faa55815338b54b73b4e5ed06098 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 19 Jul 2026 06:38:19 +0200 Subject: [PATCH] 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 --- .../operations/favorites.operations.spec.ts | 35 +++++ .../operations/favorites.operations.ts | 4 + .../recently-viewed.operations.spec.ts | 18 ++- .../operations/recently-viewed.operations.ts | 4 + .../src/app/workers/epg-database.spec.ts | 19 ++- .../src/app/workers/epg-database.ts | 25 ++-- docs/architecture/m3u-playlist-module.md | 3 +- .../xtream-portal-compatibility.md | 9 ++ .../lib/collection/stream-resolver.service.ts | 37 ++++- .../unified-favorites-data.service.spec.ts | 6 + .../unified-favorites-data.service.ts | 4 + .../unified-recent-data.service.spec.ts | 34 ++++- .../collection/unified-recent-data.service.ts | 6 + .../unified-live-epg-summary.util.ts | 16 +++ .../unified-live-tab.component.spec.ts | 104 ++++++++++++++ .../unified-live-tab.component.ts | 129 +++++++++++++++--- .../src/lib/collection/collection-helpers.ts | 2 + .../unified-collection-item.interface.ts | 6 + .../interfaces/src/lib/playlist.interface.ts | 2 + .../lib/epg-timeline/epg-archive.util.spec.ts | 15 +- .../src/lib/epg-timeline/epg-archive.util.ts | 7 +- .../epg-timeline/epg-timeline-render.util.ts | 2 +- .../epg-timeline-track.component.html | 9 ++ .../epg-timeline-track.component.scss | 3 + .../epg-timeline/epg-timeline.component.ts | 8 +- 25 files changed, 459 insertions(+), 48 deletions(-) diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts index 90b65e4de..81a413a59 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts @@ -25,9 +25,12 @@ jest.mock('drizzle-orm', () => ({ ), })); +import * as schema from '@iptvnator/shared/database/schema'; import type { AppDatabase } from '../database.types'; import { createDbMock } from './operations.test-helpers'; import { + getAllGlobalFavorites, + getFavorites, getGlobalFavorites, reorderGlobalFavorites, } from './favorites.operations'; @@ -93,6 +96,38 @@ describe('favorites.operations', () => { ]); }); + // Regression for issue #1138: archive metadata must survive every + // favorites projection so catch-up stays available outside Live TV. + it('selects archive metadata for playlist favorites', async () => { + const { db, select } = createGlobalFavoritesDbMock([]); + + await getFavorites(db, 'playlist-1'); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); + }); + + it('selects archive metadata for global favorites', async () => { + const { db, select } = createGlobalFavoritesDbMock([]); + + await getGlobalFavorites(db); + await getAllGlobalFavorites(db); + + expect(select).toHaveBeenCalledTimes(2); + for (const [projection] of select.mock.calls) { + expect(projection).toEqual( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); + } + }); + describe('reorderGlobalFavorites', () => { it('short-circuits without touching the db when there are no updates', async () => { const { db, update, transaction } = createDbMock(); diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index 8bb66e98d..251747d5b 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -73,6 +73,8 @@ export async function getFavorites(db: AppDatabase, playlistId: string) { poster_url: schema.content.posterUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, added_at: schema.favorites.addedAt, position: schema.favorites.position, }) @@ -117,6 +119,8 @@ function selectGlobalFavoriteRows( : {}), xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, playlist_id: schema.playlists.id, playlist_name: schema.playlists.name, added_at: schema.favorites.addedAt, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts index 4dce217f1..f959cb911 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -35,7 +35,7 @@ describe('recently-viewed.operations', () => { { id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' }, { id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' }, ]; - const { db, queries } = createDbMock([rows]); + const { db, queries, select } = createDbMock([rows]); await expect(getRecentlyViewed(db)).resolves.toEqual(rows); @@ -47,11 +47,19 @@ describe('recently-viewed.operations', () => { ); expect(queries[0].limit).toHaveBeenCalledWith(100); expect(queries[0].innerJoin).toHaveBeenCalledTimes(3); + // Regression for issue #1138: archive metadata must survive the + // recent projections so catch-up stays available outside Live TV. + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); }); it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => { const rows = [{ id: 5, title: 'Recent Movie' }]; - const { db, queries } = createDbMock([rows]); + const { db, queries, select } = createDbMock([rows]); await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual( rows @@ -65,6 +73,12 @@ describe('recently-viewed.operations', () => { schema.recentlyViewed.viewedAt ); expect(queries[0].limit).toHaveBeenCalledWith(100); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + }) + ); }); }); diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index fee6df185..7a2c06657 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -15,6 +15,8 @@ export async function getRecentlyViewed(db: AppDatabase) { backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, playlist_id: schema.categories.playlistId, playlist_name: schema.playlists.name, viewed_at: schema.recentlyViewed.viewedAt, @@ -58,6 +60,8 @@ export async function getRecentItems( backdrop_url: schema.content.backdropUrl, xtream_id: schema.content.xtreamId, type: schema.content.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, viewed_at: schema.recentlyViewed.viewedAt, }) .from(schema.recentlyViewed) diff --git a/apps/electron-backend/src/app/workers/epg-database.spec.ts b/apps/electron-backend/src/app/workers/epg-database.spec.ts index d448e47f7..2ec863a8b 100644 --- a/apps/electron-backend/src/app/workers/epg-database.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-database.spec.ts @@ -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( diff --git a/apps/electron-backend/src/app/workers/epg-database.ts b/apps/electron-backend/src/app/workers/epg-database.ts index 6123619c0..e58d3db98 100644 --- a/apps/electron-backend/src/app/workers/epg-database.ts +++ b/apps/electron-backend/src/app/workers/epg-database.ts @@ -11,8 +11,8 @@ export class EpgDatabase { private readonly knownChannelIds = new Set(); 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(); } diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 54dbaeb90..9ba3d33e6 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -363,7 +363,8 @@ activation, and the details dialog behave identically to the timeline. no-EPG-anywhere states and while loading. Return-to-live is a playback control (`!isLivePlayback()`) and is independent of EPG state. - **State-driven affordances.** Blocks are coloured past / now / future, with a - red "now" playhead. Catch-up "Watch" appears on past blocks only when + red "now" playhead. Catch-up "Watch" appears on past blocks — and as a + start-over replay button on the currently-airing block — only when `archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is schedule-only (dimmed past + a notice, no false buttons). The "i" button opens the shared `app-epg-item-description` dialog with a state-aware action. diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 4572bc1cb..1f90dbc2b 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -111,3 +111,12 @@ playable. MPEG-TS is preferred before HLS when the provider allows it because some portals return a valid HLS manifest while the first media segment fails in Chromium/video.js. PWA fallback keeps the REST MPEG-TS URL when no Electron probe API is available. + +Catch-up is offered from the Xtream Live TV tab and from the unified +collection surfaces (per-playlist and global Favorites and Recent). The +`tv_archive` / `tv_archive_duration` columns are carried through the +favorites and recently-viewed DB projections and mapped onto +`UnifiedCollectionItem.tvArchive` / `tvArchiveDuration` so the shared live +tab can gate the timeline's archive window. `tv_archive_duration` is +interpreted as **days** everywhere, matching +`live-stream-layout.controlledArchiveDays` (issue #1138). diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 9c1de9540..6893ee6b6 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -443,7 +443,14 @@ export class StreamResolverService { return this.fetchStalkerShortEpg(playlist, channelId, size); } - private async getXtreamCredentials(playlistId: string) { + private async getXtreamCredentials( + playlistId: string + ): Promise<{ + serverUrl: string; + username: string; + password: string; + serverTimezone?: string; + } | null> { const playlist = (await this.getElectronPlaylist(playlistId)) ?? ((await firstValueFrom( @@ -458,9 +465,37 @@ export class StreamResolverService { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, + serverTimezone: playlist.serverTimezone, }; } + /** + * Resolve an Xtream archive/catch-up URL for a given programme. + * Returns the timeshift playback URL, or null when credentials are + * missing or the provider doesn't support it. + */ + async resolveXtreamCatchupUrl( + item: UnifiedCollectionItem, + startTimestamp: number, + stopTimestamp: number + ): Promise { + const creds = await this.getXtreamCredentials(item.playlistId); + if (!creds || !item.xtreamId) return null; + + try { + return await this.xtreamUrl.resolveCatchupUrl( + item.playlistId, + creds, + item.xtreamId, + startTimestamp, + stopTimestamp, + creds.serverTimezone + ); + } catch { + return null; + } + } + private async loadM3uEpg( item: UnifiedCollectionItem, epgMap: Map, diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 744f9c8e9..c3d9d1c36 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -209,6 +209,8 @@ describe('UnifiedFavoritesDataService', () => { title: 'Live One', type: 'live', poster_url: 'live.png', + tv_archive: 1, + tv_archive_duration: 7, added_at: '2026-03-26T09:00:00.000Z', position: 0, }, @@ -247,6 +249,10 @@ describe('UnifiedFavoritesDataService', () => { contentType: 'live', logo: 'live.png', posterUrl: null, + // Regression for issue #1138: archive metadata must + // survive the global favorites mapping for catch-up. + tvArchive: 1, + tvArchiveDuration: 7, }), expect.objectContaining({ name: 'Movie One', diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index 4dbf97134..cdac59396 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -609,6 +609,8 @@ export class UnifiedFavoritesDataService { categoryId: row.category_id, tvgId: ct === 'live' ? String(row.xtream_id) : undefined, rating: row.rating ?? undefined, + tvArchive: row.tv_archive ?? null, + tvArchiveDuration: row.tv_archive_duration ?? null, addedAt: normalizeStalkerDate(row.added_at) || new Date(0).toISOString(), position: row.position ?? 0, @@ -635,6 +637,8 @@ export class UnifiedFavoritesDataService { categoryId: item.category_id, tvgId: ct === 'live' ? String(item.xtream_id) : undefined, rating: item.rating ?? undefined, + tvArchive: item.tv_archive ?? null, + tvArchiveDuration: item.tv_archive_duration ?? null, addedAt: normalizeStalkerDate(item.added_at ?? item.added) || new Date(0).toISOString(), diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 72037c787..6cf29a142 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -355,6 +355,8 @@ describe('UnifiedRecentDataService', () => { poster_url: 'https://example.com/live.png', xtream_id: 290, type: 'live', + tv_archive: 1, + tv_archive_duration: 3, viewed_at: '2026-04-21T20:42:27.000Z', }, { @@ -380,6 +382,10 @@ describe('UnifiedRecentDataService', () => { uid: 'xtream::xtream-1::live:290', contentType: 'live', contentId: 3867578, + // Regression for issue #1138: archive metadata must + // survive the recent mapping for catch-up. + tvArchive: 1, + tvArchiveDuration: 3, }), expect.objectContaining({ uid: 'xtream::xtream-1::series:290', @@ -697,10 +703,25 @@ describe('UnifiedRecentDataService', () => { playlist_name: 'Xtream One', viewed_at: '2026-04-21 22:49:02', }, + { + id: 3867578, + category_id: 11, + title: 'Live With Archive', + poster_url: 'https://example.com/live.png', + xtream_id: 290, + type: 'live', + tv_archive: 1, + tv_archive_duration: 3, + playlist_id: 'xtream-1', + playlist_name: 'Xtream One', + viewed_at: '2026-04-21 20:42:27', + }, ]); const items = await service.getRecentItems('all'); - const xtreamItem = items.find((item) => item.sourceType === 'xtream'); + const xtreamItem = items.find( + (item) => item.name === 'Unter Nachbarn - 2011' + ); expect(xtreamItem).toEqual( expect.objectContaining({ @@ -708,5 +729,16 @@ describe('UnifiedRecentDataService', () => { viewedAt: '2026-04-21T22:49:02.000Z', }) ); + // Regression for issue #1138: archive metadata must survive the + // global recent mapping for catch-up. + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Live With Archive', + tvArchive: 1, + tvArchiveDuration: 3, + }), + ]) + ); }); }); diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 3da7dedd2..3f75a3094 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -389,6 +389,8 @@ export class UnifiedRecentDataService { xtreamId: row.xtream_id, categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, + tvArchive: row.tv_archive ?? null, + tvArchiveDuration: row.tv_archive_duration ?? null, contentId: row.id, viewedAt: normalizeStalkerDate(row.viewed_at), })); @@ -428,6 +430,8 @@ export class UnifiedRecentDataService { xtreamId: row.xtream_id, categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, + tvArchive: row.tv_archive ?? null, + tvArchiveDuration: row.tv_archive_duration ?? null, contentId: row.id, viewedAt: normalizeStalkerDate(row.viewed_at), })); @@ -485,6 +489,8 @@ export class UnifiedRecentDataService { xtreamId: item.xtream_id, categoryId: item.category_id, tvgId: contentType === 'live' ? String(item.xtream_id) : undefined, + tvArchive: item.tv_archive ?? null, + tvArchiveDuration: item.tv_archive_duration ?? null, contentId: item.id, viewedAt: normalizeStalkerDate(item.viewed_at), }; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts index 0d255a4a1..ff0eef7e2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts @@ -94,6 +94,22 @@ function findCurrentPortalProgram( ); } +/** + * Convert program start/stop to epoch seconds, preferring the + * numeric timestamp (which is provider-native) over the ISO string. + */ +export function toEpochSeconds( + timestamp: number | null | undefined, + fallbackIso: string +): number | null { + if (timestamp != null && Number.isFinite(timestamp)) { + return timestamp; + } + + const ms = Date.parse(fallbackIso); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; +} + function getProgramTimeMs( rawDate: string | null | undefined, rawTimestamp?: number | string | null diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index ba0b2cd10..79bbf9876 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -140,6 +140,7 @@ describe('UnifiedLiveTabComponent', () => { let streamResolver: { resolveLiveDetail: jest.Mock; resolveM3uPlaybackDetail: jest.Mock; + resolveXtreamCatchupUrl: jest.Mock; loadM3uProgramsForItem: jest.Mock; loadEpgForItems: jest.Mock; }; @@ -164,6 +165,7 @@ describe('UnifiedLiveTabComponent', () => { streamResolver = { resolveLiveDetail: jest.fn(), resolveM3uPlaybackDetail: jest.fn(), + resolveXtreamCatchupUrl: jest.fn().mockResolvedValue(null), loadM3uProgramsForItem: jest.fn().mockResolvedValue([]), loadEpgForItems: jest.fn().mockResolvedValue(new Map()), }; @@ -1065,6 +1067,108 @@ describe('UnifiedLiveTabComponent', () => { }); }); + describe('Xtream (portal) catch-up (timeshift) playback', () => { + const selectXtreamArchiveChannel = async ( + archive: Partial = { + tvArchive: 1, + tvArchiveDuration: 5, + } + ) => { + const item = { ...buildLiveItem('xtream'), ...archive }; + streamResolver.resolveLiveDetail.mockResolvedValue({ + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/xtream.m3u8', + title: 'Xtream Live', + }, + epgItems: [buildEpgItem('Xtream Show')], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + + await component.onChannelSelected(component.channelsForList()[0]); + fixture.detectChanges(); + await fixture.whenStable(); + + return item; + }; + + const timeshiftEvent = (): EpgProgramActivationEvent => ({ + program: buildProgram('Xtream Show'), + type: 'timeshift', + }); + + const queryTimeline = () => + fixture.debugElement.query(By.directive(StubEpgTimelineComponent)) + .componentInstance as StubEpgTimelineComponent; + + it('exposes the provider archive window to the timeline in days', async () => { + // Regression: tv_archive_duration is days (matching + // live-stream-layout.controlledArchiveDays), not hours — a + // 5-day window must not collapse to ceil(5 / 24) = 1 day. + await selectXtreamArchiveChannel({ + tvArchive: 1, + tvArchiveDuration: 5, + }); + + const timeline = queryTimeline(); + expect(timeline.archivePlaybackAvailable()).toBe(true); + expect(timeline.archiveDays()).toBe(5); + }); + + it('keeps the archive gate closed when the provider has no archive', async () => { + await selectXtreamArchiveChannel({ + tvArchive: 0, + tvArchiveDuration: 0, + }); + + const timeline = queryTimeline(); + expect(timeline.archivePlaybackAvailable()).toBe(false); + expect(timeline.archiveDays()).toBe(0); + }); + + it('resolves the catch-up stream with epoch seconds and switches inline playback', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + streamResolver.resolveXtreamCatchupUrl.mockResolvedValue( + 'https://example.com/timeshift.m3u8' + ); + const item = await selectXtreamArchiveChannel(); + + component.onTimelineProgramActivated(timeshiftEvent()); + await fixture.whenStable(); + fixture.detectChanges(); + + const program = timeshiftEvent().program; + expect(streamResolver.resolveXtreamCatchupUrl).toHaveBeenCalledWith( + expect.objectContaining({ xtreamId: item.xtreamId }), + Math.floor(Date.parse(program.start) / 1000), + Math.floor(Date.parse(program.stop) / 1000) + ); + expect(component.inlinePlayback()?.streamUrl).toBe( + 'https://example.com/timeshift.m3u8' + ); + expect(component.inlinePlayback()?.isLive).toBe(false); + expect(component.activeTimeshiftProgram()?.title).toBe( + 'Xtream Show' + ); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('shows feedback instead of failing silently when the provider rejects catch-up', async () => { + streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(null); + await selectXtreamArchiveChannel(); + + component.onTimelineProgramActivated(timeshiftEvent()); + await fixture.whenStable(); + + expect(component.activeTimeshift()).toBeNull(); + expect(snackBar.open).toHaveBeenCalledTimes(1); + }); + }); + describe('timeline channel name', () => { it('strips the country prefix when the setting is enabled', () => { stripCountryPrefix.set(true); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 314f3b25c..1d7ea6e35 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -48,6 +48,7 @@ import { import { getLiveEpgPanelSummary, toEpgProgram, + toEpochSeconds, toLiveEpgPanelSummary, } from './unified-live-epg-summary.util'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; @@ -114,6 +115,10 @@ export class UnifiedLiveTabComponent { readonly activeDetail = signal(null); readonly activeUid = signal(null); + /** Full collection item for the active selection — used to read + * portal archive fields (tvArchive/tvArchiveDuration) and to + * supply credentials for Xtream catch-up URL resolution. */ + readonly activeItem = signal(null); readonly isSelecting = signal(false); readonly epgMap = signal>(new Map()); readonly progressTick = signal(0); @@ -197,22 +202,41 @@ export class UnifiedLiveTabComponent { this.activeDetail()?.playback?.thumbnail ?? '' ); - readonly timelineArchiveAvailable = computed( - () => - this.isM3uSelection() && this.currentM3uArchivePlaybackAvailable() - ); + readonly timelineArchiveAvailable = computed(() => { + if (this.isM3uSelection()) { + return this.currentM3uArchivePlaybackAvailable(); + } + + // Portal archive: tvArchive === 1 means the provider has + // timeshift / archive enabled for this channel. + const item = this.activeItem(); + return ( + Number(item?.tvArchive ?? 0) === 1 && + Number(item?.tvArchiveDuration ?? 0) > 0 + ); + }); /** - * Catch-up window (days) for the active M3U channel, so the timeline can + * Catch-up window (days) for the active channel, so the timeline can * gate "Watch" to programmes inside it. Without this the timeline defaults * `archiveDays` to 0 (treated as unlimited) and offers catch-up on - * programmes older than the channel's real `catchup-days`/`timeshift`/ - * `tvg-rec` window. 0 for portal selections (archive is M3U-only here). + * programmes older than the real archive window. + * + * M3U: reads catchup-days / timeshift / tvg-rec from the channel attrs. + * Portal: tvArchiveDuration is already in days — pass it through, + * matching `live-stream-layout.controlledArchiveDays`. */ - readonly timelineArchiveDays = computed(() => - this.timelineArchiveAvailable() - ? getM3uArchiveDays(this.currentM3uChannel()) - : 0 - ); + readonly timelineArchiveDays = computed(() => { + if (!this.timelineArchiveAvailable()) return 0; + + if (this.isM3uSelection()) { + return getM3uArchiveDays(this.currentM3uChannel()); + } + + return Math.max( + 0, + Number(this.activeItem()?.tvArchiveDuration ?? 0) || 0 + ); + }); readonly activeRadioChannel = computed(() => { const channel = this.activeDetail()?.channel ?? null; return channel?.radio === 'true' ? channel : null; @@ -389,9 +413,20 @@ export class UnifiedLiveTabComponent { return; } + if (this.isM3uSelection()) { + this.activateM3uCatchup(event.program); + } else { + void this.activatePortalCatchup(event.program); + } + } + + /** + * M3U catch-up: resolve via the M3U timeshift URL resolver. + */ + private activateM3uCatchup(program: EpgProgram): void { const playbackUrl = resolveM3uCatchupUrl( this.currentM3uChannel(), - event.program + program ); if (!playbackUrl) { this.snackBar.open( @@ -402,10 +437,70 @@ export class UnifiedLiveTabComponent { return; } - this.activeTimeshift.set({ url: playbackUrl, program: event.program }); + this.activeTimeshift.set({ url: playbackUrl, program }); + + const playback = this.activeDetail()?.playback; + if (!this.shouldUseInlinePlayer() && playback) { + void this.portalPlayer.openResolvedPlayback({ + ...playback, + streamUrl: playbackUrl, + isLive: false, + }); + } + } + + /** + * Portal (Xtream) catch-up: compute start/stop as epoch seconds and + * resolve via the provider's timeshift endpoint. + */ + private async activatePortalCatchup(program: EpgProgram): Promise { + const requestId = this.selectionRequestId; + const item = this.activeItem(); + if (!item?.xtreamId) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + const startEpoch = toEpochSeconds( + program.startTimestamp, + program.start + ); + const stopEpoch = toEpochSeconds( + program.stopTimestamp, + program.stop + ); + if (startEpoch == null || stopEpoch == null) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + const playbackUrl = await this.streamResolver.resolveXtreamCatchupUrl( + item, + startEpoch, + stopEpoch + ); + if (requestId !== this.selectionRequestId) { + return; // switched channel — discard silently + } + if (!playbackUrl) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + this.activeTimeshift.set({ url: playbackUrl, program }); - // No inline player (external MPV/VLC configured) → hand the archive - // stream to the external player, mirroring the live-selection flow. const playback = this.activeDetail()?.playback; if (!this.shouldUseInlinePlayer() && playback) { void this.portalPlayer.openResolvedPlayback({ @@ -441,6 +536,7 @@ export class UnifiedLiveTabComponent { this.isSelecting.set(false); this.activeDetail.set(null); this.activeUid.set(null); + this.activeItem.set(null); this.activeTimeshift.set(null); } @@ -472,6 +568,7 @@ export class UnifiedLiveTabComponent { const requestId = ++this.selectionRequestId; this.activeUid.set(item.uid); + this.activeItem.set(item); this.activeDetail.set(null); this.activeTimeshift.set(null); this.isSelecting.set(true); diff --git a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts index 222f12ab8..60cf6da0e 100644 --- a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts +++ b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts @@ -11,6 +11,8 @@ export interface XtreamFavoriteRow { readonly type: string; readonly poster_url?: string | null; readonly rating?: string | null; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | null; readonly added_at?: string | null; readonly position?: number | null; } diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts index 140612abe..a9656c395 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts @@ -56,6 +56,12 @@ export interface UnifiedCollectionItem { /** Xtream DB content id — used for reorder / remove IPC */ contentId?: number; + /** Whether the Xtream provider has timeshift / archive enabled for this stream */ + tvArchive?: number | null; + /** Timeshift / archive window in days — passed through as-is, matching + * `live-stream-layout.controlledArchiveDays`. */ + tvArchiveDuration?: number | null; + /** Stalker cmd for stream resolution */ stalkerId?: string | number; stalkerCmd?: string; diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 5453567e3..2721ce84e 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -54,6 +54,8 @@ export interface Playlist { recentlyViewed?: PlaylistRecentlyViewedItem[]; /** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */ isFullStalkerPortal?: boolean; + /** Xtream server timezone string for catch-up URL construction */ + serverTimezone?: string; /** Session token for full stalker portal authentication - persisted for session */ stalkerToken?: string; /** Serial number for stalker portal - generated once and stored for consistency */ diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts index 08fd1e6d9..badef32e9 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts @@ -64,10 +64,19 @@ describe('epg-archive.util', () => { ).toBe(false); }); - it('denies when the programme is not in the past', () => { + it('allows start-over on the currently-airing programme', () => { expect( canCatchUpProgramme('now', inWindowStart, true, 7, NOW) + ).toBe(true); + }); + + it('denies start-over when archive playback is unavailable', () => { + expect( + canCatchUpProgramme('now', inWindowStart, false, 7, NOW) ).toBe(false); + }); + + it('denies future programmes', () => { expect( canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW) ).toBe(false); @@ -91,8 +100,8 @@ describe('epg-archive.util', () => { expect(epgDialogActionFor('now', false)).toBe('live'); }); - it('prefers live over timeshift for an on-air programme', () => { - expect(epgDialogActionFor('now', true)).toBe('live'); + it('prefers start-over timeshift for a catch-up-able on-air programme', () => { + expect(epgDialogActionFor('now', true)).toBe('timeshift'); }); it('maps a catch-up-able past programme to timeshift', () => { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts index a529ce6d2..b7d491b20 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts @@ -21,7 +21,8 @@ export function isWithinArchiveWindow( return startMs >= nowMs - archiveDays * DAY_MS; } -/** Whether a past programme is playable from the catch-up archive. */ +/** Whether a programme is playable from the catch-up archive — + * both past programmes and the currently-airing programme (start-over). */ export function canCatchUpProgramme( when: TimelineWhen, startMs: number, @@ -31,7 +32,7 @@ export function canCatchUpProgramme( ): boolean { return ( archivePlaybackAvailable && - when === 'past' && + (when === 'past' || when === 'now') && isWithinArchiveWindow(startMs, archiveDays, nowMs) ); } @@ -41,7 +42,7 @@ export function epgDialogActionFor( when: TimelineWhen, canCatchUp: boolean ): EpgItemDialogAction | null { - if (when === 'now') { + if (when === 'now' && !canCatchUp) { return 'live'; } if (canCatchUp) { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts index 1c730a98a..a7afd196b 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts @@ -109,7 +109,7 @@ function toRenderBlock( nowFillPercent: nowFillFor(block, nowMs), canCatchUp: archivePlaybackAvailable && - block.when === 'past' && + (block.when === 'past' || block.when === 'now') && block.startMs >= archiveWindowStartMs, }; } diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html index ce0709cc6..30e604276 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html @@ -80,6 +80,15 @@ {{ 'EPG.TIMELINE.ON_NOW' | translate }} + @if (item.canCatchUp) { + + } } @else if (isPlaying(item)) { {{ 'EPG.TIMELINE.FROM_ARCHIVE' | translate diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss index 801603a49..57cb96ef6 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss @@ -167,6 +167,9 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); position: relative; z-index: 1; flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; } .epg-timeline__block-time { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts index 9ecc95204..1c7471de2 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -316,15 +316,15 @@ export class EpgTimelineComponent { onBlockClick(block: TimelineBlock): void { this.selectedKey.set(block.key); - if (block.when === 'now') { - this.returnToLive.emit(); - return; - } if (this.canCatchUp(block)) { this.programActivated.emit({ program: block.program, type: 'timeshift', }); + return; + } + if (block.when === 'now') { + this.returnToLive.emit(); } }