feat(xtream): resume the latest series episode (#1187)

Dashboard Continue Watching now carries the exact saved season/episode into
Xtream series details and starts it at the persisted offset. Successful
external MPV/VLC launches persist the launched episode and retarget the
series CTA to "Play episode N". Recent-history rows keyed by an episode id
resolve their parent series before navigation.

Includes maintainer follow-ups: no zero-offset resume on failed position
loads, seriesXtreamId-gated resume targets for legacy rows, and patch
coverage raised from 76.7% to 93.9%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MahdiHrmandClaude Fable 5 authored and GitHub committed 2026-07-18 15:49:22 +02:00
1 parent d61fd5db19
commit 643dee1be3
45 files changed
+1735 -88

No files matched your search

@@ -1,9 +1,13 @@
import { Directive, inject, TemplateRef } from '@angular/core';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
export interface UnifiedCollectionDetailContext {
$implicit: UnifiedCollectionItem;
item: UnifiedCollectionItem;
seriesResume: SeriesResumeTarget | null;
close: () => void;
}
@@ -76,8 +76,15 @@ class StubUnifiedGridTabComponent {
[portalType]="portalType"
[defaultScope]="defaultScope"
>
<ng-template unifiedCollectionDetail let-item>
<ng-template
unifiedCollectionDetail
let-item
let-seriesResume="seriesResume"
>
<div class="detail-probe">{{ item.name }}</div>
<div class="resume-probe">
{{ seriesResume?.contentXtreamId }}
</div>
</ng-template>
</app-unified-collection-page>
`,
@@ -295,10 +302,10 @@ describe('UnifiedCollectionPageComponent', () => {
})
.compileComponents();
window.history.replaceState({}, document.title);
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
fixture.componentRef.setInput('mode', 'favorites');
fixture.componentRef.setInput('defaultScope', 'all');
window.history.replaceState({}, document.title);
});
it('reloads favorites after playlist hydration completes', async () => {
@@ -712,9 +719,103 @@ describe('UnifiedCollectionPageComponent', () => {
).toBe('movie');
});
it('exposes a dashboard series resume target to the inline detail host', async () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
window.history.replaceState(
{
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
item,
seriesResume: {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
},
},
},
document.title
);
const hostFixture = TestBed.createComponent(
HostUnifiedCollectionPageComponent
);
hostFixture.detectChanges();
await hostFixture.whenStable();
hostFixture.detectChanges();
expect(
hostFixture.nativeElement.querySelector('.resume-probe')
?.textContent
).toContain('2001');
expect(
hostFixture.componentInstance.pageComponent?.selectedDetailSeriesResume()
?.episodeNumber
).toBe(1);
hostFixture.destroy();
});
it('redirects cross-provider detail selections with the resume target intact', async () => {
const originalUrl = router.url;
router.url = '/workspace/stalker/stalker-7/favorites';
try {
fixture.detectChanges();
await fixture.whenStable();
router.navigate.mockClear();
const seriesResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentInstance.selectedDetailSeriesResume.set(
seriesResume
);
fixture.componentInstance.selectedDetailItem.set({
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
} satisfies UnifiedCollectionItem);
fixture.detectChanges();
await fixture.whenStable();
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'global-favorites'],
{
state: {
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
item: expect.objectContaining({ xtreamId: 103 }),
seriesResume,
},
},
}
);
expect(
fixture.componentInstance.selectedDetailItem()
).toBeNull();
} finally {
router.url = originalUrl;
}
});
it('restores collection scope and selected content type from history state', async () => {
setRouteParams({ id: 'playlist-1' });
playlistsLoaded.set(true);
fixture.destroy();
window.history.replaceState(
{
[COLLECTION_VIEW_STATE_KEY]: {
@@ -47,6 +47,7 @@ import {
routeParamSignal,
ScopeToggleService,
STALKER_RETURN_TO_STATE_KEY,
SeriesResumeTarget,
UnifiedCollectionItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
@@ -144,6 +145,9 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.historyCollectionViewState()?.selectedContentType ?? 'live'
);
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
readonly selectedDetailSeriesResume = signal<SeriesResumeTarget | null>(
null
);
readonly pendingAutoOpenLiveItem = signal(
getOpenLiveCollectionItemState(window.history.state)
);
@@ -157,6 +161,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
return {
$implicit: item,
item,
seriesResume: this.selectedDetailSeriesResume(),
close: this.requestCloseDetail,
};
}
@@ -360,7 +365,10 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
return;
}
const navigation = this.getGlobalCollectionDetailNavigation(item);
const navigation = this.getGlobalCollectionDetailNavigation(
item,
this.selectedDetailSeriesResume()
);
if (!navigation) {
return;
}
@@ -761,7 +769,10 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
return !portalType || portalType === item.sourceType;
}
private getGlobalCollectionDetailNavigation(item: UnifiedCollectionItem) {
private getGlobalCollectionDetailNavigation(
item: UnifiedCollectionItem,
seriesResume?: SeriesResumeTarget | null
) {
if (
item.contentType === 'live' ||
(item.sourceType !== 'xtream' && item.sourceType !== 'stalker')
@@ -769,16 +780,25 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
return null;
}
return buildGlobalCollectionDetailNavigationTarget(this.mode(), item);
return buildGlobalCollectionDetailNavigationTarget(
this.mode(),
item,
seriesResume
);
}
private openInlineDetail(item: UnifiedCollectionItem): void {
private openInlineDetail(
item: UnifiedCollectionItem,
seriesResume?: SeriesResumeTarget | null
): void {
this.selectedContentType.set(item.contentType);
this.selectedDetailItem.set(item);
this.selectedDetailSeriesResume.set(seriesResume ?? null);
}
private clearInlineDetail(): void {
this.selectedDetailItem.set(null);
this.selectedDetailSeriesResume.set(null);
this.autoSelectContentType();
clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]);
}
@@ -842,12 +862,13 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}
private syncDetailFromHistoryState(): void {
const detailItem = getOpenCollectionDetailItemState(
const detailState = getOpenCollectionDetailItemState(
window.history.state
)?.item;
);
const detailItem = detailState?.item;
if (detailItem && this.canOpenInlineDetail(detailItem)) {
this.openInlineDetail(detailItem);
this.openInlineDetail(detailItem, detailState.seriesResume);
return;
}
@@ -1,5 +1,7 @@
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
import {
buildCollectionViewState,
buildOpenCollectionDetailItemState,
buildStalkerStateItem,
buildXtreamItemLink,
COLLECTION_VIEW_STATE_KEY,
@@ -153,6 +155,138 @@ describe('workspace-portal-navigation', () => {
});
});
it('carries an exact Xtream series resume target into recent collection detail state', () => {
const navigation = getRecentItemNavigation(
{
id: 81,
title: 'Series Eighty One',
type: 'series',
playlist_id: 'xtream-1',
category_id: 12,
xtream_id: 8000,
source: 'xtream',
poster_url: 'https://example.com/series.png',
viewed_at: '2026-03-01T00:00:00.000Z',
},
{
seriesXtreamId: 8000,
contentXtreamId: 8024,
seasonNumber: 2,
episodeNumber: 4,
}
);
expect(navigation).toEqual({
link: ['/workspace', 'global-recent'],
state: {
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
sourceType: 'xtream',
xtreamId: 8000,
}),
seriesResume: {
seriesXtreamId: 8000,
contentXtreamId: 8024,
seasonNumber: 2,
episodeNumber: 4,
},
},
},
});
expect(getOpenCollectionDetailItemState(navigation.state)).toEqual(
navigation.state?.[OPEN_COLLECTION_DETAIL_STATE_KEY]
);
});
it('drops malformed or misplaced series resume state on read', () => {
const seriesItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
const validResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
const readResume = (seriesResume: unknown, item: object = seriesItem) =>
getOpenCollectionDetailItemState({
[OPEN_COLLECTION_DETAIL_STATE_KEY]: { item, seriesResume },
})?.seriesResume;
expect(readResume(validResume)).toEqual(validResume);
expect(readResume('resume')).toBeUndefined();
expect(
readResume({ ...validResume, seriesXtreamId: 'x' })
).toBeUndefined();
expect(
readResume({ ...validResume, seriesXtreamId: 0 })
).toBeUndefined();
expect(
readResume({ ...validResume, contentXtreamId: 1.5 })
).toBeUndefined();
expect(
readResume({ ...validResume, contentXtreamId: -2 })
).toBeUndefined();
expect(
readResume({ ...validResume, seasonNumber: Number.NaN })
).toBeUndefined();
expect(
readResume({ ...validResume, seasonNumber: -1 })
).toBeUndefined();
expect(
readResume({ ...validResume, episodeNumber: 2.5 })
).toBeUndefined();
expect(
readResume({ ...validResume, episodeNumber: -1 })
).toBeUndefined();
// A valid target attached to a non-series item is discarded too.
expect(
readResume(validResume, {
...seriesItem,
uid: 'xtream::xtream-1::movie:55',
contentType: 'movie',
xtreamId: 55,
})
).toBeUndefined();
});
it('omits the resume key when building detail state without a valid target', () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
expect(buildOpenCollectionDetailItemState(item)).toEqual({
item: expect.objectContaining({ xtreamId: 103 }),
});
expect(
buildOpenCollectionDetailItemState(item, {
seriesXtreamId: 0,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
})
).toEqual({
item: expect.objectContaining({ xtreamId: 103 }),
});
});
it('matches collection live state against multiple live item identifiers', () => {
expect(
matchesOpenLiveCollectionItem(
@@ -36,6 +36,14 @@ export interface OpenLiveCollectionItemState {
export interface OpenCollectionDetailItemState {
item: UnifiedCollectionItem;
seriesResume?: SeriesResumeTarget;
}
export interface SeriesResumeTarget {
seriesXtreamId: number;
contentXtreamId: number;
seasonNumber: number;
episodeNumber: number;
}
export interface CollectionViewState {
@@ -44,7 +52,8 @@ export interface CollectionViewState {
}
export function getRecentItemNavigation(
item: PortalRecentItem
item: PortalRecentItem,
seriesResume?: SeriesResumeTarget | null
): WorkspaceNavigationTarget {
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
@@ -59,9 +68,24 @@ export function getRecentItemNavigation(
const collectionItem = buildDashboardCollectionDetailItem(item);
if (collectionItem) {
const detailItem =
item.source === 'xtream' &&
item.type === 'series' &&
seriesResume
? {
...collectionItem,
uid: buildXtreamCollectionUid(
item.playlist_id,
'series',
seriesResume.seriesXtreamId
),
xtreamId: seriesResume.seriesXtreamId,
}
: collectionItem;
return buildGlobalCollectionDetailNavigationTarget(
'recent',
collectionItem
detailItem,
item.type === 'series' ? seriesResume : null
);
}
@@ -180,7 +204,8 @@ export function buildLiveCollectionNavigationTarget(params: {
export function buildGlobalCollectionDetailNavigationTarget(
mode: 'favorites' | 'recent',
item: UnifiedCollectionItem
item: UnifiedCollectionItem,
seriesResume?: SeriesResumeTarget | null
): WorkspaceNavigationTarget {
return {
link: [
@@ -189,7 +214,7 @@ export function buildGlobalCollectionDetailNavigationTarget(
],
state: {
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
buildOpenCollectionDetailItemState(item),
buildOpenCollectionDetailItemState(item, seriesResume),
},
};
}
@@ -228,10 +253,13 @@ export function getCollectionViewState(
}
export function buildOpenCollectionDetailItemState(
item: UnifiedCollectionItem
item: UnifiedCollectionItem,
seriesResume?: SeriesResumeTarget | null
): OpenCollectionDetailItemState {
const normalizedResume = normalizeSeriesResumeTarget(seriesResume);
return {
item: normalizeCollectionDetailItem(item) ?? item,
...(normalizedResume ? { seriesResume: normalizedResume } : {}),
};
}
@@ -241,8 +269,18 @@ export function getOpenCollectionDetailItemState(
const record = toStateRecord(state);
const candidate = toStateRecord(record?.[OPEN_COLLECTION_DETAIL_STATE_KEY]);
const item = normalizeCollectionDetailItem(candidate?.['item']);
const seriesResume = normalizeSeriesResumeTarget(
candidate?.['seriesResume']
);
return item ? { item } : null;
return item
? {
item,
...(item.contentType === 'series' && seriesResume
? { seriesResume }
: {}),
}
: null;
}
export function buildOpenLiveCollectionItemState(params: {
@@ -668,6 +706,40 @@ function toOptionalNumber(value: unknown): number | undefined {
return Number.isFinite(normalized) ? normalized : undefined;
}
function normalizeSeriesResumeTarget(
candidate: unknown
): SeriesResumeTarget | null {
const record = toStateRecord(candidate);
if (!record) {
return null;
}
const seriesXtreamId = toOptionalNumber(record['seriesXtreamId']);
const contentXtreamId = toOptionalNumber(record['contentXtreamId']);
const seasonNumber = toOptionalNumber(record['seasonNumber']);
const episodeNumber = toOptionalNumber(record['episodeNumber']);
if (
!Number.isInteger(seriesXtreamId) ||
(seriesXtreamId ?? 0) <= 0 ||
!Number.isInteger(contentXtreamId) ||
(contentXtreamId ?? 0) <= 0 ||
!Number.isInteger(seasonNumber) ||
(seasonNumber ?? -1) < 0 ||
!Number.isInteger(episodeNumber) ||
(episodeNumber ?? -1) < 0
) {
return null;
}
return {
seriesXtreamId: seriesXtreamId as number,
contentXtreamId: contentXtreamId as number,
seasonNumber: seasonNumber as number,
episodeNumber: episodeNumber as number,
};
}
function normalizeCollectionDetailItem(
candidate: unknown
): UnifiedCollectionItem | null {
@@ -93,6 +93,42 @@ describe('getSeriesQuickStartAction', () => {
expect(action?.position?.positionSeconds).toBe(30);
});
it('plays the most recently launched episode when it has no progress yet', () => {
const latestEpisode = episode(108, 1, 8);
const action = getSeriesQuickStartAction({
seasons: {
'1': [episode(101, 1, 1), latestEpisode],
},
playbackPositions: new Map([
[
101,
position(101, {
positionSeconds: 20,
updatedAt: '2026-05-10T10:00:00.000Z',
}),
],
[
108,
position(108, {
positionSeconds: 0,
durationSeconds: undefined,
updatedAt: '2026-05-10T11:00:00.000Z',
}),
],
]),
});
expect(action?.kind).toBe(
SERIES_QUICK_START_ACTION_KIND.PlayRecent
);
expect(action?.labelKey).toBe('XTREAM.PLAY_EPISODE');
expect(action?.labelParams).toEqual({ episode: 8 });
expect(action?.episodeLabel).toBe('S01E08 \u00b7 Episode 8');
expect(action?.episode).toBe(latestEpisode);
expect(action?.position?.positionSeconds).toBe(0);
});
it('plays the next unwatched episode after watched episodes', () => {
const nextEpisode = episode(102, 1, 2);
@@ -6,6 +6,7 @@ import {
export const SERIES_QUICK_START_ACTION_KIND = {
PlayFirst: 'play-first',
PlayRecent: 'play-recent',
Resume: 'resume',
PlayNext: 'play-next',
Completed: 'completed',
@@ -17,6 +18,7 @@ export type SeriesQuickStartActionKind =
export interface SeriesQuickStartAction {
kind: SeriesQuickStartActionKind;
labelKey: string;
labelParams?: Record<string, number>;
episodeLabel: string;
icon: string;
episode: XtreamSerieEpisode;
@@ -49,18 +51,37 @@ export function getSeriesQuickStartAction(
return null;
}
const inProgress = orderedEpisodes
.filter(({ position }) => isPortalPlaybackInProgress(position))
.sort(compareInProgressPositions);
const startedEpisodes = orderedEpisodes
.filter(
({ position }) =>
position !== null && !isPortalPlaybackWatched(position)
)
.sort(comparePlaybackPositionRecency);
const resumeEpisode = inProgress[inProgress.length - 1];
if (resumeEpisode?.position) {
const latestStartedEpisode = startedEpisodes[startedEpisodes.length - 1];
if (latestStartedEpisode?.position) {
const shouldResume = isPortalPlaybackInProgress(
latestStartedEpisode.position
);
return createQuickStartAction({
kind: SERIES_QUICK_START_ACTION_KIND.Resume,
labelKey: 'XTREAM.RESUME_EPISODE',
kind: shouldResume
? SERIES_QUICK_START_ACTION_KIND.Resume
: SERIES_QUICK_START_ACTION_KIND.PlayRecent,
labelKey: shouldResume
? 'XTREAM.RESUME_EPISODE'
: 'XTREAM.PLAY_EPISODE',
...(!shouldResume
? {
labelParams: {
episode: Number(
latestStartedEpisode.episode.episode_num
),
},
}
: {}),
icon: 'play_arrow',
episode: resumeEpisode.episode,
position: resumeEpisode.position,
episode: latestStartedEpisode.episode,
position: latestStartedEpisode.position,
disabled: false,
});
}
@@ -150,7 +171,7 @@ function compareEpisodes(
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
}
function compareInProgressPositions(
function comparePlaybackPositionRecency(
episodeA: OrderedEpisode,
episodeB: OrderedEpisode
): number {
@@ -0,0 +1,303 @@
import type {
ExternalPlayerSession,
PlaybackPositionData,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
function episode(id: string, season: number, episodeNum: number) {
return {
id,
season,
episode_num: episodeNum,
title: `Episode ${episodeNum}`,
};
}
function seriesView(): XtreamSerieDetailsView {
return {
series_id: 103,
info: {},
episodes: {
'1': [episode('1001', 1, 1), episode('1002', 1, 2)],
'2': [episode('2001', 2, 1)],
},
} as unknown as XtreamSerieDetailsView;
}
function position(
contentXtreamId: number,
overrides: Partial<PlaybackPositionData> = {}
): PlaybackPositionData {
return {
contentXtreamId,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 60,
durationSeconds: 1200,
playlistId: 'xtream-1',
updatedAt: '2026-05-10T12:00:00.000Z',
...overrides,
} as PlaybackPositionData;
}
function playback(
contentXtreamId: number,
overrides: Partial<ResolvedPortalPlayback> = {}
): ResolvedPortalPlayback {
return {
streamUrl: `http://xtream.example/series/${contentXtreamId}.mp4`,
title: 'Episode',
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 2,
episodeNumber: 1,
},
...overrides,
} as ResolvedPortalPlayback;
}
const session = {
id: 'vlc-1',
player: 'vlc',
status: 'opened',
} as ExternalPlayerSession;
const target = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
describe('SerialDetailsPlaybackPositionState', () => {
let state: SerialDetailsPlaybackPositionState;
beforeEach(() => {
state = new SerialDetailsPlaybackPositionState();
});
async function loadPositions(
positions: PlaybackPositionData[] = [position(2001)]
): Promise<void> {
await state.load('xtream-1', 103, () => Promise.resolve(positions));
}
describe('load', () => {
it('keeps only the latest concurrent load result', async () => {
let resolveFirst!: (value: PlaybackPositionData[]) => void;
const first = state.load(
'xtream-1',
103,
() =>
new Promise<PlaybackPositionData[]>((resolve) => {
resolveFirst = resolve;
})
);
await loadPositions([position(2001, { positionSeconds: 84 })]);
resolveFirst([position(1001, { positionSeconds: 5 })]);
await first;
expect(state.positions().get(2001)?.positionSeconds).toBe(84);
expect(state.positions().has(1001)).toBe(false);
});
});
describe('takeResumeEpisode', () => {
it('rejects targets before positions finish loading', () => {
expect(
state.takeResumeEpisode({
playlistId: 'xtream-1',
selectedItem: seriesView(),
target,
})
).toBeNull();
});
it('rejects targets that belong to another series', async () => {
await loadPositions();
expect(
state.takeResumeEpisode({
playlistId: 'xtream-1',
selectedItem: seriesView(),
target: { ...target, seriesXtreamId: 999 },
})
).toBeNull();
});
it('consumes a matching target only once', async () => {
await loadPositions();
const request = {
playlistId: 'xtream-1',
selectedItem: seriesView(),
target,
};
expect(state.takeResumeEpisode(request)?.id).toBe('2001');
expect(state.takeResumeEpisode(request)).toBeNull();
});
it('falls back to season/episode matching for unknown episode ids', async () => {
await loadPositions();
expect(
state.takeResumeEpisode({
playlistId: 'xtream-1',
selectedItem: seriesView(),
target: { ...target, contentXtreamId: 5555 },
})?.id
).toBe('2001');
});
it('leaves unmatched targets unconsumed until episodes arrive', async () => {
await loadPositions();
const request = {
playlistId: 'xtream-1',
selectedItem: {
...seriesView(),
episodes: {},
} as unknown as XtreamSerieDetailsView,
target,
};
expect(state.takeResumeEpisode(request)).toBeNull();
expect(
state.takeResumeEpisode({
...request,
selectedItem: seriesView(),
})?.id
).toBe('2001');
});
it('stops resolving targets after a reset', async () => {
await loadPositions();
state.reset();
expect(
state.takeResumeEpisode({
playlistId: 'xtream-1',
selectedItem: seriesView(),
target,
})
).toBeNull();
});
});
describe('update and remove', () => {
it('updates and removes entries by content id', () => {
state.update(position(1001));
expect(state.positions().get(1001)?.positionSeconds).toBe(60);
state.remove(1001);
expect(state.positions().has(1001)).toBe(false);
});
});
describe('recordExternalLaunch', () => {
let save: jest.Mock;
beforeEach(() => {
save = jest.fn().mockResolvedValue(undefined);
});
it('ignores launches that do not produce a session', async () => {
await state.recordExternalLaunch(
playback(2001),
Promise.resolve(undefined),
save
);
expect(save).not.toHaveBeenCalled();
});
it('ignores launches without episode content info', async () => {
await state.recordExternalLaunch(
playback(2001, { contentInfo: undefined }),
Promise.resolve(session),
save
);
await state.recordExternalLaunch(
playback(2001, {
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 2001,
contentType: 'vod',
} as ResolvedPortalPlayback['contentInfo'],
}),
Promise.resolve(session),
save
);
expect(save).not.toHaveBeenCalled();
});
it('persists the floored requested start time', async () => {
await state.recordExternalLaunch(
playback(2001, { startTime: 42.9 }),
Promise.resolve(session),
save
);
expect(save).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
contentXtreamId: 2001,
positionSeconds: 42,
})
);
expect(state.positions().get(2001)?.positionSeconds).toBe(42);
});
it('keeps the existing saved offset when the launch has none', async () => {
state.update(
position(2001, { positionSeconds: 84, durationSeconds: 1200 })
);
await state.recordExternalLaunch(
playback(2001),
Promise.resolve(session),
save
);
expect(save).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
positionSeconds: 84,
durationSeconds: 1200,
})
);
});
it('clamps invalid start times to zero', async () => {
await state.recordExternalLaunch(
playback(2001, { startTime: Number.NaN }),
Promise.resolve(session),
save
);
await state.recordExternalLaunch(
playback(2001, { startTime: -30 }),
Promise.resolve(session),
save
);
expect(save).toHaveBeenNthCalledWith(
1,
'xtream-1',
expect.objectContaining({ positionSeconds: 0 })
);
expect(save).toHaveBeenNthCalledWith(
2,
'xtream-1',
expect.objectContaining({ positionSeconds: 0 })
);
});
});
});
@@ -0,0 +1,165 @@
import { signal } from '@angular/core';
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
import type {
ExternalPlayerSession,
PlaybackPositionData,
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
interface ResumeEpisodeRequest {
playlistId: string;
selectedItem: XtreamSerieDetailsView;
target: SeriesResumeTarget;
}
export class SerialDetailsPlaybackPositionState {
readonly positions = signal<Map<number, PlaybackPositionData>>(new Map());
private loadRequestId = 0;
private readonly loadedKey = signal<string | null>(null);
private readonly consumedResumeKey = signal<string | null>(null);
reset(): void {
this.loadRequestId++;
this.positions.set(new Map());
this.loadedKey.set(null);
}
async load(
playlistId: string,
seriesXtreamId: number,
loader: () => Promise<PlaybackPositionData[]>
): Promise<void> {
const requestId = ++this.loadRequestId;
this.loadedKey.set(null);
let positions: PlaybackPositionData[] = [];
let loadSucceeded = true;
try {
positions = await loader();
} catch (error) {
loadSucceeded = false;
console.warn(
'[SerialDetailsPlayback] Failed to load series playback positions',
error
);
}
if (requestId !== this.loadRequestId) {
return;
}
this.positions.set(
new Map(
positions.map((position) => [
position.contentXtreamId,
position,
])
)
);
// A failed load must not mark the series resume-ready: without the
// persisted offsets a dashboard handoff would start the target
// episode from the beginning instead of its saved position.
if (loadSucceeded) {
this.loadedKey.set(this.createKey(playlistId, seriesXtreamId));
}
}
takeResumeEpisode(
request: ResumeEpisodeRequest
): XtreamSerieEpisode | null {
const seriesXtreamId = Number(request.selectedItem.series_id);
if (
request.target.seriesXtreamId !== seriesXtreamId ||
this.loadedKey() !==
this.createKey(request.playlistId, seriesXtreamId)
) {
return null;
}
const resumeKey = [
request.playlistId,
request.target.seriesXtreamId,
request.target.contentXtreamId,
request.target.seasonNumber,
request.target.episodeNumber,
].join(':');
if (this.consumedResumeKey() === resumeKey) {
return null;
}
const episodes: XtreamSerieEpisode[] = [];
Object.values(request.selectedItem.episodes ?? {}).forEach(
(seasonEpisodes) => episodes.push(...seasonEpisodes)
);
const episode =
episodes.find(
(item) => Number(item.id) === request.target.contentXtreamId
) ??
episodes.find(
(item) =>
Number(item.season) === request.target.seasonNumber &&
Number(item.episode_num) === request.target.episodeNumber
) ??
null;
if (episode) {
this.consumedResumeKey.set(resumeKey);
}
return episode;
}
update(position: PlaybackPositionData): void {
const updated = new Map(this.positions());
updated.set(position.contentXtreamId, position);
this.positions.set(updated);
}
remove(contentXtreamId: number): void {
const updated = new Map(this.positions());
updated.delete(contentXtreamId);
this.positions.set(updated);
}
async recordExternalLaunch(
playback: ResolvedPortalPlayback,
launch: Promise<ExternalPlayerSession | void>,
save: (
playlistId: string,
position: PlaybackPositionData
) => Promise<void>
): Promise<void> {
const session = await launch;
if (!session) {
return;
}
const contentInfo = playback.contentInfo;
if (!contentInfo || contentInfo.contentType !== 'episode') {
return;
}
const existing = this.positions().get(contentInfo.contentXtreamId);
const requestedPosition =
playback.startTime ?? existing?.positionSeconds ?? 0;
const positionSeconds = Number.isFinite(requestedPosition)
? Math.max(0, Math.floor(requestedPosition))
: 0;
const position: PlaybackPositionData = {
...contentInfo,
playlistId: contentInfo.playlistId,
positionSeconds,
durationSeconds: existing?.durationSeconds,
updatedAt: new Date().toISOString(),
};
await save(contentInfo.playlistId, position);
this.update(position);
}
private createKey(playlistId: string, seriesXtreamId: number): string {
return `${playlistId}:${seriesXtreamId}`;
}
}
@@ -6,6 +6,7 @@ import {
Injectable,
Signal,
signal,
untracked,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
@@ -31,6 +32,8 @@ import {
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
} from '@iptvnator/ui/playback';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
export type XtreamSerieDetailsView = XtreamSerieDetails & {
readonly series_id: number;
@@ -55,6 +58,7 @@ export class SerialDetailsPlaybackService {
);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
null
@@ -62,12 +66,12 @@ export class SerialDetailsPlaybackService {
private readonly currentPlaylistId = computed(
() => this.xtreamStore.currentPlaylist()?.id ?? ''
);
private readonly playbackPositionState =
new SerialDetailsPlaybackPositionState();
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
readonly episodePlaybackPositions = this.playbackPositionState.positions;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
@@ -129,6 +133,27 @@ export class SerialDetailsPlaybackService {
this.activeEpisodeId.set(null);
});
effect(() => {
const target = this.resumeTarget();
const selectedItem = this.selectedItem();
const playlistId = this.currentPlaylistId();
if (!target || !selectedItem || !playlistId) {
return;
}
const episode = this.playbackPositionState.takeResumeEpisode({
playlistId,
selectedItem,
target,
});
if (!episode) {
return;
}
untracked(() => this.playEpisode(episode));
});
const unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
@@ -143,7 +168,7 @@ export class SerialDetailsPlaybackService {
return;
}
this.updateEpisodePlaybackPosition(data);
this.playbackPositionState.update(data);
}
) ?? null;
@@ -160,7 +185,7 @@ export class SerialDetailsPlaybackService {
/** Clears all playback state when switching to another series. */
resetForNewSeries(): void {
this.closeInlinePlayer();
this.episodePlaybackPositions.set(new Map());
this.playbackPositionState.reset();
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
}
@@ -257,13 +282,21 @@ export class SerialDetailsPlaybackService {
playback.contentInfo.playlistId,
position
);
this.updateEpisodePlaybackPosition(position);
this.playbackPositionState.update(position);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
void this.playbackPositionState.recordExternalLaunch(
request.playback,
request.player
this.portalPlayer.openExternalPlayback(
request.playback,
request.player
),
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
position
)
);
}
@@ -280,7 +313,7 @@ export class SerialDetailsPlaybackService {
playlistId,
request.nextPosition
);
this.updateEpisodePlaybackPosition(request.nextPosition);
this.playbackPositionState.update(request.nextPosition);
return;
}
@@ -289,23 +322,19 @@ export class SerialDetailsPlaybackService {
request.contentXtreamId,
'episode'
);
this.removeEpisodePlaybackPosition(request.contentXtreamId);
this.playbackPositionState.remove(request.contentXtreamId);
}
async loadSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
return this.playbackPositionState.load(playlistId, seriesXtreamId, () =>
this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
});
this.episodePlaybackPositions.set(positionsMap);
)
);
}
private selectedItem(): XtreamSerieDetailsView | null {
@@ -329,7 +358,15 @@ export class SerialDetailsPlaybackService {
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
void this.playbackPositionState.recordExternalLaunch(
playback,
this.portalPlayer.openResolvedPlayback(playback, true),
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
position
)
);
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
@@ -352,18 +389,4 @@ export class SerialDetailsPlaybackService {
fallbackEpisodeNumber: playback.contentInfo.episodeNumber,
});
}
private updateEpisodePlaybackPosition(
position: PlaybackPositionData
): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
}
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
const updated = new Map(this.episodePlaybackPositions());
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
}
@@ -0,0 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
describe('XTREAM_SERIES_RESUME_TARGET', () => {
it('defaults to a null resume target outside collection detail hosts', () => {
const target = TestBed.inject(XTREAM_SERIES_RESUME_TARGET);
expect(target()).toBeNull();
});
});
@@ -0,0 +1,8 @@
import { InjectionToken, Signal, signal } from '@angular/core';
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
export const XTREAM_SERIES_RESUME_TARGET = new InjectionToken<
Signal<SeriesResumeTarget | null>
>('XTREAM_SERIES_RESUME_TARGET', {
factory: () => signal(null),
});
@@ -102,7 +102,10 @@
</span>
<span class="play-btn__copy">
<span class="play-btn__label">
{{ action.labelKey | translate }}
{{
action.labelKey
| translate: action.labelParams
}}
</span>
<span class="play-btn__meta">
{{ action.episodeLabel }}
@@ -15,11 +15,16 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
@Component({
selector: 'app-season-container',
@@ -84,8 +89,16 @@ describe('SerialDetailsComponent', () => {
const constructEpisodeStreamUrl = jest.fn();
const addRecentItem = jest.fn();
const openResolvedPlayback = jest.fn();
const openExternalPlayback = jest.fn();
const savePlaybackPosition = jest.fn();
const clearPlaybackPosition = jest.fn();
const isEmbeddedPlayer = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
let positionUpdateCallback: ((data: PlaybackPositionData) => void) | null =
null;
let seriesResumeTarget: ReturnType<
typeof signal<SeriesResumeTarget | null>
>;
beforeEach(async () => {
selectedItem.set({
@@ -133,11 +146,20 @@ describe('SerialDetailsComponent', () => {
`http://xtream.example/series/${episode.id}.mp4`
);
addRecentItem.mockClear();
openResolvedPlayback.mockClear();
openResolvedPlayback.mockReset();
openResolvedPlayback.mockResolvedValue(undefined);
openExternalPlayback.mockReset();
openExternalPlayback.mockResolvedValue(undefined);
savePlaybackPosition.mockReset();
savePlaybackPosition.mockResolvedValue(undefined);
clearPlaybackPosition.mockReset();
clearPlaybackPosition.mockResolvedValue(undefined);
positionUpdateCallback = null;
isEmbeddedPlayer.mockReset();
isEmbeddedPlayer.mockReturnValue(false);
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
@@ -187,8 +209,8 @@ describe('SerialDetailsComponent', () => {
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
savePlaybackPosition,
clearPlaybackPosition,
},
},
{
@@ -196,8 +218,26 @@ describe('SerialDetailsComponent', () => {
useValue: {
isEmbeddedPlayer,
openResolvedPlayback,
openExternalPlayback,
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
callback: (data: PlaybackPositionData) => void
) => {
positionUpdateCallback = callback;
return () => {
positionUpdateCallback = null;
};
},
},
},
{
provide: XTREAM_SERIES_RESUME_TARGET,
useValue: seriesResumeTarget,
},
{
provide: MatSnackBar,
useValue: {
@@ -408,6 +448,293 @@ describe('SerialDetailsComponent', () => {
);
});
it('records the selected episode after a successful external-player launch', async () => {
openResolvedPlayback.mockResolvedValue({
id: 'vlc-session-1',
player: 'vlc',
status: 'opened',
title: 'Season 2 Episode 1',
streamUrl: 'http://xtream.example/series/2001.mp4',
startedAt: '2026-07-14T10:00:00.000Z',
updatedAt: '2026-07-14T10:00:00.000Z',
canClose: true,
});
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.playEpisode({
id: '2001',
episode_num: 1,
title: 'Season 2 Episode 1',
season: 2,
} as never);
await fixture.whenStable();
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
playlistId: 'xtream-1',
contentXtreamId: 2001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 2,
episodeNumber: 1,
positionSeconds: 0,
updatedAt: expect.any(String),
})
);
fixture.detectChanges();
const quickStartButton: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton?.textContent).toContain(
'XTREAM.PLAY_EPISODE'
);
expect(quickStartButton?.textContent).toContain(
'S02E01 \u00b7 Season 2 Episode 1'
);
});
it('automatically resumes the exact dashboard episode after positions load', async () => {
getSeriesPlaybackPositions.mockResolvedValue([
{
contentXtreamId: 2001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 2,
episodeNumber: 1,
positionSeconds: 84,
durationSeconds: 1200,
playlistId: 'xtream-1',
updatedAt: '2026-05-10T12:00:00.000Z',
},
]);
seriesResumeTarget.set({
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(constructEpisodeStreamUrl).toHaveBeenCalledTimes(1);
expect(constructEpisodeStreamUrl).toHaveBeenCalledWith(
expect.objectContaining({
id: '2001',
season: 2,
episode_num: 1,
})
);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/2001.mp4',
startTime: 84,
contentInfo: expect.objectContaining({
contentXtreamId: 2001,
seriesXtreamId: 103,
seasonNumber: 2,
episodeNumber: 1,
}),
}),
true
);
});
it('does not auto-resume the dashboard episode when positions fail to load', async () => {
const warnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
getSeriesPlaybackPositions.mockRejectedValue(
new Error('storage unavailable')
);
seriesResumeTarget.set({
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
});
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(constructEpisodeStreamUrl).not.toHaveBeenCalled();
expect(openResolvedPlayback).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('applies streamed playback-position updates for the selected series only', async () => {
fixture.detectChanges();
await fixture.whenStable();
if (!positionUpdateCallback) {
throw new Error('expected a playback-position subscription');
}
positionUpdateCallback({
playlistId: 'other-playlist',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 300,
durationSeconds: 1200,
} as PlaybackPositionData);
fixture.detectChanges();
const quickStartButton = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-testid="series-quick-start"]'
);
expect(quickStartButton()?.textContent).not.toContain(
'XTREAM.RESUME_EPISODE'
);
positionUpdateCallback({
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 300,
durationSeconds: 1200,
} as PlaybackPositionData);
fixture.detectChanges();
expect(quickStartButton()?.textContent).toContain(
'XTREAM.RESUME_EPISODE'
);
expect(quickStartButton()?.textContent).toContain(
'S01E01 · Episode 1'
);
});
it('persists the launched episode after an external fallback succeeds', async () => {
openExternalPlayback.mockResolvedValue({
id: 'mpv-session-1',
player: 'mpv',
status: 'opened',
});
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
playbackService.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'http://xtream.example/series/2001.mp4',
title: 'Season 2 Episode 1',
contentInfo: {
playlistId: 'xtream-1',
contentXtreamId: 2001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 2,
episodeNumber: 1,
},
},
diagnostic: {},
} as never);
await fixture.whenStable();
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
contentXtreamId: 2001,
contentType: 'episode',
positionSeconds: 0,
})
);
});
it('persists throttled inline time updates for the playing episode', async () => {
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
// Without an inline playback there is nothing to persist.
playbackService.handleInlineTimeUpdate({
currentTime: 10,
duration: 100,
});
expect(savePlaybackPosition).not.toHaveBeenCalled();
fixture.componentInstance.playEpisode({
id: '1001',
episode_num: 1,
title: 'Episode 1',
season: 1,
} as never);
playbackService.handleInlineTimeUpdate({
currentTime: 123.9,
duration: 1200.4,
});
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
contentXtreamId: 1001,
positionSeconds: 123,
durationSeconds: 1200,
})
);
// A second update inside the 15s throttle window is skipped.
savePlaybackPosition.mockClear();
playbackService.handleInlineTimeUpdate({
currentTime: 130,
duration: 1200,
});
expect(savePlaybackPosition).not.toHaveBeenCalled();
});
it('saves and clears positions for season-container toggle requests', async () => {
fixture.detectChanges();
await fixture.whenStable();
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
await playbackService.handlePlaybackToggleRequested({
contentXtreamId: 1001,
nextPosition: {
playlistId: 'xtream-1',
contentXtreamId: 1001,
contentType: 'episode',
seriesXtreamId: 103,
seasonNumber: 1,
episodeNumber: 1,
positionSeconds: 950,
durationSeconds: 1000,
},
} as never);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({ contentXtreamId: 1001 })
);
await playbackService.handlePlaybackToggleRequested({
contentXtreamId: 1001,
nextPosition: null,
} as never);
expect(clearPlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
1001,
'episode'
);
});
it('passes inline episode metadata and autoplays only inside the current season', async () => {
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
@@ -10,6 +10,7 @@ import { PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { firstValueFrom, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token';
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
describe('XtreamCollectionDetailComponent', () => {
@@ -105,19 +106,23 @@ describe('XtreamCollectionDetailComponent', () => {
});
it('opens Xtream series favorites with the serial detail route context', async () => {
fixture.componentRef.setInput(
'item',
{
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Portal',
xtreamId: 103,
categoryId: 3,
} satisfies UnifiedCollectionItem
);
const seriesResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentRef.setInput('item', {
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Portal',
xtreamId: 103,
categoryId: 3,
} satisfies UnifiedCollectionItem);
fixture.componentRef.setInput('seriesResume', seriesResume);
fixture.detectChanges();
await fixture.whenStable();
@@ -140,9 +145,17 @@ describe('XtreamCollectionDetailComponent', () => {
// toSignal(), so the fake route must expose the observable too —
// otherwise the inline detail crashes on construction.
expect(route?.params).toBeDefined();
await expect(firstValueFrom(route!.params)).resolves.toEqual({
if (!route) {
throw new Error('expected an inline ActivatedRoute');
}
await expect(firstValueFrom(route.params)).resolves.toEqual({
categoryId: '3',
serialId: '103',
});
expect(
fixture.componentInstance
.detailInjector()
?.get(XTREAM_SERIES_RESUME_TARGET)()
).toEqual(seriesResume);
});
});
@@ -12,7 +12,10 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
XtreamPlaylistData,
XtreamStore,
@@ -21,6 +24,7 @@ import { PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { firstValueFrom, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token';
import { VodDetailsRouteComponent } from './vod-details/vod-details-route.component';
interface XtreamCollectionStateSnapshot {
@@ -62,6 +66,7 @@ interface XtreamCollectionStateSnapshot {
})
export class XtreamCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
private readonly parentInjector = inject(Injector);
private readonly playlistsService = inject(PlaylistsService);
@@ -154,6 +159,10 @@ export class XtreamCollectionDetailComponent {
params: of(routeParams),
},
},
{
provide: XTREAM_SERIES_RESUME_TARGET,
useValue: this.seriesResume,
},
],
parent: this.parentInjector,
})
@@ -969,6 +969,9 @@ describe('DashboardDataService', () => {
const series = vodItems.find(
(item) => item.title === 'Color Orchard S02E04'
);
if (!series) {
throw new Error('expected the series recent item');
}
// Movie: vod position with 60% watched.
const moviePos = service.getPlaybackPositionForItem(movie!);
@@ -977,13 +980,91 @@ describe('DashboardDataService', () => {
// Series: episode position keyed by the recent item's xtream_id (the
// episode id, not the series id).
const seriesPos = service.getPlaybackPositionForItem(series!);
const seriesPos = service.getPlaybackPositionForItem(series);
expect(seriesPos?.contentType).toBe('episode');
expect(seriesPos?.positionSeconds).toBe(720);
// Season/episode metadata travels with the position so cards can
// render an "S2 · E5" badge without an extra round-trip.
expect(seriesPos?.seasonNumber).toBe(2);
expect(seriesPos?.episodeNumber).toBe(4);
expect(service.getRecentItemNavigationState(series)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
xtreamId: 900,
}),
seriesResume: {
seriesXtreamId: 900,
contentXtreamId: 909,
seasonNumber: 2,
episodeNumber: 4,
},
},
});
});
it('keeps legacy episode-keyed recents detail-only when the position row lacks the parent series id', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 103,
category_id: 19,
title: 'Legacy Orchard S02E04',
rating: '8.1',
viewed_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/legacy-orchard.png',
xtream_id: 909,
type: 'series',
playlist_id: 'xtream-L',
playlist_name: 'Xtream Legacy',
},
]);
playlistsSignal.set([
...playlistsSignal(),
{
_id: 'xtream-L',
title: 'Xtream Legacy',
count: 1,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://legacy.example.com',
},
]);
// Rows saved before seriesXtreamId existed: keyed by the episode id
// with no pointer back to the parent series.
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
{
contentXtreamId: 909,
contentType: 'episode',
seasonNumber: 2,
episodeNumber: 4,
positionSeconds: 720,
durationSeconds: 1800,
playlistId: 'xtream-L',
} as PlaybackPositionData,
]);
await service.reloadGlobalRecentItems();
await service.reloadPlaybackPositions();
const series = service
.globalRecentVodItems()
.find((item) => item.title === 'Legacy Orchard S02E04');
if (!series) {
throw new Error('expected the series recent item');
}
// The episode id must not be promoted to a series id: no resume
// target, and the detail item keeps its original identifier.
expect(service.getRecentItemNavigationState(series)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
xtreamId: 909,
}),
},
});
});
it('resolves the latest episode position for series whose recent_items row carries the series id', async () => {
@@ -1077,6 +1158,21 @@ describe('DashboardDataService', () => {
expect(position?.contentType).toBe('episode');
expect(position?.seasonNumber).toBe(3);
expect(position?.episodeNumber).toBe(7);
expect(service.getRecentItemNavigationState(series)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
contentType: 'series',
sourceType: 'xtream',
xtreamId: 4000,
}),
seriesResume: {
seriesXtreamId: 4000,
contentXtreamId: 4007,
seasonNumber: 3,
episodeNumber: 7,
},
},
});
expect(failOnLinearScan).not.toHaveBeenCalled();
});
@@ -893,7 +893,10 @@ export class DashboardDataService {
getRecentItemNavigationState(
item: GlobalRecentItem
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigationStateUtil(item);
return getRecentItemNavigationStateUtil(
item,
this.getPlaybackPositionForItem(item)
);
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
@@ -1,5 +1,6 @@
import {
PlaylistMeta,
PlaybackPositionData,
PortalActivityType,
PortalAddedItem,
PortalFavoriteItem,
@@ -12,6 +13,7 @@ import {
getGlobalFavoriteNavigation,
getRecentItemNavigation,
WorkspaceNavigationTarget,
type SeriesResumeTarget,
} from '@iptvnator/portal/shared/util';
/**
@@ -58,9 +60,54 @@ export function getRecentItemLink(item: PortalRecentItem): string[] {
}
export function getRecentItemNavigationState(
item: PortalRecentItem
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): WorkspaceNavigationTarget['state'] {
return getRecentItemNavigation(item).state;
return getRecentItemNavigation(
item,
buildRecentSeriesResumeTarget(item, playbackPosition)
).state;
}
function buildRecentSeriesResumeTarget(
item: PortalRecentItem,
playbackPosition?: PlaybackPositionData | null
): SeriesResumeTarget | null {
if (
item.type !== 'series' ||
item.source !== 'xtream' ||
playbackPosition?.contentType !== 'episode'
) {
return null;
}
// Episode-keyed recent rows make item.xtream_id an episode id, so only a
// position row that names its parent series can produce a resume target;
// legacy rows without seriesXtreamId stay detail-only.
const seriesXtreamId = Number(playbackPosition.seriesXtreamId);
const contentXtreamId = Number(playbackPosition.contentXtreamId);
const seasonNumber = Number(playbackPosition.seasonNumber);
const episodeNumber = Number(playbackPosition.episodeNumber);
if (
!Number.isInteger(seriesXtreamId) ||
seriesXtreamId <= 0 ||
!Number.isInteger(contentXtreamId) ||
contentXtreamId <= 0 ||
!Number.isInteger(seasonNumber) ||
seasonNumber < 0 ||
!Number.isInteger(episodeNumber) ||
episodeNumber < 0
) {
return null;
}
return {
seriesXtreamId,
contentXtreamId,
seasonNumber,
episodeNumber,
};
}
export function getGlobalFavoriteLink(item: PortalFavoriteItem): string[] {