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),
};
@@ -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
@@ -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<UnifiedCollectionItem> = {
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);
@@ -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<ResolvedLiveCollectionDetail | null>(null);
readonly activeUid = signal<string | null>(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<UnifiedCollectionItem | null>(null);
readonly isSelecting = signal(false);
readonly epgMap = signal<Map<string, EpgProgram | null>>(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<void> {
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);
@@ -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;
}
@@ -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;
@@ -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 */
@@ -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', () => {
@@ -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) {
@@ -109,7 +109,7 @@ function toRenderBlock(
nowFillPercent: nowFillFor(block, nowMs),
canCatchUp:
archivePlaybackAvailable &&
block.when === 'past' &&
(block.when === 'past' || block.when === 'now') &&
block.startMs >= archiveWindowStartMs,
};
}
@@ -80,6 +80,15 @@
<span class="epg-timeline__tag is-now">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
@if (item.canCatchUp) {
<button
type="button"
class="epg-timeline__watch"
(click)="watchClick.emit(item.block); $event.stopPropagation()"
>
<mat-icon>replay</mat-icon>
</button>
}
} @else if (isPlaying(item)) {
<span class="epg-timeline__tag is-arch">{{
'EPG.TIMELINE.FROM_ARCHIVE' | translate
@@ -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 {
@@ -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();
}
}