From 643dee1be37e6a1116dfbb51c3afa1cb3fa127f1 Mon Sep 17 00:00:00 2001 From: MahdiHrm <141994685+MahdiHrm@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:49:22 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 + apps/web/jest.config.ts | 1 + .../global-collection-route.component.spec.ts | 140 ++++++++ .../app/global-collection-route.component.ts | 23 +- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + apps/web/src/test-stubs/video-js.js | 43 +++ docs/architecture/embedded-inline-playback.md | 10 + docs/architecture/portal-detail-navigation.md | 14 + docs/architecture/workspace-dashboard.md | 12 +- .../unified-collection-detail.directive.ts | 6 +- .../unified-collection-page.component.spec.ts | 105 +++++- .../unified-collection-page.component.ts | 35 +- .../workspace-portal-navigation.spec.ts | 134 +++++++ .../navigation/workspace-portal-navigation.ts | 84 ++++- .../util/src/lib/series-quick-start.spec.ts | 36 ++ .../shared/util/src/lib/series-quick-start.ts | 41 ++- ...al-details-playback-position-state.spec.ts | 303 ++++++++++++++++ .../serial-details-playback-position-state.ts | 165 +++++++++ .../serial-details-playback.service.ts | 89 +++-- ...serial-details-resume-target.token.spec.ts | 10 + .../serial-details-resume-target.token.ts | 8 + .../serial-details.component.html | 5 +- .../serial-details.component.spec.ts | 333 +++++++++++++++++- ...xtream-collection-detail.component.spec.ts | 41 ++- .../lib/xtream-collection-detail.component.ts | 11 +- .../src/lib/dashboard-data.service.spec.ts | 98 +++++- .../src/lib/dashboard-data.service.ts | 5 +- .../src/lib/dashboard-navigation.util.ts | 51 ++- 45 files changed, 1735 insertions(+), 88 deletions(-) create mode 100644 apps/web/src/app/global-collection-route.component.spec.ts create mode 100644 apps/web/src/test-stubs/video-js.js create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.ts diff --git a/CLAUDE.md b/CLAUDE.md index dfad56813..a640accb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**: diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 6145bb3e0..b6ba9eba2 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -39,6 +39,7 @@ export default { tslib: 'tslib/tslib.es6.js', '^iptv-playlist-parser$': '/src/test-stubs/iptv-playlist-parser.mjs', + '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', '^uuid$': '/../../node_modules/uuid/wrapper.mjs', }, diff --git a/apps/web/src/app/global-collection-route.component.spec.ts b/apps/web/src/app/global-collection-route.component.spec.ts new file mode 100644 index 000000000..fb577cbfe --- /dev/null +++ b/apps/web/src/app/global-collection-route.component.spec.ts @@ -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(null); + readonly seriesResume = input(null); + readonly closeRequested = output(); +} + +jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({ + XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent, +})); + +@Component({ + imports: [GlobalCollectionDetailHostComponent], + template: ` + + `, +}) +class WrapperComponent { + readonly item = signal(null); + readonly seriesResume = signal(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; + + 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 { + 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(); + }); +}); diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index a560d385f..32542d89d 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -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(null); + readonly seriesResume = input(null); readonly closeRequested = output(); 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 { 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()" > - + @if (item.sourceType === 'xtream') { - + } @else if (item.sourceType === 'stalker') { ({ + 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; diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 69f7c642d..6dbb15a33 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 68ded27f2..6b684b99f 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -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. diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 011a3a86a..913421a86 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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`. diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail.directive.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail.directive.ts index bb9bc0527..268607eb5 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail.directive.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail.directive.ts @@ -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; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 9e2aa5e2b..13b303ec1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -76,8 +76,15 @@ class StubUnifiedGridTabComponent { [portalType]="portalType" [defaultScope]="defaultScope" > - +
{{ item.name }}
+
+ {{ seriesResume?.contentXtreamId }} +
`, @@ -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]: { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index f043383f3..81df4588c 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -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(null); + readonly selectedDetailSeriesResume = signal( + 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; } diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts index ab9a748d0..8a0640f4b 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -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( diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index f4b68cd9b..4424b8d3c 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -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 { diff --git a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts index 5be1358f2..04044bf14 100644 --- a/libs/portal/shared/util/src/lib/series-quick-start.spec.ts +++ b/libs/portal/shared/util/src/lib/series-quick-start.spec.ts @@ -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); diff --git a/libs/portal/shared/util/src/lib/series-quick-start.ts b/libs/portal/shared/util/src/lib/series-quick-start.ts index 4a2d243e0..582f4ef7c 100644 --- a/libs/portal/shared/util/src/lib/series-quick-start.ts +++ b/libs/portal/shared/util/src/lib/series-quick-start.ts @@ -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; 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 { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts new file mode 100644 index 000000000..d0dd1afee --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.spec.ts @@ -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 { + 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 { + 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 { + 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((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 }) + ); + }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts new file mode 100644 index 000000000..4f0f8283a --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback-position-state.ts @@ -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>(new Map()); + + private loadRequestId = 0; + private readonly loadedKey = signal(null); + private readonly consumedResumeKey = signal(null); + + reset(): void { + this.loadRequestId++; + this.positions.set(new Map()); + this.loadedKey.set(null); + } + + async load( + playlistId: string, + seriesXtreamId: number, + loader: () => Promise + ): Promise { + 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, + save: ( + playlistId: string, + position: PlaybackPositionData + ) => Promise + ): Promise { + 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}`; + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 47a9bf579..8669ed032 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -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( 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(null); - readonly episodePlaybackPositions = signal< - Map - >(new Map()); + readonly episodePlaybackPositions = this.playbackPositionState.positions; readonly openingEpisodeId = signal(null); readonly activeEpisodeId = signal(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 { - const positions = - await this.playbackPositions.getSeriesPlaybackPositions( + return this.playbackPositionState.load(playlistId, seriesXtreamId, () => + this.playbackPositions.getSeriesPlaybackPositions( playlistId, seriesXtreamId - ); - const positionsMap = new Map(); - 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 | 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); - } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.spec.ts new file mode 100644 index 000000000..98d4aab92 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.spec.ts @@ -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(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.ts new file mode 100644 index 000000000..b9bebb053 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-resume-target.token.ts @@ -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 +>('XTREAM_SERIES_RESUME_TARGET', { + factory: () => signal(null), +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index a46abaf26..f10e52862 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -102,7 +102,10 @@ - {{ action.labelKey | translate }} + {{ + action.labelKey + | translate: action.labelParams + }} {{ action.episodeLabel }} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 1ade24b8b..b3997d8ea 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -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 + >; 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(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(); diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts index 2869f20d9..98b91e9e2 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -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); }); }); diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index e4020ffba..e8d098126 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -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(null); + readonly seriesResume = input(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, }) diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 7db523906..f2124918c 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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(); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 989c839cd..5b399effd 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts index 6b8993d1b..eb0ea59af 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.util.ts @@ -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[] {