Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/stalker-series-next-episode-hydration.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 11 additions & 14 deletions docs/architecture/embedded-inline-playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 14 additions & 10 deletions docs/architecture/stalker-portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions libs/portal/shared/util/src/lib/series-next-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -173,7 +173,7 @@ function episodeSeasonNumber(
return toSeasonNumber(episode.season) ?? toSeasonNumber(seasonKey);
}

function compareEpisodes(
export function compareSeriesEpisodes(
episodeA: XtreamSerieEpisode,
episodeB: XtreamSerieEpisode
): number {
Expand Down Expand Up @@ -214,16 +214,16 @@ 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;
}

/**
* 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ export function withStalkerSeries() {
return {
async fetchVodSeriesEpisodes(
videoId: string,
seasonId: string
seasonId: string,
isCurrent: () => boolean = () => true
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeContext.currentPlaylist();
if (!playlist) {
Expand All @@ -281,6 +282,7 @@ export function withStalkerSeries() {
}
),
() =>
isCurrent() &&
storeContext.currentPlaylist() ===
playlist &&
storeContext.selectedItem()?.id ===
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
SERIES_QUICK_START_ACTION_KIND,
formatSeriesEpisodeCode,
getSeriesPositionTimestamp,
getSeriesQuickStartAction,
isExtrasSeason,
isPortalPlaybackWatched,
type SeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import {
Expand Down Expand Up @@ -52,6 +54,7 @@ export function getStalkerSeriesQuickStartButton(
? getQuickStartLazyVodSeriesSeason(
action,
request.mappedSeasons,
request.playbackPositions,
orderedVodSeriesSeasons
)
: null;
Expand Down Expand Up @@ -99,6 +102,7 @@ function getOrderedVodSeriesSeasons(
function getQuickStartLazyVodSeriesSeason(
action: SeriesQuickStartAction | null,
mappedSeasons: Record<string, XtreamSerieEpisode[]>,
playbackPositions: ReadonlyMap<number, PlaybackPositionData>,
seasons: ReadonlyArray<VodSeriesSeasonVm>
): VodSeriesSeasonVm | null {
const firstUnloadedSeason = getFirstUnloadedVodSeriesSeason(
Expand Down Expand Up @@ -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)
Comment thread
4gray marked this conversation as resolved.
Resolved
) {
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(
Expand All @@ -147,6 +187,34 @@ function getQuickStartLazyVodSeriesSeason(
: null;
}

function findLastWatchedVodSeriesSeasonIndex(
seasons: ReadonlyArray<VodSeriesSeasonVm>,
mappedSeasons: Record<string, XtreamSerieEpisode[]>,
playbackPositions: ReadonlyMap<number, PlaybackPositionData>
): 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
Expand Down
Loading