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

+2
View File
@@ -646,8 +646,10 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
**Radio Player**:
+1
View File
@@ -39,6 +39,7 @@ export default {
tslib: 'tslib/tslib.es6.js',
'^iptv-playlist-parser$':
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
'^uuid$': '<rootDir>/../../node_modules/uuid/wrapper.mjs',
},
@@ -0,0 +1,140 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { GlobalCollectionDetailHostComponent } from './global-collection-route.component';
@Component({
selector: 'app-xtream-collection-detail',
template: '',
})
class MockXtreamCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
}
jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({
XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent,
}));
@Component({
imports: [GlobalCollectionDetailHostComponent],
template: `
<app-global-collection-detail-host
[item]="item()"
[seriesResume]="seriesResume()"
(closeRequested)="closeCount = closeCount + 1"
/>
`,
})
class WrapperComponent {
readonly item = signal<UnifiedCollectionItem | null>(null);
readonly seriesResume = signal<SeriesResumeTarget | null>(null);
closeCount = 0;
}
const xtreamSeriesItem: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
describe('GlobalCollectionDetailHostComponent', () => {
let fixture: ComponentFixture<WrapperComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WrapperComponent],
}).compileComponents();
fixture = TestBed.createComponent(WrapperComponent);
});
afterEach(() => {
fixture?.destroy();
});
function queryDetail() {
return fixture.debugElement.query(
By.directive(MockXtreamCollectionDetailComponent)
);
}
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('mocks the xtream feature barrel', async () => {
const mod = await import('@iptvnator/portal/xtream/feature');
expect(mod.XtreamCollectionDetailComponent).toBe(
MockXtreamCollectionDetailComponent as never
);
});
it('renders nothing until an item arrives', async () => {
await stabilize();
expect(queryDetail()).toBeNull();
});
it('ignores sources without a collection detail component', async () => {
fixture.componentInstance.item.set({
...xtreamSeriesItem,
uid: 'm3u::pl-1::vod:1',
sourceType: 'm3u' as UnifiedCollectionItem['sourceType'],
playlistId: 'pl-1',
});
await stabilize();
expect(queryDetail()).toBeNull();
});
it('creates the Xtream detail with the series resume target attached', async () => {
const seriesResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentInstance.seriesResume.set(seriesResume);
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
const detail = queryDetail();
expect(detail).not.toBeNull();
const instance =
detail?.componentInstance as MockXtreamCollectionDetailComponent;
expect(instance.item()?.xtreamId).toBe(103);
expect(instance.seriesResume()).toEqual(seriesResume);
});
it('re-emits close requests and clears the detail with the item', async () => {
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
const instance = queryDetail()
?.componentInstance as MockXtreamCollectionDetailComponent;
instance.closeRequested.emit();
expect(fixture.componentInstance.closeCount).toBe(1);
fixture.componentInstance.item.set(null);
await stabilize();
expect(queryDetail()).toBeNull();
});
});
@@ -24,6 +24,7 @@ import {
import {
CollectionScope,
PortalProvider,
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
@@ -68,6 +69,7 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
})
export class GlobalCollectionDetailHostComponent implements OnDestroy {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
private readonly viewContainer = inject(ViewContainerRef);
@@ -78,9 +80,10 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
constructor() {
effect(() => {
const item = this.item();
const seriesResume = this.seriesResume();
untracked(() => {
void this.renderDetail(item);
void this.renderDetail(item, seriesResume);
});
});
}
@@ -91,7 +94,8 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
}
private async renderDetail(
item: UnifiedCollectionItem | null
item: UnifiedCollectionItem | null,
seriesResume: SeriesResumeTarget | null
): Promise<void> {
const requestId = ++this.renderRequestId;
this.clearDetail();
@@ -109,6 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
if (item.sourceType === 'xtream') {
componentRef.setInput('seriesResume', seriesResume);
}
this.subscribeToClose(componentRef.instance);
}
@@ -175,9 +182,17 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
[portalType]="activePortalType() ?? undefined"
[defaultScope]="effectiveDefaultScope()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
<ng-template
unifiedCollectionDetail
let-item
let-seriesResume="seriesResume"
let-close="close"
>
@if (item.sourceType === 'xtream') {
<app-global-collection-detail-host [item]="item" />
<app-global-collection-detail-host
[item]="item"
[seriesResume]="seriesResume"
/>
} @else if (item.sourceType === 'stalker') {
<app-global-collection-detail-host
[item]="item"
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "البدء من جديد",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
"PLAY_EPISODE": "تشغيل الحلقة {{episode}}",
"RESUME_EPISODE": "استئناف الحلقة",
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "بدا من الأول",
"CONTINUE_WATCHING": "كمل المشاهدة",
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
"PLAY_EPISODE": "شغل الحلقة {{episode}}",
"RESUME_EPISODE": "كمل الحلقة",
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
"SERIES_WATCHED": "السلسلة متشافة",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Спачатку",
"CONTINUE_WATCHING": "Працягнуць прагляд",
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
"PLAY_EPISODE": "Прайграць эпізод {{episode}}",
"RESUME_EPISODE": "Працягнуць эпізод",
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
"SERIES_WATCHED": "Серыял прагледжаны",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Neu starten",
"CONTINUE_WATCHING": "Weiter schauen",
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
"PLAY_EPISODE": "Folge {{episode}} abspielen",
"RESUME_EPISODE": "Folge fortsetzen",
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
"SERIES_WATCHED": "Serie angesehen",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Από την αρχή",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
"PLAY_EPISODE": "Αναπαραγωγή επεισοδίου {{episode}}",
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Start Over",
"CONTINUE_WATCHING": "Continue Watching",
"PLAY_FIRST_EPISODE": "Play first episode",
"PLAY_EPISODE": "Play episode {{episode}}",
"RESUME_EPISODE": "Resume episode",
"PLAY_NEXT_EPISODE": "Play next episode",
"SERIES_WATCHED": "Series watched",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Reiniciar",
"CONTINUE_WATCHING": "Continuar viendo",
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
"PLAY_EPISODE": "Reproducir episodio {{episode}}",
"RESUME_EPISODE": "Continuar episodio",
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
"SERIES_WATCHED": "Serie vista",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Recommencer",
"CONTINUE_WATCHING": "Reprendre la lecture",
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
"PLAY_EPISODE": "Lire l'épisode {{episode}}",
"RESUME_EPISODE": "Reprendre l'épisode",
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
"SERIES_WATCHED": "Série vue",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Ricomincia",
"CONTINUE_WATCHING": "Continua a guardare",
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
"PLAY_EPISODE": "Riproduci episodio {{episode}}",
"RESUME_EPISODE": "Riprendi episodio",
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
"SERIES_WATCHED": "Serie vista",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "最初から再生",
"CONTINUE_WATCHING": "視聴を再開",
"PLAY_FIRST_EPISODE": "第1話を再生",
"PLAY_EPISODE": "第{{episode}}話を再生",
"RESUME_EPISODE": "エピソードを再開",
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
"SERIES_WATCHED": "シリーズ視聴済み",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "처음부터 보기",
"CONTINUE_WATCHING": "이어서 시청",
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
"PLAY_EPISODE": "에피소드 {{episode}} 재생",
"RESUME_EPISODE": "에피소드 이어 보기",
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
"SERIES_WATCHED": "시리즈 시청 완료",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Opnieuw beginnen",
"CONTINUE_WATCHING": "Verder kijken",
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
"PLAY_EPISODE": "Aflevering {{episode}} afspelen",
"RESUME_EPISODE": "Aflevering hervatten",
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
"SERIES_WATCHED": "Serie bekeken",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Zacznij od nowa",
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
"PLAY_EPISODE": "Odtwórz odcinek {{episode}}",
"RESUME_EPISODE": "Wznów odcinek",
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
"SERIES_WATCHED": "Serial obejrzany",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Recomeçar",
"CONTINUE_WATCHING": "Continuar assistindo",
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
"PLAY_EPISODE": "Reproduzir episódio {{episode}}",
"RESUME_EPISODE": "Continuar episódio",
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
"SERIES_WATCHED": "Série assistida",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "С начала",
"CONTINUE_WATCHING": "Продолжить просмотр",
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
"PLAY_EPISODE": "Воспроизвести серию {{episode}}",
"RESUME_EPISODE": "Продолжить серию",
"PLAY_NEXT_EPISODE": "Следующая серия",
"SERIES_WATCHED": "Сериал просмотрен",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Baştan Başla",
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
"PLAY_EPISODE": "{{episode}}. bölümü oynat",
"RESUME_EPISODE": "Bölüme devam et",
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
"SERIES_WATCHED": "Dizi izlendi",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "从头开始",
"CONTINUE_WATCHING": "继续观看",
"PLAY_FIRST_EPISODE": "播放第一集",
"PLAY_EPISODE": "播放第 {{episode}} 集",
"RESUME_EPISODE": "继续播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "剧集已看完",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "從頭開始",
"CONTINUE_WATCHING": "繼續觀看",
"PLAY_FIRST_EPISODE": "播放第一集",
"PLAY_EPISODE": "播放第 {{episode}} 集",
"RESUME_EPISODE": "繼續播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "劇集已看完",
+43
View File
@@ -0,0 +1,43 @@
// The video.js CJS bundle fails to evaluate under the web project's ESM jest
// preset, so specs that transitively import the vjs player get this stub. It
// stays CommonJS because videojs plugin packages require() it at module load.
class StubComponent {
createEl() {
return {};
}
}
class StubPlugin {}
class StubEventTarget {
on() {
return undefined;
}
off() {
return undefined;
}
one() {
return undefined;
}
trigger() {
return undefined;
}
}
const videoJs = () => ({
dispose: () => undefined,
on: () => undefined,
off: () => undefined,
src: () => undefined,
});
videoJs.getComponent = () => StubComponent;
videoJs.registerComponent = () => undefined;
videoJs.getPlugin = () => StubPlugin;
videoJs.registerPlugin = () => undefined;
videoJs.EventTarget = StubEventTarget;
videoJs.dom = { createEl: () => ({}) };
videoJs.browser = {};
module.exports = videoJs;
module.exports.default = videoJs;
@@ -176,6 +176,13 @@ When a detail view starts playback:
The detail or collection/search host owns inline state. `PlayerService` is not
an owner of embedded UI playback state.
After a successful external episode launch, the detail host immediately
persists that episode as the latest playback-position entry, preserving an
existing resume offset or using zero for a newly opened episode. MPV/VLC
position telemetry overwrites this launch marker when available. This keeps the
last-watched season and episode correct even when an external player's progress
interface is unavailable; exact external timestamps remain best-effort.
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
@@ -189,6 +196,9 @@ Current contract:
`S01E02 · Episode title`
- if an episode is in progress, resume the latest updated in-progress episode
with its saved offset
- if the newest episode entry is a successful external-player launch marker
with no meaningful progress yet, target it with `Play episode N` instead of
falling back to the first episode
- if no episode is in progress, play the first unwatched episode in season order
- if watched episodes end at a season boundary, play the first episode of the
next loaded season
@@ -23,6 +23,12 @@ Related:
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
Stalker movies/series into the matching global collection route with detail
pre-opened.
- Dashboard Continue Watching handoffs for Xtream series may additionally carry
a one-shot season/episode resume target. The collection-owned Xtream detail
consumes it after its episode positions load and starts that exact episode;
opening the series from the collection grid itself remains detail-only. If the
positions load fails, the target stays unconsumed and the handoff degrades to
detail-only rather than starting the episode at offset zero.
- Do not force both portals into the same browse/detail behavior unless the full
portal detail architecture is being changed.
@@ -73,6 +79,14 @@ Dashboard behavior to preserve:
Xtream movie/series items into `/workspace/global-favorites` or
`/workspace/global-recent` with collection detail pre-opened from navigation
state.
- When an Xtream series recent has a saved episode position, the dashboard hero
and Continue Watching card should include that exact series/episode target in
the navigation state. It is a one-shot playback request and must not leak into
normal favorites, search, category, or collection-grid navigation. Only
position rows that name their parent `seriesXtreamId` produce a target:
episode-keyed recents make `item.xtream_id` an episode id, so legacy rows
without the pointer stay detail-only instead of promoting the episode id to a
series id.
- Back from the collection detail should return to the dashboard handoff state,
not switch the active playlist.
+10 -2
View File
@@ -97,6 +97,11 @@ Render rules:
hero and cards can show progress, remaining time, and series season/
episode badges. Series lookup uses keyed maps for both direct episode ids
and series ids; card renders must not scan the full playback-position map.
Dashboard-originated Xtream series clicks also carry that exact episode
target through the global-recent inline-detail handoff. Once the series
metadata and playback positions load, the detail player consumes the
target once and resumes the saved episode. Opening the same item normally
from the global recent grid remains a detail-only action.
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
falling back to recently watched live channels when no live favorites
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
@@ -174,9 +179,12 @@ The welcome state is rendered via the existing
rails have data.
5. Navigation from a rail card must deep-link into the appropriate workspace
route without switching the active playlist in the header switcher.
6. `Recently Used Sources` reflects recent source usage across all provider
6. Xtream series hero/Continue Watching clicks with a saved episode position
must resume that exact episode while preserving the collection-owned detail
and Back behavior. Do not apply autoplay to ordinary collection-grid clicks.
7. `Recently Used Sources` reflects recent source usage across all provider
types, not just recent imports.
7. The live rail title key must match the rendered source: favorites use
8. The live rail title key must match the rendered source: favorites use
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
@@ -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[] {