mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
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>,
|
||||
|
||||
+6
@@ -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(),
|
||||
|
||||
+33
-1
@@ -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),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user