diff --git a/.changes/stalker-series-next-episode-hydration.md b/.changes/stalker-series-next-episode-hydration.md new file mode 100644 index 0000000000..2fbcb5da1a --- /dev/null +++ b/.changes/stalker-series-next-episode-hydration.md @@ -0,0 +1,7 @@ +--- +type: fix +area: stalker +issues: [2123] +--- + +Stalker series now load season progress when opening a show and point the next-episode button and season picker to where you left off, even when skipping ahead to a later season. diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 6ef5d85583..ba1340c4c1 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -281,20 +281,17 @@ drawn in the player's `--pc-progress` like the dock's Up next card. Season navigation inside `SeasonContainerComponent` uses season tabs (`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old -seasons-grid + "Back to seasons" level. A season is auto-selected -(inline-playing episode's season → most recently updated in-progress -episode's season → earliest season with unwatched episodes → latest -non-empty season; Stalker lazy-VOD series with unhydrated seasons pin -the fallback to the first season still to load or holding episodes -(`unloadedSeasonKeys`: a season the portal answered empty is passed over) -because a pending season's watched state is unknown, -and the empty→loaded positions flip caused by the session's own watched -toggles never re-resolves the selection). Extras (season 0, by the -episodes' season or, unloaded, the key: `isExtrasSeason`) steer none of -the last three while the series' other seasons hold, or may still hold, -episodes, as with its next episode, so the page does not open on Specials; -only a playing extra opens its season. The -auto-selection emits `seasonSelected`, +seasons-grid + "Back to seasons" level. The picker opens the inline-playing +episode's season, then tries the most recent in-progress episode's season, +then the first season with an unwatched episode or pending list after the last +watched episode. It falls back to the earliest unwatched season or the latest +non-empty season when the series is watched. With pending regular seasons and +no next season derived from progress, Stalker opens the first season still to +load or holding episodes. A manual pick or local watched toggle holds the +selection while progress arrives for the current series. Extras (season 0) +stay outside auto-selection while regular seasons have episodes or are still +pending. The currently playing episode can still open its Extras season. +Auto-selection emits `seasonSelected`, so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch, TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open without a click. Switching tabs never stops playback; a "back to playing diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 211fe7156f..f492df9978 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1645,8 +1645,9 @@ records. Both accept the same closed set: boolean `true`, numeric `1`, or string determine completion. Legacy array replies without paging metadata stay single-response lists. Empty early pages, malformed replies, repeated pages and the 1000-page safety bound fail the load rather than publish a partial - list. Navigation retires the collection before another page or state write; - an HTTP request already in flight may still finish. Other seasons stay lazy. + list. Changing the selected series or closing its view retires episode + loading before another page or state write. An HTTP request already in flight + may still finish. - The season resource depends on the VOD item id and series mode, so a TMDB metadata patch does not reload seasons or discard loaded episodes. Pending episode requests belong to the exact loading season VM; replies from an old @@ -1658,14 +1659,17 @@ records. Both accept the same closed set: boolean `true`, numeric `1`, or string Multi-season items keep provider numbering. Lazy VOD retains the original season key/number separately for stable tracking IDs and compatible legacy progress; provider request ids remain unchanged. -- The series quick-start CTA can load the first unloaded VOD-series season - before playback. Unloaded seasons are considered unplayed in full season - order, so an earlier unloaded season is not skipped just because a later - season was loaded manually. If all currently loaded episodes are watched and - more season metadata exists, quick start loads the next unloaded season - instead of showing the completed state. After a lazy load, quick start is - recomputed from the mapped episodes before playback so provider episode - ordering cannot start the wrong episode. +- When a lazy VOD series opens with saved positions, pending regular seasons + load one at a time to fill episode and watched counts and reconcile progress. + Failed requests are skipped for the current season list and can be retried by + selecting the season. A reused view continues with the current series after + an older request settles. Without saved positions, seasons load on demand. +- Quick start preserves the shared resume and recent-launch actions. Otherwise + it follows the first unwatched episode after the most recently watched one, + loading a pending season in that range before falling back to skipped + episodes. After loading, it recomputes the action against the actual episode + order. The season picker follows watched progress as episode lists arrive and + preserves a manual choice or watched toggle within the current series. - Extras (season 0) follow the shared next-episode rule (`getSeriesNextUp`): while the series has other seasons they never become the quick-start target, and the lazy load before playback picks the first regular season the portal diff --git a/libs/portal/shared/util/src/lib/series-next-up.ts b/libs/portal/shared/util/src/lib/series-next-up.ts index 02f3e37935..6f7f79ea28 100644 --- a/libs/portal/shared/util/src/lib/series-next-up.ts +++ b/libs/portal/shared/util/src/lib/series-next-up.ts @@ -126,7 +126,7 @@ function getOrderedSeriesEpisodes( naturalCollator.compare(seasonA, seasonB) ) .forEach(([seasonKey, episodes]) => { - [...episodes].sort(compareEpisodes).forEach((episode) => { + [...episodes].sort(compareSeriesEpisodes).forEach((episode) => { const seasonNumber = episodeSeasonNumber(episode, seasonKey); let position = request.playbackPositions.get(Number(episode.id)) ?? null; @@ -173,7 +173,7 @@ function episodeSeasonNumber( return toSeasonNumber(episode.season) ?? toSeasonNumber(seasonKey); } -function compareEpisodes( +export function compareSeriesEpisodes( episodeA: XtreamSerieEpisode, episodeB: XtreamSerieEpisode ): number { @@ -214,8 +214,8 @@ function compareEntryRecency( entryB: SeriesEpisodeEntry ): number { const delta = - getTimestamp(entryA.position?.updatedAt) - - getTimestamp(entryB.position?.updatedAt); + getSeriesPositionTimestamp(entryA.position?.updatedAt) - + getSeriesPositionTimestamp(entryB.position?.updatedAt); return delta !== 0 ? delta : entryA.order - entryB.order; } @@ -223,7 +223,7 @@ function compareEntryRecency( * SQLite rows are UTC without a zone, which `Date.parse` alone would read as * local time and misorder against the ISO times the renderer writes. */ -function getTimestamp(value: string | undefined): number { +export function getSeriesPositionTimestamp(value: string | undefined): number { if (!value) { return 0; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts index 1b24478ac5..09b3fdf13a 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.spec.ts @@ -358,6 +358,26 @@ describe('withStalkerSeries serialSeasonsResource gating', () => { expect(store.vodSeriesEpisodes()).toEqual([]); }); + it('stops pagination when the episode view retires', async () => { + let viewOpen = true; + dataService.sendIpcEvent.mockImplementationOnce(async () => { + viewOpen = false; + return { + js: { + total_items: 2, + data: [ + { id: 'e1', is_episode: true, series_number: 1 }, + ], + }, + }; + }); + await expect( + store.fetchVodSeriesEpisodes('11', 's1', () => viewOpen) + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1); + expect(store.vodSeriesEpisodes()).toEqual([]); + }); + it('trusts a well-formed empty answer in either envelope shape', async () => { dataService.sendIpcEvent.mockResolvedValue({ js: { data: [] } }); await expect( diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts index 3b2899974f..eb66a42356 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-series.feature.ts @@ -257,7 +257,8 @@ export function withStalkerSeries() { return { async fetchVodSeriesEpisodes( videoId: string, - seasonId: string + seasonId: string, + isCurrent: () => boolean = () => true ): Promise { const playlist = storeContext.currentPlaylist(); if (!playlist) { @@ -281,6 +282,7 @@ export function withStalkerSeries() { } ), () => + isCurrent() && storeContext.currentPlaylist() === playlist && storeContext.selectedItem()?.id === diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.spec.ts index 06db2eb859..b4aaf66e01 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.spec.ts @@ -163,4 +163,71 @@ describe('getStalkerSeriesQuickStartButton with extras (season 0)', () => { expect(button?.lazySeason?.season_number).toBe('0'); }); + + it('offers the next unwatched episode after the last watched episode when skipping ahead', () => { + const button = getStalkerSeriesQuickStartButton({ + isVodSeries: true, + mappedSeasons: { + '1': [], + '2': [], + '3': [], + '4': mapped(4, 4), + }, + playbackPositions: watched(401, 402), + vodSeriesSeasons: [ + season('1', 0), + season('2', 0), + season('3', 0), + season('4', 4), + ], + }); + + expect(button?.lazySeason).toBeNull(); + expect(button?.action?.episode.id).toBe('403'); + expect(button?.episodeLabel).toContain('S04E03'); + }); + + it('keeps the most recent short launch even with a later season pending', () => { + const playbackPositions = watched(401, 402); + playbackPositions.set(101, { + contentXtreamId: 101, + contentType: 'episode', + positionSeconds: 5, + durationSeconds: 100, + updatedAt: '2026-10-05T12:00:00.000Z', + }); + const button = getStalkerSeriesQuickStartButton({ + isVodSeries: true, + mappedSeasons: { '1': mapped(1, 2), '4': mapped(4, 2), '5': [] }, + playbackPositions, + vodSeriesSeasons: [season('1', 2), season('4', 2), season('5', 0)], + }); + + expect(button?.lazySeason).toBeNull(); + expect(button?.action?.episode.id).toBe('101'); + }); + + it('loads the next unloaded season after a finished skipped-ahead season instead of wrapping to season 1', () => { + const button = getStalkerSeriesQuickStartButton({ + isVodSeries: true, + mappedSeasons: { + '1': mapped(1, 2), + '2': [], + '3': [], + '4': mapped(4, 2), + '5': [], + }, + playbackPositions: watched(401, 402), + vodSeriesSeasons: [ + season('1', 2), + season('2', 0), + season('3', 0), + season('4', 2), + season('5', 0), + ], + }); + + expect(button?.lazySeason?.season_number).toBe('5'); + expect(button?.episodeLabel).toBe('S05E01'); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts index c8ece3c8ae..4294aae9c0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-quick-start.ts @@ -1,8 +1,10 @@ import { SERIES_QUICK_START_ACTION_KIND, formatSeriesEpisodeCode, + getSeriesPositionTimestamp, getSeriesQuickStartAction, isExtrasSeason, + isPortalPlaybackWatched, type SeriesQuickStartAction, } from '@iptvnator/portal/shared/util'; import { @@ -52,6 +54,7 @@ export function getStalkerSeriesQuickStartButton( ? getQuickStartLazyVodSeriesSeason( action, request.mappedSeasons, + request.playbackPositions, orderedVodSeriesSeasons ) : null; @@ -99,6 +102,7 @@ function getOrderedVodSeriesSeasons( function getQuickStartLazyVodSeriesSeason( action: SeriesQuickStartAction | null, mappedSeasons: Record, + playbackPositions: ReadonlyMap, seasons: ReadonlyArray ): VodSeriesSeasonVm | null { const firstUnloadedSeason = getFirstUnloadedVodSeriesSeason( @@ -128,12 +132,48 @@ function getQuickStartLazyVodSeriesSeason( return firstUnloadedSeason; } - if (action.kind === SERIES_QUICK_START_ACTION_KIND.Resume) { + if ( + action.kind === SERIES_QUICK_START_ACTION_KIND.Resume || + action.kind === SERIES_QUICK_START_ACTION_KIND.PlayRecent + ) { return null; } - if (action.kind === SERIES_QUICK_START_ACTION_KIND.Completed) { - return firstUnloadedSeason; + const lastWatchedSeasonIndex = findLastWatchedVodSeriesSeasonIndex( + seasons, + mappedSeasons, + playbackPositions + ); + + if ( + action.kind === SERIES_QUICK_START_ACTION_KIND.Completed || + (lastWatchedSeasonIndex !== -1 && + actionSeasonIndex !== -1 && + actionSeasonIndex < lastWatchedSeasonIndex) + ) { + const nextUnloadedAfterWatched = + lastWatchedSeasonIndex !== -1 + ? getFirstUnloadedVodSeriesSeason( + seasons.slice(lastWatchedSeasonIndex + 1), + mappedSeasons + ) + : null; + if (nextUnloadedAfterWatched) { + return nextUnloadedAfterWatched; + } + if (action.kind === SERIES_QUICK_START_ACTION_KIND.Completed) { + return firstUnloadedSeason; + } + } + + if ( + lastWatchedSeasonIndex !== -1 && + actionSeasonIndex >= lastWatchedSeasonIndex + ) { + return getFirstUnloadedVodSeriesSeason( + seasons.slice(lastWatchedSeasonIndex + 1, actionSeasonIndex), + mappedSeasons + ); } const unloadedSeasonIndex = seasons.findIndex( @@ -147,6 +187,34 @@ function getQuickStartLazyVodSeriesSeason( : null; } +function findLastWatchedVodSeriesSeasonIndex( + seasons: ReadonlyArray, + mappedSeasons: Record, + playbackPositions: ReadonlyMap +): number { + let lastWatchedSeasonIndex = -1; + let bestTimestamp = -1; + for (let index = 0; index < seasons.length; index++) { + const season = seasons[index]; + if (isExtrasVodSeason(season, mappedSeasons)) { + continue; + } + const episodes = mappedSeasons[getVodSeriesSeasonKey(season)] ?? []; + for (const episode of episodes) { + const position = playbackPositions.get(Number(episode.id)); + if (!isPortalPlaybackWatched(position)) { + continue; + } + const timestamp = getSeriesPositionTimestamp(position?.updatedAt); + if (lastWatchedSeasonIndex === -1 || timestamp >= bestTimestamp) { + bestTimestamp = timestamp; + lastWatchedSeasonIndex = index; + } + } + } + return lastWatchedSeasonIndex; +} + /** * The first season the portal has not answered for yet (an empty answer is * loaded, not pending). Extras (season 0) are passed over while the series diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 322bbdba4d..8ee8a42e51 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -1,4 +1,7 @@ -import { StalkerEpisodeResolver } from '@iptvnator/portal/stalker/data-access'; +import { + StalkerEpisodeResolver, + mapVodSeriesEpisodes, +} from '@iptvnator/portal/stalker/data-access'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; @@ -19,6 +22,7 @@ describe('StalkerSeriesViewComponent', () => { vodSeriesSeasonsResource, fetchVodSeriesEpisodes, resolveVodPlayback, + getSeriesPlaybackPositions, openResolvedPlayback, openExternalPlayback, isEmbeddedPlayer, @@ -391,7 +395,8 @@ describe('StalkerSeriesViewComponent', () => { expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( '50001', - 'season-1' + 'season-1', + expect.any(Function) ); expect(resolveVodPlayback).toHaveBeenCalledWith( '/media/file_episode-1.mpg', @@ -503,7 +508,8 @@ describe('StalkerSeriesViewComponent', () => { expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( '50001', - 'season-1' + 'season-1', + expect.any(Function) ); expect(resolveVodPlayback).toHaveBeenCalledWith( '/media/file_episode-1.mpg', @@ -765,7 +771,8 @@ describe('StalkerSeriesViewComponent', () => { expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( '50001', - 'season-2' + 'season-2', + expect.any(Function) ); expect(resolveVodPlayback).toHaveBeenCalledWith( '/media/file_episode-2.mpg', @@ -776,4 +783,124 @@ describe('StalkerSeriesViewComponent', () => { undefined ); }); + + it('hydrates lazy VOD-series seasons when saved playback positions exist and points quick start to the next unwatched episode', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + }, + { + id: 'season-3', + video_id: '50001', + season_number: '3', + name: 'Season 3', + }, + { + id: 'season-4', + video_id: '50001', + season_number: '4', + name: 'Season 4', + }, + ]); + const seasonFourMapped = mapVodSeriesEpisodes( + [ + { + id: 'season-4', + video_id: '50001', + season_number: '4', + name: 'Season 4', + episodes: [ + { + id: 'season-4-ep-1', + series_number: 1, + name: 'season-4 Episode 1', + }, + { + id: 'season-4-ep-2', + series_number: 2, + name: 'season-4 Episode 2', + }, + ], + isLoading: false, + isExpanded: false, + }, + ], + { parentSeriesId: 50001 } + )['4']; + getSeriesPlaybackPositions.mockResolvedValue([ + { + contentXtreamId: Number(seasonFourMapped[0].id), + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 4, + episodeNumber: 1, + positionSeconds: 95, + durationSeconds: 100, + updatedAt: '2026-10-05T10:00:00.000Z', + }, + { + contentXtreamId: Number(seasonFourMapped[1].id), + contentType: 'episode', + seriesXtreamId: 50001, + seasonNumber: 4, + episodeNumber: 2, + positionSeconds: 95, + durationSeconds: 100, + updatedAt: '2026-10-05T11:00:00.000Z', + }, + ]); + fetchVodSeriesEpisodes.mockImplementation( + async (_videoId: string, seasonId: string) => [ + { + id: `${seasonId}-ep-1`, + series_number: 1, + name: `${seasonId} Episode 1`, + }, + { + id: `${seasonId}-ep-2`, + series_number: 2, + name: `${seasonId} Episode 2`, + }, + { + id: `${seasonId}-ep-3`, + series_number: 3, + name: `${seasonId} Episode 3`, + }, + ] + ); + + await stabilize(); + await settleStart(); + await stabilize(); + + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(4); + expect(fixture.componentInstance.hasUnloadedVodSeasons()).toBe(false); + expect(fixture.componentInstance.unloadedVodSeasonKeys()).toEqual([]); + + const quickStartButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + expect(quickStartButton?.textContent).toContain('S04E03'); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index e3d5021e07..a915cbae03 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -508,6 +508,24 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.positions.applyReconciledSeriesPositions(); }); + effect(() => { + const ownerKey = this.seriesPlaybackOwnerKey(); + const playlistId = this.stalkerStore.currentPlaylist()?._id ?? ''; + const seriesId = this.toSeriesId(this.displayItem()?.id ?? 0); + const canHydrate = + this.seasonContainerRef() && + this.isVodSeries() && + this.positions.seriesPositionsLoadedKey() === + this.positions.seriesPositionsKey(playlistId, seriesId) && + this.positions.rawSeriesPositions().length > 0; + this.vodSeriesSeasons(); + untracked(() => + this.vodSeasonLoader.hydrateForSavedProgress( + canHydrate ? ownerKey : null + ) + ); + }); + // Dashboard "Continue watching" handoff: once the persisted // positions for THIS series are in (so the episode resumes at its // saved offset, not from zero) and the target episode is on the @@ -913,8 +931,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly vodSeasonLoader = new StalkerVodSeasonEpisodeLoader({ seasons: this.vodSeriesSeasons, ownerKey: () => this.seriesPlaybackOwnerKey(), - fetchEpisodes: (videoId, seasonId) => - this.stalkerStore.fetchVodSeriesEpisodes(videoId, seasonId), + fetchEpisodes: (videoId, seasonId, isCurrent) => + this.stalkerStore.fetchVodSeriesEpisodes( + videoId, + seasonId, + isCurrent + ), logError: (message, error) => this.logger.error(message, error), }); @@ -1413,6 +1435,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { } ngOnDestroy(): void { + this.vodSeasonLoader.destroy(); this.unsubscribePositionUpdates?.(); this.closeInlinePlayer(); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts index 87809e8ce8..22d4e5eabf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts @@ -456,7 +456,8 @@ describe('StalkerSeriesViewComponent dashboard resume handoff', () => { expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( '50001', - 'season-1' + 'season-1', + expect.any(Function) ); expect(resolveVodPlayback).toHaveBeenCalledTimes(1); expect(resolveVodPlayback).toHaveBeenCalledWith( diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts index 24ccc4582a..7e03754a36 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts @@ -658,7 +658,11 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { seriesToggleRequest([firstId], true) ); - expect(fetch).toHaveBeenCalledWith(String(SERIES_A_ID), 'season-2'); + expect(fetch).toHaveBeenCalledWith( + String(SERIES_A_ID), + 'season-2', + expect.any(Function) + ); expect(repositoryOrder).toEqual([ `save:${firstId}`, `save:${S2_SCOPED_ID}`, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts index d7a506ff8e..413f5ea9db 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.spillover-prefetch.spec.ts @@ -101,7 +101,8 @@ describe('StalkerSeriesViewComponent Up Next spillover prefetch', () => { expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith( '50001', - 'season-2' + 'season-2', + expect.any(Function) ); const inlinePlayer = fixture.debugElement.query( diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.spec.ts new file mode 100644 index 0000000000..2e191bd3e8 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.spec.ts @@ -0,0 +1,98 @@ +import { signal } from '@angular/core'; +import type { VodSeriesSeasonVm } from '@iptvnator/portal/stalker/data-access'; +import { StalkerVodSeasonEpisodeLoader } from './stalker-vod-season-episode-loader'; + +function season(id: string): VodSeriesSeasonVm { + return { + id, + name: 'Synthetic Season', + video_id: 'video-1', + season_number: '1', + episodes: [], + isLoading: false, + isExpanded: false, + }; +} + +function setup() { + const ownerKey = signal('series-a'); + const seasons = signal([season('season-a1'), season('season-a2')]); + const fetchEpisodes = jest.fn< + Promise, + [string, string, () => boolean] + >(); + const loader = new StalkerVodSeasonEpisodeLoader({ + ownerKey, + seasons, + fetchEpisodes, + logError: jest.fn(), + }); + return { ownerKey, seasons, fetchEpisodes, loader }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('saved progress season loading', () => { + it('continues the current series after an older request settles', async () => { + const { ownerKey, seasons, fetchEpisodes, loader } = setup(); + let finishFirst!: (episodes: []) => void; + fetchEpisodes + .mockImplementationOnce( + () => new Promise((resolve) => (finishFirst = resolve)) + ) + .mockResolvedValue([]); + loader.hydrateForSavedProgress(ownerKey()); + const oldRequestIsCurrent = fetchEpisodes.mock.calls[0][2]; + + ownerKey.set('series-b'); + seasons.set([season('season-b1'), season('season-b2')]); + loader.hydrateForSavedProgress(ownerKey()); + + expect(oldRequestIsCurrent()).toBe(false); + expect(fetchEpisodes).toHaveBeenCalledTimes(1); + finishFirst([]); + await settle(); + + expect(fetchEpisodes.mock.calls.map(([, id]) => id)).toEqual([ + 'season-a1', + 'season-b1', + 'season-b2', + ]); + expect(seasons().every((item) => item.episodesLoaded)).toBe(true); + }); + + it('retires the current request and stops loading seasons on destroy', async () => { + const { ownerKey, seasons, fetchEpisodes, loader } = setup(); + let finishFirst!: (episodes: []) => void; + fetchEpisodes.mockImplementationOnce( + () => new Promise((resolve) => (finishFirst = resolve)) + ); + loader.hydrateForSavedProgress(ownerKey()); + const requestIsCurrent = fetchEpisodes.mock.calls[0][2]; + + loader.destroy(); + expect(requestIsCurrent()).toBe(false); + finishFirst([]); + await settle(); + + expect(fetchEpisodes).toHaveBeenCalledTimes(1); + expect(seasons()[0].episodesLoaded).toBeUndefined(); + }); + + it('skips a failed request during background loading and allows manual retry', async () => { + const { ownerKey, seasons, fetchEpisodes, loader } = setup(); + fetchEpisodes + .mockRejectedValueOnce(new Error('Synthetic portal failure')) + .mockResolvedValue([]); + loader.hydrateForSavedProgress(ownerKey()); + await settle(); + + expect(fetchEpisodes.mock.calls.map(([, id]) => id)).toEqual([ + 'season-a1', + 'season-a2', + ]); + expect(seasons()[0].episodesLoaded).toBeUndefined(); + await expect(loader.load(seasons()[0])).resolves.toBe(true); + expect(seasons()[0].episodesLoaded).toBe(true); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts index 585f71e05b..f6965ba11b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-vod-season-episode-loader.ts @@ -1,6 +1,8 @@ import type { WritableSignal } from '@angular/core'; +import { isExtrasSeason } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, + isVodSeasonHydrationPending, type VodSeriesSeasonVm, } from '@iptvnator/portal/stalker/data-access'; @@ -12,7 +14,8 @@ export interface StalkerVodSeasonEpisodeLoaderHost { readonly ownerKey: () => string; readonly fetchEpisodes: ( videoId: string, - seasonId: string + seasonId: string, + isCurrent: () => boolean ) => Promise; readonly logError: (message: string, error: unknown) => void; } @@ -23,9 +26,61 @@ export class StalkerVodSeasonEpisodeLoader { string, { season: VodSeriesSeasonVm; promise: Promise } >(); + private hydrationOwnerKey: string | null = null; + private hydrationInFlight = false; + private destroyed = false; + private readonly failedSeasons = new WeakSet(); constructor(private readonly host: StalkerVodSeasonEpisodeLoaderHost) {} + /** + * Loads pending regular seasons sequentially when a lazy VOD series + * has saved positions, so the picker and quick start can show progress. + */ + hydrateForSavedProgress(ownerKey: string | null): void { + this.hydrationOwnerKey = ownerKey; + this.hydrateNextSeason(); + } + + destroy(): void { + this.destroyed = true; + } + + private hydrateNextSeason(): void { + if ( + this.destroyed || + this.hydrationInFlight || + !this.hydrationOwnerKey || + this.hydrationOwnerKey !== this.host.ownerKey() + ) { + return; + } + const seasons = this.host.seasons(); + const hasRegularRun = seasons.some( + (season) => + !isExtrasSeason(getVodSeriesSeasonKey(season), null) && + (season.episodes.length > 0 || + isVodSeasonHydrationPending(season)) + ); + const nextSeason = seasons.find( + (season) => + !this.failedSeasons.has(season) && + !( + hasRegularRun && + isExtrasSeason(getVodSeriesSeasonKey(season), null) + ) && + isVodSeasonHydrationPending(season) + ); + if (!nextSeason) { + return; + } + this.hydrationInFlight = true; + void this.load(nextSeason).finally(() => { + this.hydrationInFlight = false; + this.hydrateNextSeason(); + }); + } + /** * Loads episodes for a specific VOD season. * @@ -37,8 +92,10 @@ export class StalkerVodSeasonEpisodeLoader { */ /** Resolves true when the portal answered, false when the request failed. */ load(season: VodSeriesSeasonVm): Promise { + if (this.destroyed) return Promise.resolve(false); + const ownerKey = this.host.ownerKey(); const key = JSON.stringify([ - this.host.ownerKey(), + ownerKey, season.video_id, season.id, getVodSeriesSeasonKey(season), @@ -47,79 +104,69 @@ export class StalkerVodSeasonEpisodeLoader { if (inFlight && this.host.seasons().includes(inFlight.season)) { return inFlight.promise; } - const load = this.fetch(season).finally(() => { - if (this.loads.get(key)?.promise === load) { - this.loads.delete(key); - } - }); - const loadingSeason = this.host - .seasons() - .find( - (candidate) => - candidate.id === season.id && - candidate.video_id === season.video_id - ); - if (loadingSeason) { - this.loads.set(key, { - season: loadingSeason, - promise: load, - }); - } - return load; - } - - private async fetch(season: VodSeriesSeasonVm): Promise { - // Set loading state in local signal const seasons = this.host.seasons(); const index = seasons.findIndex( - (s) => - s.id === season.id && - s.video_id === season.video_id && - getVodSeriesSeasonKey(s) === getVodSeriesSeasonKey(season) + (candidate) => + candidate.id === season.id && + candidate.video_id === season.video_id && + getVodSeriesSeasonKey(candidate) === + getVodSeriesSeasonKey(season) ); - if (index === -1) return false; + if (index === -1) return Promise.resolve(false); const updatedSeasons = [...seasons]; const loadingSeason = { ...updatedSeasons[index], isLoading: true }; updatedSeasons[index] = loadingSeason; this.host.seasons.set(updatedSeasons); + // Only the exact loading VM owns this response. Navigation or + // refresh can reuse provider ids while replacing the season list. + const isCurrent = () => + !this.destroyed && + this.host.ownerKey() === ownerKey && + this.host.seasons().includes(loadingSeason); + + const load = this.fetch(loadingSeason, isCurrent) + .then((updatedSeason) => { + const current = this.host.seasons(); + const index = current.indexOf(loadingSeason); + if (!isCurrent()) return false; + const updated = [...current]; + updated[index] = updatedSeason; + this.host.seasons.set(updated); + return !this.failedSeasons.has(updatedSeason); + }) + .finally(() => { + if (this.loads.get(key)?.promise === load) { + this.loads.delete(key); + } + }); + this.loads.set(key, { season: loadingSeason, promise: load }); + return load; + } + private async fetch( + season: VodSeriesSeasonVm, + isCurrent: () => boolean + ): Promise { try { const episodes = await this.host.fetchEpisodes( season.video_id, - season.id + season.id, + isCurrent ); - - // Update with loaded episodes - const newSeasons = [...this.host.seasons()]; - // Only the exact loading VM owns this response. A navigation or - // refresh can reuse provider ids while replacing the season list. - const newIndex = newSeasons.indexOf(loadingSeason); - if (newIndex !== -1) { - newSeasons[newIndex] = { - ...newSeasons[newIndex], - episodes: episodes, - // Even an EMPTY answer marks the season loaded: the - // portal spoke, so it must stop counting as "unloaded" - // (label/verdict gating and series-toggle hydration). - episodesLoaded: true, - isLoading: false, - }; - this.host.seasons.set(newSeasons); - } - return newIndex !== -1; + // An empty answer is also a loaded season. + return { + ...season, + episodes, + episodesLoaded: true, + isLoading: false, + }; } catch (error) { - this.host.logError('Failed to load episodes', error); - const newSeasons = [...this.host.seasons()]; - const newIndex = newSeasons.indexOf(loadingSeason); - if (newIndex !== -1) { - newSeasons[newIndex] = { - ...newSeasons[newIndex], - isLoading: false, - }; - this.host.seasons.set(newSeasons); - } - return false; + if (isCurrent()) + this.host.logError('Failed to load episodes', error); + const failedSeason = { ...season, isLoading: false }; + this.failedSeasons.add(failedSeason); + return failedSeason; } } } diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.state.ts b/libs/ui/components/src/lib/season-container/season-auto-select.state.ts index 631c5954c8..15a2141f87 100644 --- a/libs/ui/components/src/lib/season-container/season-auto-select.state.ts +++ b/libs/ui/components/src/lib/season-container/season-auto-select.state.ts @@ -14,10 +14,11 @@ import { resolveAutoSelectedSeason } from './season-auto-select.util'; export interface SeasonAutoSelectSources { /** * The container's selection (see `SeasonContainerComponent.selectedSeason`). - * Auto-select writes to it; user tab clicks write to it too and stick - * until the auto-select key changes. + * Auto-select and manual season picks both write to it. */ readonly selectedSeason: WritableSignal; + readonly playlistId: Signal; + readonly seriesId: Signal; readonly seasons: Signal>; /** Season keys in display order. */ readonly sortedSeasonKeys: Signal; @@ -44,6 +45,11 @@ export interface SeasonAutoSelectState { * flip that follows is then not treated as an initial load. */ markLocalWatchedMutation(): void; + /** + * Record that the user explicitly chose a season in the picker, so lazy + * season hydration does not override their manual choice. + */ + markManualSelection(): void; } /** Key of the loaded season that holds the episode with this content id. */ @@ -60,12 +66,9 @@ export function findSeasonOfEpisode( } /** - * Season auto-selection and `seasonSelected` emission of - * `SeasonContainerComponent`. The selection re-resolves when the season key - * set changes or when playback positions first arrive; ongoing position - * saves do not reset it — only the empty→loaded transition of the positions - * map does, and even that is ignored once this session toggled watched state - * itself. + * Re-resolves selection when the series, season set or hydrated progress + * changes. A manual pick or local watched toggle holds the selected season + * while that series' progress loads. * * Registers two effects, so it must be called in an injection context (the * container's constructor). @@ -73,12 +76,29 @@ export function findSeasonOfEpisode( export function createSeasonAutoSelectState( sources: SeasonAutoSelectSources ): SeasonAutoSelectState { - const autoSelectKey = computed( - () => - `${sources.sortedSeasonKeys().join('|')}::${ - sources.playbackPositions().size > 0 ? '1' : '0' - }` + const seriesKey = computed(() => + JSON.stringify([sources.playlistId(), sources.seriesId()]) + ); + const seasonSetKey = computed(() => + JSON.stringify([seriesKey(), sources.sortedSeasonKeys()]) ); + const autoSelectKey = computed(() => { + const keys = sources.sortedSeasonKeys(); + const seasonsWithPositions = keys + .filter((key) => + (sources.seasons()[key] ?? []).some( + (episode) => sources.positionOf(episode) !== undefined + ) + ) + .join('|'); + return JSON.stringify([ + seasonSetKey(), + sources.playbackPositions().size > 0, + seasonsWithPositions, + sources.hasUnloadedSeasons(), + sources.unloadedSeasonKeys(), + ]); + }); let lastAutoSelectKey: string | null = null; let lastAutoSelectSeasonSet: string | null = null; /** @@ -88,6 +108,7 @@ export function createSeasonAutoSelectState( * season they just marked (e.g. all-watched season 1 → jump to season 2). */ let hasLocalWatchedMutation = false; + let hasManualSelection = false; let lastEmittedSeason: string | undefined; /** Auto-select rules live in season-auto-select.util.ts. */ @@ -110,14 +131,21 @@ export function createSeasonAutoSelectState( if (key === lastAutoSelectKey) { return; } - const seasonSet = untracked(() => sources.sortedSeasonKeys().join('|')); + const seasonSet = seasonSetKey(); const seasonSetUnchanged = seasonSet === lastAutoSelectSeasonSet; lastAutoSelectKey = key; lastAutoSelectSeasonSet = seasonSet; - // A positions-emptiness flip after a local watched toggle keeps - // the current selection; only the async initial positions load - // (or a season-set change) re-resolves the season. - if (seasonSetUnchanged && hasLocalWatchedMutation) { + if (!seasonSetUnchanged) { + hasLocalWatchedMutation = false; + hasManualSelection = false; + } + // A positions-emptiness flip after a local watched toggle or a manual + // season pick keeps the current selection; only the async initial + // positions/season hydration (or a season-set change) re-resolves. + if ( + seasonSetUnchanged && + (hasLocalWatchedMutation || hasManualSelection) + ) { return; } sources.selectedSeason.set(untracked(() => resolveAutoSeason())); @@ -127,8 +155,9 @@ export function createSeasonAutoSelectState( // with tabs there is no initial "pick a season" click anymore. effect(() => { const selected = sources.selectedSeason(); - if (selected && selected !== lastEmittedSeason) { - lastEmittedSeason = selected; + const emission = JSON.stringify([seriesKey(), selected]); + if (selected && emission !== lastEmittedSeason) { + lastEmittedSeason = emission; sources.emitSeasonSelected(selected); } }); @@ -137,5 +166,8 @@ export function createSeasonAutoSelectState( markLocalWatchedMutation: () => { hasLocalWatchedMutation = true; }, + markManualSelection: () => { + hasManualSelection = true; + }, }; } diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts b/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts index fedce21028..a1b33b9477 100644 --- a/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-auto-select.util.spec.ts @@ -158,6 +158,97 @@ describe('resolveAutoSelectedSeason', () => { expect(resolveAutoSelectedSeason(ctx)).toBe('2'); }); + it('keeps the season of the last watched episode when unwatched episodes follow it', () => { + const ctx = context( + { + '1': ['101', '102'], + '2': ['201', '202'], + '3': ['301', '302'], + '4': ['401', '402', '403'], + }, + { + '401': position(401, { + positionSeconds: 2700, + updatedAt: '2026-07-01T00:00:00.000Z', + }), + '402': position(402, { + positionSeconds: 2700, + updatedAt: '2026-07-02T00:00:00.000Z', + }), + } + ); + expect(resolveAutoSelectedSeason(ctx)).toBe('4'); + }); + + it('advances to the season after the last watched episode when that season is finished', () => { + const ctx = context( + { + '1': ['101'], + '2': ['201'], + '3': ['301'], + '4': ['401', '402'], + '5': ['501'], + }, + { + '401': position(401, { + positionSeconds: 2700, + updatedAt: '2026-07-01T00:00:00.000Z', + }), + '402': position(402, { + positionSeconds: 2700, + updatedAt: '2026-07-02T00:00:00.000Z', + }), + } + ); + expect(resolveAutoSelectedSeason(ctx)).toBe('5'); + }); + + it('continues in episode number order when the provider lists episodes in reverse', () => { + const ctx = context( + { '1': ['103', '102', '101'], '2': ['201'] }, + { + '101': watched(101), + '102': position(102, { + positionSeconds: 2700, + updatedAt: '2026-07-02T00:00:00Z', + }), + } + ); + for (const item of ctx.seasons['1']) { + item.episode_num = Number(item.id) - 100; + } + + expect(resolveAutoSelectedSeason(ctx)).toBe('1'); + }); + + it('compares SQLite and ISO timestamps as UTC instants', () => { + const ctx = context( + { '1': ['101', '102'], '4': ['401', '402'] }, + { + '101': position(101, { + positionSeconds: 2700, + updatedAt: '2026-07-01 11:00:00', + }), + '401': position(401, { + positionSeconds: 2700, + updatedAt: '2026-07-01T10:00:00.000Z', + }), + } + ); + + expect(resolveAutoSelectedSeason(ctx)).toBe('1'); + }); + + it('advances from the last watched season with earlier seasons and Specials still pending', () => { + const ctx = context( + { '0': [], '1': [], '4': ['401'], '5': ['501'] }, + { '401': watched(401) }, + { hasUnloadedSeasons: true, unloadedSeasonKeys: ['0', '1'] } + ); + + expect(resolveAutoSelectedSeason(ctx)).toBe('5'); + }); + it('opens the latest non-empty season once everything is watched', () => { const ctx = context( { '1': ['101'], '2': ['201'], '3': ['301'] }, diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.util.ts b/libs/ui/components/src/lib/season-container/season-auto-select.util.ts index 345f4a4320..436b7f4e82 100644 --- a/libs/ui/components/src/lib/season-container/season-auto-select.util.ts +++ b/libs/ui/components/src/lib/season-container/season-auto-select.util.ts @@ -1,6 +1,9 @@ import { + compareSeriesEpisodes, + getSeriesPositionTimestamp, isExtrasSeason, isPortalPlaybackInProgress, + isPortalPlaybackWatched, } from '@iptvnator/portal/shared/util'; import { PlaybackPositionData, @@ -31,11 +34,9 @@ export interface AutoSeasonContext { /** * The season the container opens on when the season set or the initial * playback positions change: the inline-playing episode's season, else the - * most recently updated in-progress episode's season, else the default - * fallback below. Extras (season 0) steer neither of the last two while the - * series' other seasons hold, or may still hold, episodes, as with the - * series' next episode (`getSeriesNextUp`). Pure, so the container's auto-select effect stays a - * thin wrapper (see `SeasonContainerComponent.selectedSeason`). + * most recently updated in-progress episode's season, then the season where + * playback can continue after the last watched episode. Extras (season 0) + * are skipped while regular seasons have episodes or are still pending. */ export function resolveAutoSelectedSeason( context: AutoSeasonContext @@ -59,9 +60,21 @@ export function resolveAutoSelectedSeason( ) || (unloaded.size === 0 && context.hasUnloadedSeasons); const steering = runKeys.length > 0 && runMayHaveEpisodes ? runKeys : keys; + const pendingUnknown = unloaded.size === 0 && context.hasUnloadedSeasons; return ( findMostRecentInProgressSeason(context, steering) ?? - resolveDefaultSeason(context, steering, unloaded) + findNextAfterLastWatchedSeason( + context, + steering, + unloaded, + pendingUnknown + ) ?? + resolveDefaultSeason( + context, + steering, + unloaded, + pendingUnknown || steering.some((key) => unloaded.has(key)) + ) ); } @@ -76,9 +89,10 @@ export function resolveAutoSelectedSeason( * answered empty before it has nothing to show. */ function resolveDefaultSeason( - { hasUnloadedSeasons, episodeCounts, watchedCounts }: AutoSeasonContext, + { episodeCounts, watchedCounts }: AutoSeasonContext, keys: readonly string[], - unloaded: ReadonlySet + unloaded: ReadonlySet, + hasUnloadedSeasons: boolean ): string { if (hasUnloadedSeasons) { return ( @@ -105,17 +119,14 @@ function findMostRecentInProgressSeason( keys: readonly string[] ): string | null { let bestSeason: string | null = null; - let bestUpdatedAt = ''; - for (const [key, episodes] of Object.entries(seasons)) { - if (!keys.includes(key)) { - continue; - } - for (const episode of episodes ?? []) { + let bestUpdatedAt = -Infinity; + for (const key of keys) { + for (const episode of seasons[key] ?? []) { const position = positionOf(episode); if (!isPortalPlaybackInProgress(position)) { continue; } - const updatedAt = position?.updatedAt ?? ''; + const updatedAt = getSeriesPositionTimestamp(position?.updatedAt); if (updatedAt >= bestUpdatedAt) { bestUpdatedAt = updatedAt; bestSeason = key; @@ -124,3 +135,59 @@ function findMostRecentInProgressSeason( } return bestSeason; } + +/** + * Season of the first unwatched episode after the episode watched last, so + * skipping ahead to a later season (e.g. watching S04E01–S04E02 with + * seasons 1–3 untouched) keeps the container on season 4 instead of falling + * back to season 1. + */ +function findNextAfterLastWatchedSeason( + { seasons, positionOf, episodeCounts, watchedCounts }: AutoSeasonContext, + keys: readonly string[], + unloaded: ReadonlySet, + pendingUnknown: boolean +): string | null { + let lastWatchedKey: string | null = null; + let lastWatchedEpisodes: XtreamSerieEpisode[] = []; + let lastWatchedIndex = -1; + let bestUpdatedAt = -Infinity; + for (const key of keys) { + const episodes = [...(seasons[key] ?? [])].sort(compareSeriesEpisodes); + for (const [index, episode] of episodes.entries()) { + const position = positionOf(episode); + if (!isPortalPlaybackWatched(position)) { + continue; + } + const updatedAt = getSeriesPositionTimestamp(position?.updatedAt); + if (updatedAt >= bestUpdatedAt) { + bestUpdatedAt = updatedAt; + lastWatchedKey = key; + lastWatchedEpisodes = episodes; + lastWatchedIndex = index; + } + } + } + if (!lastWatchedKey) { + return null; + } + if ( + lastWatchedEpisodes + .slice(lastWatchedIndex + 1) + .some((episode) => !isPortalPlaybackWatched(positionOf(episode))) + ) { + return lastWatchedKey; + } + if (pendingUnknown) { + return null; + } + return ( + keys + .slice(keys.indexOf(lastWatchedKey) + 1) + .find( + (key) => + unloaded.has(key) || + (watchedCounts[key] ?? 0) < (episodeCounts[key] ?? 0) + ) ?? null + ); +} diff --git a/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts b/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts index c01ff7be44..2375d49be5 100644 --- a/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.auto-season.spec.ts @@ -205,4 +205,70 @@ describe('SeasonContainerComponent default-season fallback', () => { expect(component.selectedSeason()).toBe('1'); }); + + it('keeps a manual selection during hydration and resets it for another series with the same season keys', () => { + const seasons = { + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }; + fixture.componentRef.setInput('hasUnloadedSeasons', true); + fixture.componentRef.setInput('unloadedSeasonKeys', ['2']); + setRequiredInputs({ ...seasons, '2': [] }); + fixture.detectChanges(); + + component.selectSeason('1'); + fixture.componentRef.setInput('seasons', seasons); + fixture.componentRef.setInput('hasUnloadedSeasons', false); + fixture.componentRef.setInput('unloadedSeasonKeys', []); + const positions = new Map([[101, watchedPosition(101)]]); + fixture.componentRef.setInput('playbackPositions', positions); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('1'); + fixture.componentRef.setInput('seriesId', 21); + fixture.detectChanges(); + expect(component.selectedSeason()).toBe('2'); + }); + + it('re-resolves to the watched season once lazy seasons finish hydrating', () => { + fixture.componentRef.setInput('hasUnloadedSeasons', true); + fixture.componentRef.setInput('unloadedSeasonKeys', ['2', '3', '4']); + setRequiredInputs({ + '1': [createEpisode()], + '2': [], + '3': [], + '4': [], + }); + fixture.detectChanges(); + expect(component.selectedSeason()).toBe('1'); + + fixture.componentRef.setInput('hasUnloadedSeasons', false); + fixture.componentRef.setInput('unloadedSeasonKeys', []); + fixture.componentRef.setInput('seasons', { + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + '3': [createEpisode({ id: '301', season: 3 })], + '4': [ + createEpisode({ id: '401', episode_num: 1, season: 4 }), + createEpisode({ id: '402', episode_num: 2, season: 4 }), + createEpisode({ id: '403', episode_num: 3, season: 4 }), + ], + }); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [401, watchedPosition(401)], + [ + 402, + { + ...watchedPosition(402), + updatedAt: '2026-07-02T00:00:00.000Z', + }, + ], + ]) + ); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('4'); + }); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 19cc6cc11f..2e1e397a0b 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -201,18 +201,7 @@ export class SeasonContainerComponent implements OnInit { : findSeasonOfEpisode(this.seasons(), episodeId); }); - /** - * Selected season. Auto-resolves when the season key set changes or when - * playback positions first arrive (priority: inline-playing episode's - * season → most recently updated in-progress episode's season → earliest - * season with unwatched episodes → latest season, with unhydrated - * lazy-VOD seasons pinning the fallback to the first season); user tab - * clicks write to it and stick until the auto-select - * key changes. Ongoing position saves do not reset the selection — only - * the empty→loaded transition of the positions map does, and even that - * is ignored once this session toggled watched state itself (the flip is - * then an echo of the local action, not an initial load). - */ + /** Season chosen by the viewer or resolved from playback progress. */ readonly selectedSeason = signal(undefined); readonly seasonUnknown = computed(() => @@ -348,6 +337,8 @@ export class SeasonContainerComponent implements OnInit { // season-auto-select.state.ts / season-auto-select.util.ts. this.autoSelect = createSeasonAutoSelectState({ selectedSeason: this.selectedSeason, + seriesId: this.seriesId, + playlistId: this.playlistId, seasons: this.seasons, sortedSeasonKeys: this.sortedSeasonKeys, playingSeasonKey: this.playingSeasonKey, @@ -388,6 +379,7 @@ export class SeasonContainerComponent implements OnInit { } selectSeason(seasonKey: string) { + this.autoSelect.markManualSelection(); this.selectedSeason.set(seasonKey); }