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

@@ -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<string | null> {
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<string, EpgProgram | null>,
@@ -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',
@@ -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(),
@@ -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,
}),
])
);
});
});
@@ -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),
};