mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(dashboard): keep finished titles off Continue Watching and continue a series with its next episode (#1841)
Continue Watching lists only what is left to watch: a title counts as watched at 90% (PORTAL_WATCHED_PROGRESS_PERCENT), and a series moves on to the episode after the one watched last (getSeriesNextUp, shared with the series page's play button), ignoring extras (season 0). Episode lists come from DashboardSeriesEpisodesService with bounded, backed-off lookups; a cached list that predates the episode watched last is fetched again. Stalker and Xtream series pages date the rows they write so the quick start resumes the episode just played, and the season tabs open the extras only when nothing else is left to load. Fixes #1838 Co-authored-by: Ramjot Singh <13517857+RamjotSingh@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
46 files changed
+4988
-263
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: dashboard
|
||||
---
|
||||
|
||||
Continue Watching no longer lists movies you have finished. A series moves on
|
||||
to the episode after the one you watched last, ignoring extras (season 0), and
|
||||
leaves after its final episode. A title counts as finished once 90% of it has
|
||||
played, as in Plex, Jellyfin and Kodi, so the end credits can be skipped. A
|
||||
series page's play button picks the same episode.
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
expectVisibleContentCardTitle,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openGlobalRecent,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
fetchXtreamSeriesFixture,
|
||||
fetchXtreamVodFixture,
|
||||
} from './portal-mock-fixtures';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Continue Watching lists only unfinished titles
|
||||
//
|
||||
// A title counts as watched once its saved position reaches 90% of the
|
||||
// runtime (PORTAL_WATCHED_PROGRESS_PERCENT), so a movie stopped during its end
|
||||
// credits leaves the dashboard's Continue Watching rail, as does one the user
|
||||
// marks watched from the card menu. Both stay in the watch history. The
|
||||
// positions are written through the real SQLite bridge: the clip the tests
|
||||
// stream lasts seconds, far from any real runtime.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RAIL = 'dashboard-continue-watching-rail';
|
||||
|
||||
type MoviePick = { title: string; playlistId: string; vodId: number };
|
||||
|
||||
async function playMovieAt(page: Page, index: number): Promise<MoviePick> {
|
||||
const card = page.locator('.category-content-layout mat-card').nth(index);
|
||||
await expect(card).toBeVisible({ timeout: 20_000 });
|
||||
let title = '';
|
||||
await expect(async () => {
|
||||
title = ((await card.locator('.title').textContent()) ?? '').trim();
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await card.click();
|
||||
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/);
|
||||
const [, playlistId, vodId] =
|
||||
new URL(page.url()).pathname.match(
|
||||
/\/workspace\/xtreams\/([^/]+)\/vod\/[^/]+\/([^/]+)$/
|
||||
) ?? [];
|
||||
|
||||
// Recorded as recently viewed once it has really played.
|
||||
await startAndConfirmPlayback(page, async () => {
|
||||
const playButton = page.locator('button.play-btn').first();
|
||||
await expect(playButton).toBeVisible({ timeout: 20_000 });
|
||||
await playButton.click();
|
||||
});
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
await expect(backButton).toBeVisible({ timeout: 20_000 });
|
||||
await backButton.click();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
return { title, playlistId, vodId: Number(vodId) };
|
||||
}
|
||||
|
||||
async function saveMoviePosition(
|
||||
page: Page,
|
||||
movie: MoviePick,
|
||||
positionSeconds: number,
|
||||
durationSeconds: number
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ playlistId, data }) =>
|
||||
window.electron.dbSavePlaybackPosition(playlistId, data),
|
||||
{
|
||||
playlistId: movie.playlistId,
|
||||
data: {
|
||||
contentXtreamId: movie.vodId,
|
||||
contentType: 'vod' as const,
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function railCard(page: Page, title: string) {
|
||||
return page
|
||||
.locator(`[data-test-id="${RAIL}-card"]`)
|
||||
.filter({ hasText: title });
|
||||
}
|
||||
|
||||
/** The ids of a mock series' season 1 episodes, in episode order. */
|
||||
async function fetchSeasonOneEpisodeIds(
|
||||
request: APIRequestContext,
|
||||
seriesId: number
|
||||
): Promise<number[]> {
|
||||
const params = new URLSearchParams({
|
||||
action: 'get_series_info',
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
series_id: String(seriesId),
|
||||
});
|
||||
const response = await request.get(
|
||||
`${xtreamMockServer}/player_api.php?${params}`
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const info = (await response.json()) as {
|
||||
episodes?: Record<string, { id: string; episode_num: number }[]>;
|
||||
};
|
||||
return [...(info.episodes?.['1'] ?? [])]
|
||||
.sort((a, b) => a.episode_num - b.episode_num)
|
||||
.map((episode) => Number(episode.id));
|
||||
}
|
||||
|
||||
async function playFirstEpisode(page: Page): Promise<void> {
|
||||
const seasonCard = page.locator('.season-card').first();
|
||||
const episodeCard = page
|
||||
.locator('.episode-card, .episode-list-item')
|
||||
.first();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await seasonCard.count()) + (await episodeCard.count()),
|
||||
{ timeout: 20_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
if ((await seasonCard.count()) > 0) {
|
||||
await seasonCard.click();
|
||||
}
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Continue Watching', () => {
|
||||
test('@dashboard @persistence @electron leaves finished movies off Continue Watching and keeps them in the history', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Continue Watching Source',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
const finished = await playMovieAt(app.mainWindow, 0);
|
||||
const halfway = await playMovieAt(app.mainWindow, 1);
|
||||
expect(halfway.title).not.toBe(finished.title);
|
||||
|
||||
// Stopped while the end credits rolled: 61 s of 59 min left.
|
||||
await saveMoviePosition(app.mainWindow, finished, 3491, 3552);
|
||||
await saveMoviePosition(app.mainWindow, halfway, 2700, 5400);
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
await expect(railCard(app.mainWindow, halfway.title)).toHaveCount(
|
||||
1,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
await expect(railCard(app.mainWindow, finished.title)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await railCard(app.mainWindow, halfway.title)
|
||||
.locator(`[data-test-id="${RAIL}-card-actions"]`)
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.getByRole('menuitem', { name: /mark as watched/i })
|
||||
.click();
|
||||
|
||||
// Nothing unfinished is left, so the rail itself goes away.
|
||||
await expect(
|
||||
app.mainWindow.locator(`[data-test-id="${RAIL}"]`)
|
||||
).toHaveCount(0, { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.mainWindow.evaluate(
|
||||
async ({ playlistId, vodId }) =>
|
||||
(
|
||||
await window.electron.dbGetAllPlaybackPositions(
|
||||
playlistId
|
||||
)
|
||||
).find(
|
||||
(row) =>
|
||||
row.contentType === 'vod' &&
|
||||
row.contentXtreamId === vodId
|
||||
),
|
||||
halfway
|
||||
)
|
||||
)
|
||||
.toMatchObject({
|
||||
positionSeconds: 5400,
|
||||
durationSeconds: 5400,
|
||||
});
|
||||
|
||||
// The page lists only movies here, so it shows no content toggle.
|
||||
await openGlobalRecent(app.mainWindow);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, finished.title);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, halfway.title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@dashboard @xtream @electron keeps a series on Continue Watching with the episode after the one watched last', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
await routePlayableStreams(app.mainWindow);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page, { name: 'Continue Watching Series' });
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickCategoryByNameExact(page, seriesFixture.categoryName);
|
||||
const seriesTitle = await clickFirstGridListCard(page);
|
||||
const playlistId = new URL(page.url()).pathname.match(
|
||||
/\/workspace\/xtreams\/([^/]+)\//
|
||||
)?.[1];
|
||||
expect(playlistId).toBeTruthy();
|
||||
await startAndConfirmPlayback(page, () => playFirstEpisode(page));
|
||||
|
||||
const episodeRow = () =>
|
||||
page.evaluate(
|
||||
async (id) =>
|
||||
(
|
||||
await window.electron.dbGetAllPlaybackPositions(id)
|
||||
).find((row) => row.contentType === 'episode') ?? null,
|
||||
playlistId as string
|
||||
);
|
||||
await expect.poll(episodeRow).not.toBeNull();
|
||||
const played = await episodeRow();
|
||||
if (!played) {
|
||||
throw new Error('expected the played episode to be saved');
|
||||
}
|
||||
expect(played).toMatchObject({ seasonNumber: 1, episodeNumber: 1 });
|
||||
|
||||
const backButton = page.getByTestId('workspace-header-back');
|
||||
await expect(backButton).toBeVisible({ timeout: 20_000 });
|
||||
await backButton.click();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
// Stopped during the end credits.
|
||||
await page.evaluate(
|
||||
({ id, row }) =>
|
||||
window.electron.dbSavePlaybackPosition(id, {
|
||||
contentXtreamId: row.contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: row.seriesXtreamId,
|
||||
seasonNumber: row.seasonNumber,
|
||||
episodeNumber: row.episodeNumber,
|
||||
positionSeconds: 3491,
|
||||
durationSeconds: 3552,
|
||||
}),
|
||||
{ id: playlistId as string, row: played }
|
||||
);
|
||||
|
||||
await goToDashboard(page);
|
||||
const card = railCard(page, seriesTitle);
|
||||
await expect(card).toHaveCount(1, { timeout: 20_000 });
|
||||
await expect(card).toContainText('S1·E2');
|
||||
|
||||
await card.locator(`[data-test-id="${RAIL}-card-actions"]`).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /resume episode/i })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('.player-shell__episode-meta')
|
||||
).toContainText('S01E02', { timeout: 30_000 });
|
||||
|
||||
// Skipping ahead: episode 3 is finished after episode 2 was
|
||||
// left. The series goes on after the episode watched last, not
|
||||
// back to the one left behind.
|
||||
await backButton.click();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
const seasonOne = await fetchSeasonOneEpisodeIds(
|
||||
request,
|
||||
Number(played.seriesXtreamId)
|
||||
);
|
||||
expect(seasonOne.length).toBeGreaterThanOrEqual(4);
|
||||
expect(seasonOne[0]).toBe(played.contentXtreamId);
|
||||
await page.evaluate(
|
||||
({ id, row, episodeId }) =>
|
||||
window.electron.dbSavePlaybackPosition(id, {
|
||||
contentXtreamId: episodeId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: row.seriesXtreamId,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
positionSeconds: 3491,
|
||||
durationSeconds: 3552,
|
||||
}),
|
||||
{
|
||||
id: playlistId as string,
|
||||
row: played,
|
||||
episodeId: seasonOne[2],
|
||||
}
|
||||
);
|
||||
|
||||
// Back from the player returned to the dashboard, which read the
|
||||
// positions before episode 3 was saved: open it anew.
|
||||
await openSources(page);
|
||||
await goToDashboard(page);
|
||||
await expect(card).toContainText('S1·E4', { timeout: 20_000 });
|
||||
await card.locator(`[data-test-id="${RAIL}-card-actions"]`).click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /resume episode/i })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator('.player-shell__episode-meta')
|
||||
).toContainText('S01E04', { timeout: 30_000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -269,9 +269,15 @@ 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 because their watched state is unknown,
|
||||
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) and the
|
||||
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`,
|
||||
so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch,
|
||||
TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open
|
||||
@@ -351,7 +357,8 @@ Both hosts delegate to `createVodWatchedToggle()` in
|
||||
Catalog cards derive their corner badge from one shared `PortalWatchState`
|
||||
(`unwatched` / `in-progress` / `watched`, `portal-watch-state.ts`): both
|
||||
facades map a movie's position through `watchStateFromProgressPercent` /
|
||||
`resolvePortalWatchState` (90% threshold, shared with the Resume rule),
|
||||
`resolvePortalWatchState` (`PORTAL_WATCHED_PROGRESS_PERCENT`, 90%, shared
|
||||
with the Resume rule and the dashboard's Continue Watching rail),
|
||||
and a series through `resolvePortalSeriesWatchState`, which reports at
|
||||
most `in-progress` — the list payload never carries the episode total, so
|
||||
"every episode watched" is not decidable there.
|
||||
@@ -526,23 +533,36 @@ Owner keys and queueing are provider contracts:
|
||||
## Series Quick Start CTA
|
||||
|
||||
Xtream and Stalker series detail views share the quick-start decision helper in
|
||||
`libs/portal/shared/util/src/lib/series-quick-start.ts`.
|
||||
The helper flattens the loaded season/episode map, sorts seasons and episodes in
|
||||
natural order, and returns the hero CTA state.
|
||||
`libs/portal/shared/util/src/lib/series-quick-start.ts`, built on
|
||||
`getSeriesNextUp` (`series-next-up.ts`), which the dashboard's Continue
|
||||
Watching rail also follows. The helper flattens the loaded season/episode map,
|
||||
sorts seasons and episodes in natural order, and returns the hero CTA state.
|
||||
|
||||
Current contract:
|
||||
|
||||
- the CTA shows the action label plus a compact episode target such as
|
||||
`S01E02 · Episode title`
|
||||
- if an episode is in progress, resume the latest updated in-progress episode
|
||||
with its saved offset
|
||||
- if the newest activity is an episode left unfinished, resume it with its
|
||||
saved offset (recency reads SQLite `updated_at`, UTC without a zone, as UTC;
|
||||
both hosts date the rows they write themselves with
|
||||
`stampPlaybackPositionNow`, so the episode just played ranks newest)
|
||||
- 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
|
||||
- otherwise play the first unwatched episode after the episode watched last,
|
||||
as Plex's On Deck and Jellyfin's Next Up do: an earlier episode skipped or
|
||||
left unfinished is passed over, and a next episode started before resumes
|
||||
- extras (season 0) stand apart when the series has other seasons: they never
|
||||
come next, are never resumed from here (not even one left unfinished) and
|
||||
never keep the series from completing; after only extras were played the
|
||||
CTA plays the first episode of the run. A series filed under season 0 alone
|
||||
has them as its run
|
||||
- if watched episodes end at a season boundary, play the first episode of the
|
||||
next loaded season
|
||||
- if every loaded episode is watched, render a disabled completed state
|
||||
- when no unwatched episode follows the one watched last, offer the first one
|
||||
skipped before it (never an extra); with none left, render a disabled
|
||||
completed state
|
||||
- with nothing played yet, play the first episode of the run in season order
|
||||
|
||||
The click path must continue through each detail host's normal episode playback
|
||||
method so recent-item updates, inline/external player selection, resume offsets,
|
||||
|
||||
@@ -1475,6 +1475,12 @@ records. Both accept the same closed set: boolean `true`, numeric `1`, or string
|
||||
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.
|
||||
- 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
|
||||
has not answered for (an empty answer counts as loaded:
|
||||
`isVodSeasonHydrationPending`), never an unloaded Specials season. Only when
|
||||
every regular season came back empty do extras count as the run.
|
||||
- For unloaded VOD-series seasons, the CTA target label is derived from season
|
||||
metadata and rendered as `SxxE01` until episode details are loaded.
|
||||
- Lazy VOD-series episodes use scoped tracking IDs derived from the parent
|
||||
|
||||
@@ -75,8 +75,9 @@ Render rules:
|
||||
full-bleed. All rails and the hero are skipped.
|
||||
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
|
||||
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
|
||||
it has no slide and any of its sources (history, favorites, Xtream
|
||||
recently added) is still on its first load, or a live candidate still
|
||||
it has no slide and any of its sources (history and its playback
|
||||
positions, favorites, Xtream recently added) is still on its first load,
|
||||
or a live candidate still
|
||||
waits for its first programme answer (portal or XMLTV, for at most
|
||||
`DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS`, 2 s, from the hero's creation).
|
||||
Dropping it earlier removed the hero and inserted it again when a later
|
||||
@@ -121,14 +122,18 @@ programme) can still grow the tallest slide, once, when it arrives.
|
||||
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
|
||||
once):
|
||||
|
||||
1. the newest unfinished movie/series (`isPortalPlaybackWatched` rows skip);
|
||||
1. the newest unfinished movie/series (`continueWatchingItems()`, the rail's
|
||||
list: a finished movie skips, a series features the episode it continues
|
||||
with);
|
||||
2. a live channel with a programme on air — the first of
|
||||
`selectDashboardHeroLiveCandidates` (up to three favourites, then two
|
||||
recently watched channels) whose EPG answer has a title;
|
||||
3. one favourite movie/series and one Xtream recently-added title;
|
||||
4. remaining places round-robin over the next items of those lists;
|
||||
5. only when nothing qualifies, the newest history row of any kind (a
|
||||
detail action: it can be a finished title).
|
||||
detail action: it can be a finished title). It waits for
|
||||
`continueWatchingSettled()`: before the positions load, that row may be
|
||||
the title the resume slide is about to feature.
|
||||
|
||||
While live candidates exist but none has answered yet, one place stays
|
||||
reserved for the live slide, so its late arrival never evicts a slide the
|
||||
@@ -234,8 +239,83 @@ all, and none inside the hero.
|
||||
2. It derives the dashboard surface via `computed()`:
|
||||
1. The hero slides — built by `DashboardHeroSlidesPresenter`, see
|
||||
[Cinematic Hero](#cinematic-hero).
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Portal playback positions are bulk-loaded per playlist so
|
||||
2. `continueWatchingCards` — maps `continueWatchingItems()` to movie/series
|
||||
cover cards. The list and its loading gate are built by
|
||||
`createDashboardContinueWatching` (`dashboard-continue-watching.ts`)
|
||||
from the history and the playback positions `DashboardDataService`
|
||||
holds, which exposes them as `continueWatchingItems()` and
|
||||
`continueWatchingSettled()`. A movie stays until its position reaches
|
||||
`PORTAL_WATCHED_PROGRESS_PERCENT` (`isPortalPlaybackWatched`). The
|
||||
threshold is 90%, the Plex/Jellyfin/Emby/Kodi default, so stopping
|
||||
during the end credits finishes a title, as does "Mark watched". A
|
||||
series stays while it has something to continue: once its newest
|
||||
episode is watched, or is an extra (season 0),
|
||||
`DashboardSeriesEpisodesService` fetches its episode list
|
||||
(`get_series_info`, two lookups at a time) and
|
||||
`resolveDashboardSeriesContinuation` applies
|
||||
`getSeriesNextUp`, the rule the series page's quick start follows
|
||||
too. Like Plex's On Deck and Jellyfin's Next Up it goes on from the
|
||||
episode watched last, not from the first gap in the list: the first
|
||||
episode after it that is not watched, so a series started at season
|
||||
2 or with an episode skipped (or left unfinished) goes on after the
|
||||
episode actually watched. A started next episode resumes where it was
|
||||
left. Extras (season 0) stand apart when the series has other
|
||||
seasons: they never come next, never keep the series on the rail and
|
||||
never take its card, not even one left unfinished; a series filed
|
||||
under season 0 alone has them as its run. The card then shows and
|
||||
resumes that episode; a next episode not started yet is a
|
||||
synthesized row at 0:00 without duration, so it carries the S·E
|
||||
badge but no progress, remaining time or "Mark watched". The series
|
||||
leaves once no unwatched episode follows the one watched last, even
|
||||
if an earlier one was skipped or extras are left. Until its list is
|
||||
in (loading, failed, empty, or not holding the episode played last:
|
||||
a list cached before that episode is fetched again in the next
|
||||
round), for
|
||||
series that cannot be looked up (Stalker), and for series never
|
||||
looked up, a series keeps its newest episode rather than vanish on a
|
||||
guess. `planDashboardSeriesLookups` picks the series to look up:
|
||||
newest first until `CONTINUE_WATCHING_VISIBLE_ITEMS` (20, the rail's
|
||||
`RAIL_ITEM_LIMIT`) titles are known to stay listed, counting movies
|
||||
and series that need no lookup; a finished series frees its slot, so
|
||||
an older one is looked up next, up to
|
||||
`CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT` (40) lookups. Lists are kept
|
||||
per session and per source (server and username: an edited playlist
|
||||
is looked up again, and a late answer for the old source is
|
||||
dropped). Lookups are background requests (`suppressErrorLog`: a
|
||||
failure is logged, never a toast). Each `reloadPlaybackPositions()`
|
||||
that lands (dashboard entry, a changed history, "Mark watched") is a
|
||||
lookup round: a failed lookup is tried again once
|
||||
`DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS` (5 min) has passed (at
|
||||
once when the playlist's password is corrected), and a list older
|
||||
than `DASHBOARD_SERIES_EPISODES_MAX_AGE_MS` (1 h) is fetched again so
|
||||
newly added episodes show up, the old list standing until the new
|
||||
one arrives (a failed refresh keeps it, and waits for the same delay).
|
||||
A source that failed two lookups in a row is not asked for its other
|
||||
series until the delay has passed either: a portal that is down or
|
||||
expired costs two requests per delay, not one per series per dashboard
|
||||
visit. Within one round, more
|
||||
series to look past never repeat a request, and the request list
|
||||
compares by value, so playlist store churn elsewhere in the app does
|
||||
not trigger lookups. Finished titles
|
||||
stay in `globalRecentVodItems()` and on the Global Recent page ("See
|
||||
all");
|
||||
the rail's count badge counts only the listed ones. The list stays
|
||||
empty until `continueWatchingSettled()`: the history has loaded (on
|
||||
the PWA that includes its Xtream data-source read, not only the
|
||||
playlist inventory), `reloadPlaybackPositions()` has covered every
|
||||
playlist in it once, and the episode lists the first open asked for
|
||||
have answered or `CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS` (2 s) has
|
||||
passed, so on first open the rail inserts once instead of listing
|
||||
finished titles and dropping them a moment later. A playlist whose
|
||||
positions failed to load counts as covered. The gate stays open
|
||||
afterwards: like the history, a dashboard opened again first renders
|
||||
the positions it already holds and refreshes them on entry, so a title
|
||||
finished since the last visit leaves the rail, or a series moves on to
|
||||
its next episode, when that refresh (and, for a series not looked up
|
||||
yet this session, its episode list) lands.
|
||||
`reloadPlaybackPositions()` applies only its latest call's result, so
|
||||
an older read finishing last cannot bring finished titles back.
|
||||
Portal playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
episode badges. Whether an item is looked up as a movie (one `vod`
|
||||
row) or a series (episode rows under the parent id) is its WATCH
|
||||
@@ -247,7 +327,11 @@ all, and none inside the hero.
|
||||
rows. The mappers give both it and a lazy Ministra `is_series` row
|
||||
(already typed `series`) `watch_kind: 'series'`. Series lookup uses
|
||||
keyed maps for both direct episode ids and parent series ids; card
|
||||
renders must not scan the full playback-position map. The badge uses saved `seasonNumber` /
|
||||
renders must not scan the full playback-position map. A series'
|
||||
newest row is the latest `updatedAt`; rows saved in the same second
|
||||
(SQLite keeps whole seconds, and "Mark season watched" stamps a
|
||||
season at once) go to the later episode, as `getSeriesNextUp` breaks
|
||||
the tie. The badge uses saved `seasonNumber` /
|
||||
`episodeNumber` metadata and does not infer it from provider payloads;
|
||||
legacy rows without that metadata remain badge-less until replay.
|
||||
Dashboard-originated Xtream and Stalker series clicks also carry that
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './lib/portal-catalog-facade';
|
||||
export * from './lib/portal-external-playback';
|
||||
export * from './lib/portal-player';
|
||||
export * from './lib/portal-playback-positions';
|
||||
export * from './lib/series-next-up';
|
||||
export * from './lib/series-quick-start';
|
||||
export * from './lib/portal-shell-actions';
|
||||
export * from './lib/portal-collection-context';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
isPortalPlaybackInProgress,
|
||||
isPortalPlaybackWatched,
|
||||
PORTAL_WATCHED_PROGRESS_PERCENT,
|
||||
stampPlaybackPositionNow,
|
||||
} from './portal-playback-positions';
|
||||
|
||||
function row(
|
||||
positionSeconds: number,
|
||||
durationSeconds = 6000
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId: positionSeconds,
|
||||
contentType: 'episode',
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
describe('portal playback positions', () => {
|
||||
it('counts a title as watched once only the credits are left', () => {
|
||||
expect(PORTAL_WATCHED_PROGRESS_PERCENT).toBe(90);
|
||||
// 61 s of a 59-minute episode left: the end credits.
|
||||
expect(isPortalPlaybackWatched(row(3491, 3552))).toBe(true);
|
||||
expect(isPortalPlaybackWatched(row(5400))).toBe(true);
|
||||
expect(isPortalPlaybackWatched(row(5340))).toBe(false);
|
||||
expect(isPortalPlaybackWatched(row(5400, 0))).toBe(false);
|
||||
expect(isPortalPlaybackWatched(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats progress below the watched threshold as resumable', () => {
|
||||
expect(isPortalPlaybackInProgress(row(5340))).toBe(true);
|
||||
expect(isPortalPlaybackInProgress(row(5400))).toBe(false);
|
||||
// A few seconds in is not worth resuming.
|
||||
expect(isPortalPlaybackInProgress(row(5))).toBe(false);
|
||||
});
|
||||
|
||||
it('dates a row a page writes itself, and leaves a dated row alone', () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-10-08T09:00:00Z'));
|
||||
try {
|
||||
const tick = row(600);
|
||||
expect(stampPlaybackPositionNow(tick)).toEqual({
|
||||
...tick,
|
||||
updatedAt: '2026-10-08T09:00:00.000Z',
|
||||
});
|
||||
// The caller's row is not modified.
|
||||
expect(tick.updatedAt).toBeUndefined();
|
||||
|
||||
const stored = { ...row(600), updatedAt: '2026-10-03 08:00:00' };
|
||||
expect(stampPlaybackPositionNow(stored)).toBe(stored);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,9 @@ export interface PortalPlaybackPositions {
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
): Promise<PlaybackPositionData[]>;
|
||||
getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]>;
|
||||
getAllPlaybackPositions(
|
||||
playlistId: string
|
||||
): Promise<PlaybackPositionData[]>;
|
||||
clearPlaybackPosition(
|
||||
playlistId: string,
|
||||
contentXtreamId: number,
|
||||
@@ -48,6 +50,14 @@ export interface PortalPlaybackPositions {
|
||||
export const PORTAL_PLAYBACK_POSITIONS =
|
||||
new InjectionToken<PortalPlaybackPositions>('PORTAL_PLAYBACK_POSITIONS');
|
||||
|
||||
/**
|
||||
* Progress at or above which a movie or episode counts as watched. The last
|
||||
* tenth is mostly end credits, so stopping there finishes the title. Plex
|
||||
* ("video played threshold"), Jellyfin and Emby ("max resume percentage")
|
||||
* and Kodi ("playcountminimumpercent") all default to the same 90%.
|
||||
*/
|
||||
export const PORTAL_WATCHED_PROGRESS_PERCENT = 90;
|
||||
|
||||
export function getPortalPlaybackProgressPercent(
|
||||
position: PlaybackPositionData | null | undefined
|
||||
): number {
|
||||
@@ -67,7 +77,10 @@ export function getPortalPlaybackProgressPercent(
|
||||
export function isPortalPlaybackWatched(
|
||||
position: PlaybackPositionData | null | undefined
|
||||
): boolean {
|
||||
return getPortalPlaybackProgressPercent(position) >= 90;
|
||||
return (
|
||||
getPortalPlaybackProgressPercent(position) >=
|
||||
PORTAL_WATCHED_PROGRESS_PERCENT
|
||||
);
|
||||
}
|
||||
|
||||
export function isPortalPlaybackInProgress(
|
||||
@@ -78,5 +91,23 @@ export function isPortalPlaybackInProgress(
|
||||
}
|
||||
|
||||
const percent = getPortalPlaybackProgressPercent(position);
|
||||
return position.positionSeconds > 10 && percent < 90;
|
||||
return (
|
||||
position.positionSeconds > 10 &&
|
||||
percent < PORTAL_WATCHED_PROGRESS_PERCENT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows a page writes itself (player ticks, external-player updates, watched
|
||||
* toggles) carry no `updatedAt`, while the stored rows do: without one the
|
||||
* row just written would rank as the oldest, not the newest, and the series
|
||||
* would go on from the wrong episode. The store stamps its own time on save,
|
||||
* so this only orders a page's in-memory copy; a dated row keeps its date.
|
||||
*/
|
||||
export function stampPlaybackPositionNow(
|
||||
position: PlaybackPositionData
|
||||
): PlaybackPositionData {
|
||||
return position.updatedAt
|
||||
? position
|
||||
: { ...position, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import { getPortalPlaybackProgressPercent } from './portal-playback-positions';
|
||||
import {
|
||||
getPortalPlaybackProgressPercent,
|
||||
PORTAL_WATCHED_PROGRESS_PERCENT,
|
||||
} from './portal-playback-positions';
|
||||
|
||||
/**
|
||||
* One watch state for every catalog card, movie or series, on every portal.
|
||||
@@ -13,9 +16,6 @@ import { getPortalPlaybackProgressPercent } from './portal-playback-positions';
|
||||
*/
|
||||
export type PortalWatchState = 'unwatched' | 'in-progress' | 'watched';
|
||||
|
||||
/** Progress at or above which a movie or episode counts as watched. */
|
||||
export const PORTAL_WATCHED_PROGRESS_PERCENT = 90;
|
||||
|
||||
export function watchStateFromProgressPercent(
|
||||
percent: number
|
||||
): PortalWatchState {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
getSeriesNextUp,
|
||||
isExtrasSeason,
|
||||
type SeriesNextUp,
|
||||
} from './series-next-up';
|
||||
|
||||
function episode(
|
||||
id: number,
|
||||
season: number,
|
||||
episodeNum: number
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: String(id),
|
||||
episode_num: episodeNum,
|
||||
title: `Episode ${episodeNum}`,
|
||||
container_extension: 'mp4',
|
||||
info: [],
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season,
|
||||
direct_source: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Season `season`, episodes 1..count, ids `season * 100 + n`. */
|
||||
function season(seasonNumber: number, count: number): XtreamSerieEpisode[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
episode(seasonNumber * 100 + index + 1, seasonNumber, index + 1)
|
||||
);
|
||||
}
|
||||
|
||||
const WATCHED = 95;
|
||||
const STARTED = 40;
|
||||
|
||||
function position(
|
||||
contentXtreamId: number,
|
||||
positionSeconds: number,
|
||||
updatedAt?: string
|
||||
): [number, PlaybackPositionData] {
|
||||
return [
|
||||
contentXtreamId,
|
||||
{
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 10,
|
||||
positionSeconds,
|
||||
durationSeconds: 100,
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Watched in the order given, an hour apart. */
|
||||
function watchedInOrder(...ids: number[]): [number, PlaybackPositionData][] {
|
||||
return ids.map((id, index) =>
|
||||
position(id, WATCHED, `2026-10-04T${10 + index}:00:00.000Z`)
|
||||
);
|
||||
}
|
||||
|
||||
function nextUp(
|
||||
seasons: Record<string, XtreamSerieEpisode[]>,
|
||||
positions: [number, PlaybackPositionData][]
|
||||
): SeriesNextUp | null {
|
||||
return getSeriesNextUp({
|
||||
seasons,
|
||||
playbackPositions: new Map(positions),
|
||||
});
|
||||
}
|
||||
|
||||
/** The kind and the episode id it names. */
|
||||
function summary(result: SeriesNextUp | null): [string, number] | null {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
const entry = result.kind === 'caught-up' ? result.skipped : result.entry;
|
||||
return [result.kind, Number(entry?.episode.id ?? 0)];
|
||||
}
|
||||
|
||||
describe('getSeriesNextUp', () => {
|
||||
it('starts with the first episode when nothing has been played', () => {
|
||||
expect(summary(nextUp({ '1': season(1, 3) }, []))).toEqual([
|
||||
'start',
|
||||
101,
|
||||
]);
|
||||
});
|
||||
|
||||
it('goes on with the episode after the one watched last', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp(
|
||||
{ '1': season(1, 5) },
|
||||
watchedInOrder(101, 102, 103, 104)
|
||||
)
|
||||
)
|
||||
).toEqual(['next', 105]);
|
||||
});
|
||||
|
||||
it('goes on into the next season after a finale', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp(
|
||||
{ '1': season(1, 2), '2': season(2, 2) },
|
||||
watchedInOrder(101, 102)
|
||||
)
|
||||
)
|
||||
).toEqual(['next', 201]);
|
||||
});
|
||||
|
||||
it('goes on from a later season the user started with', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp(
|
||||
{ '1': season(1, 3), '2': season(2, 3) },
|
||||
watchedInOrder(201)
|
||||
)
|
||||
)
|
||||
).toEqual(['next', 202]);
|
||||
});
|
||||
|
||||
it('goes on past an episode the user skipped', () => {
|
||||
const seasons = { '1': season(1, 5) };
|
||||
|
||||
expect(summary(nextUp(seasons, watchedInOrder(101, 102, 104)))).toEqual(
|
||||
['next', 105]
|
||||
);
|
||||
|
||||
// Caught up after the finale; the skipped episode is still named.
|
||||
const caughtUp = nextUp(seasons, watchedInOrder(101, 102, 104, 105));
|
||||
expect(summary(caughtUp)).toEqual(['caught-up', 103]);
|
||||
expect(caughtUp?.kind === 'caught-up' && caughtUp.last.episode.id).toBe(
|
||||
'105'
|
||||
);
|
||||
});
|
||||
|
||||
it('goes on after the furthest episode when an earlier one is watched again', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp(
|
||||
{ '1': season(1, 6) },
|
||||
watchedInOrder(101, 102, 103, 104, 105, 102)
|
||||
)
|
||||
)
|
||||
).toEqual(['next', 106]);
|
||||
});
|
||||
|
||||
it('is caught up, with nothing skipped, once every episode is watched', () => {
|
||||
const result = nextUp({ '1': season(1, 2) }, watchedInOrder(101, 102));
|
||||
|
||||
expect(summary(result)).toEqual(['caught-up', 0]);
|
||||
});
|
||||
|
||||
describe('an episode left unfinished', () => {
|
||||
it('is resumed when it is the newest activity', () => {
|
||||
const result = nextUp({ '1': season(1, 4) }, [
|
||||
...watchedInOrder(101),
|
||||
position(102, STARTED, '2026-10-04T12:00:00.000Z'),
|
||||
]);
|
||||
|
||||
expect(summary(result)).toEqual(['resume', 102]);
|
||||
expect(
|
||||
result?.kind === 'resume' && result.entry.position
|
||||
).toMatchObject({ positionSeconds: STARTED });
|
||||
});
|
||||
|
||||
it('is moved past once a later episode is watched after it', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp({ '1': season(1, 4) }, [
|
||||
position(102, STARTED, '2026-10-04T09:00:00.000Z'),
|
||||
...watchedInOrder(101, 103),
|
||||
])
|
||||
)
|
||||
).toEqual(['next', 104]);
|
||||
});
|
||||
|
||||
it('comes next, with its position, when it follows the episode watched last', () => {
|
||||
const result = nextUp({ '1': season(1, 4) }, [
|
||||
position(102, STARTED, '2026-10-04T09:00:00.000Z'),
|
||||
...watchedInOrder(101),
|
||||
]);
|
||||
|
||||
expect(summary(result)).toEqual(['next', 102]);
|
||||
expect(
|
||||
result?.kind === 'next' && result.entry.position
|
||||
).toMatchObject({ positionSeconds: STARTED });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extras (season 0)', () => {
|
||||
const seasons = { '0': season(0, 2), '1': season(1, 3) };
|
||||
|
||||
it('do not come next after an episode of the run', () => {
|
||||
expect(summary(nextUp(seasons, watchedInOrder(101)))).toEqual([
|
||||
'next',
|
||||
102,
|
||||
]);
|
||||
expect(
|
||||
summary(nextUp(seasons, watchedInOrder(101, 102, 103)))
|
||||
).toEqual(['caught-up', 0]);
|
||||
});
|
||||
|
||||
it('do not change what comes next when one is watched mid-run', () => {
|
||||
expect(summary(nextUp(seasons, watchedInOrder(101, 1)))).toEqual([
|
||||
'next',
|
||||
102,
|
||||
]);
|
||||
});
|
||||
|
||||
it('do not take over when one is left unfinished', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp(seasons, [
|
||||
...watchedInOrder(101),
|
||||
position(1, STARTED, '2026-10-05T10:00:00.000Z'),
|
||||
])
|
||||
)
|
||||
).toEqual(['next', 102]);
|
||||
});
|
||||
|
||||
it('lead into the first episode of the run when only extras were played', () => {
|
||||
expect(summary(nextUp(seasons, watchedInOrder(1)))).toEqual([
|
||||
'next',
|
||||
101,
|
||||
]);
|
||||
expect(
|
||||
summary(
|
||||
nextUp(seasons, [
|
||||
position(2, STARTED, '2026-10-05T10:00:00.000Z'),
|
||||
])
|
||||
)
|
||||
).toEqual(['next', 101]);
|
||||
});
|
||||
|
||||
it('are not where a series starts', () => {
|
||||
expect(summary(nextUp(seasons, []))).toEqual(['start', 101]);
|
||||
});
|
||||
|
||||
it('are recognised by the episode season when the key is not a number', () => {
|
||||
const result = nextUp(
|
||||
{ '1': season(1, 2), Specials: season(0, 1) },
|
||||
watchedInOrder(101, 102)
|
||||
);
|
||||
|
||||
// Caught up with the run; the unwatched extra is not "skipped".
|
||||
expect(summary(result)).toEqual(['caught-up', 0]);
|
||||
expect(result?.kind === 'caught-up' && result.last.episode.id).toBe(
|
||||
'102'
|
||||
);
|
||||
});
|
||||
|
||||
it('are the run of a series filed under season 0 alone', () => {
|
||||
const only = { '0': season(0, 3) };
|
||||
|
||||
expect(summary(nextUp(only, []))).toEqual(['start', 1]);
|
||||
expect(summary(nextUp(only, watchedInOrder(1)))).toEqual([
|
||||
'next',
|
||||
2,
|
||||
]);
|
||||
expect(
|
||||
summary(
|
||||
nextUp(only, [
|
||||
...watchedInOrder(1, 2),
|
||||
position(3, STARTED, '2026-10-05T10:00:00.000Z'),
|
||||
])
|
||||
)
|
||||
).toEqual(['resume', 3]);
|
||||
});
|
||||
});
|
||||
|
||||
it('orders saves without a time by series order', () => {
|
||||
expect(
|
||||
summary(
|
||||
nextUp({ '1': season(1, 3) }, [
|
||||
position(101, STARTED),
|
||||
position(102, STARTED),
|
||||
])
|
||||
)
|
||||
).toEqual(['resume', 102]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a minute after', '2026-10-04T07:59:00.000Z', 'next', 103],
|
||||
['a minute before', '2026-10-04T08:01:00.000Z', 'resume', 103],
|
||||
])(
|
||||
'reads SQLite times as UTC: a database row saved %s a renderer row',
|
||||
(_, isoTime, kind, id) => {
|
||||
// The database writes CURRENT_TIMESTAMP (UTC, no zone); a row
|
||||
// the page just saved carries an ISO time.
|
||||
const result = nextUp({ '1': season(1, 4) }, [
|
||||
position(101, WATCHED, '2026-10-03 08:00:00'),
|
||||
position(102, WATCHED, '2026-10-04 08:00:00'),
|
||||
position(103, STARTED, isoTime),
|
||||
]);
|
||||
|
||||
expect(summary(result)).toEqual([kind, id]);
|
||||
}
|
||||
);
|
||||
|
||||
it('has nothing to go on with when no season lists an episode', () => {
|
||||
expect(nextUp({ '1': [] }, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExtrasSeason', () => {
|
||||
it('goes by the episodes, else by the key of an unloaded season', () => {
|
||||
expect(isExtrasSeason('0', season(0, 2))).toBe(true);
|
||||
expect(isExtrasSeason('Specials', season(0, 2))).toBe(true);
|
||||
expect(isExtrasSeason('1', season(1, 2))).toBe(false);
|
||||
expect(isExtrasSeason('0', [])).toBe(true);
|
||||
expect(isExtrasSeason('Specials', [])).toBe(false);
|
||||
expect(isExtrasSeason('2', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { isPortalPlaybackWatched } from './portal-playback-positions';
|
||||
|
||||
export interface SeriesProgressRequest {
|
||||
readonly seasons: Readonly<Record<string, readonly XtreamSerieEpisode[]>>;
|
||||
readonly playbackPositions: ReadonlyMap<number, PlaybackPositionData>;
|
||||
}
|
||||
|
||||
/** An episode in series order, with its saved position. */
|
||||
export interface SeriesEpisodeEntry {
|
||||
readonly episode: XtreamSerieEpisode;
|
||||
readonly position: PlaybackPositionData | null;
|
||||
/** Index in season order, then episode order. */
|
||||
readonly order: number;
|
||||
/** The episode's own season number, else its season key's. */
|
||||
readonly seasonNumber: number | null;
|
||||
/** Filed under season 0, where providers keep extras (specials). */
|
||||
readonly special: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a series goes on from its saved positions. Like Plex's On Deck and
|
||||
* Jellyfin's Next Up, it goes from the episode watched last, not from the
|
||||
* first gap in the list: a series started at season 2, or with an episode
|
||||
* skipped, goes on after the episode the user actually watched.
|
||||
*
|
||||
* Extras (season 0) stand apart from a series that has other seasons: they
|
||||
* never come next, never keep the series pending and never take its place,
|
||||
* not even one left unfinished. A series filed under season 0 alone has
|
||||
* nothing else, so there season 0 is the run.
|
||||
* - `resume`: the newest activity in the run is an episode left unfinished;
|
||||
* - `next`: the first unwatched episode after the one watched last, in
|
||||
* series order; it may have been started before. When only extras were
|
||||
* played, the first episode of the run;
|
||||
* - `caught-up`: no unwatched episode follows the one watched last.
|
||||
* `skipped` is the first one left unwatched before it, `last` the final
|
||||
* episode of the run;
|
||||
* - `start`: no episode of the list has been played.
|
||||
*/
|
||||
export type SeriesNextUp =
|
||||
| {
|
||||
readonly kind: 'start' | 'resume' | 'next';
|
||||
readonly entry: SeriesEpisodeEntry;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'caught-up';
|
||||
readonly skipped: SeriesEpisodeEntry | null;
|
||||
readonly last: SeriesEpisodeEntry;
|
||||
};
|
||||
|
||||
const naturalCollator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
/** SQLite's CURRENT_TIMESTAMP: UTC, written without a zone. */
|
||||
const SQLITE_UTC_TIMESTAMP = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$/;
|
||||
|
||||
export function getSeriesNextUp(
|
||||
request: SeriesProgressRequest
|
||||
): SeriesNextUp | null {
|
||||
const entries = getOrderedSeriesEpisodes(request);
|
||||
const run = entries.some((entry) => !entry.special)
|
||||
? entries.filter((entry) => !entry.special)
|
||||
: entries;
|
||||
if (run.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const nextUp = getNextUpWithin(run);
|
||||
// Only extras were played: the run begins now.
|
||||
if (
|
||||
nextUp.kind === 'start' &&
|
||||
entries.some(({ position }) => position !== null)
|
||||
) {
|
||||
return { kind: 'next', entry: nextUp.entry };
|
||||
}
|
||||
return nextUp;
|
||||
}
|
||||
|
||||
/** The rule within one sequence of episodes, in series order. */
|
||||
function getNextUpWithin(
|
||||
sequence: readonly SeriesEpisodeEntry[]
|
||||
): SeriesNextUp {
|
||||
const lastWatched = newestEntry(
|
||||
sequence.filter(({ position }) => isPortalPlaybackWatched(position))
|
||||
);
|
||||
const lastStarted = newestEntry(
|
||||
sequence.filter(
|
||||
({ position }) =>
|
||||
position !== null && !isPortalPlaybackWatched(position)
|
||||
)
|
||||
);
|
||||
if (
|
||||
lastStarted &&
|
||||
(!lastWatched || compareEntryRecency(lastStarted, lastWatched) > 0)
|
||||
) {
|
||||
return { kind: 'resume', entry: lastStarted };
|
||||
}
|
||||
if (!lastWatched) {
|
||||
return { kind: 'start', entry: sequence[0] };
|
||||
}
|
||||
const unwatched = sequence.filter(
|
||||
(entry) => !isPortalPlaybackWatched(entry.position)
|
||||
);
|
||||
const next = unwatched.find((entry) => entry.order > lastWatched.order);
|
||||
if (next) {
|
||||
return { kind: 'next', entry: next };
|
||||
}
|
||||
return {
|
||||
kind: 'caught-up',
|
||||
skipped: unwatched[0] ?? null,
|
||||
last: sequence[sequence.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** Seasons in natural order ("Season 2" before "Season 10"), then episodes. */
|
||||
function getOrderedSeriesEpisodes(
|
||||
request: SeriesProgressRequest
|
||||
): SeriesEpisodeEntry[] {
|
||||
const entries: SeriesEpisodeEntry[] = [];
|
||||
Object.entries(request.seasons)
|
||||
.sort(([seasonA], [seasonB]) =>
|
||||
naturalCollator.compare(seasonA, seasonB)
|
||||
)
|
||||
.forEach(([seasonKey, episodes]) => {
|
||||
[...episodes].sort(compareEpisodes).forEach((episode) => {
|
||||
const seasonNumber = episodeSeasonNumber(episode, seasonKey);
|
||||
entries.push({
|
||||
episode,
|
||||
position:
|
||||
request.playbackPositions.get(Number(episode.id)) ??
|
||||
null,
|
||||
order: entries.length,
|
||||
seasonNumber,
|
||||
special: seasonNumber === 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a season holds extras (season 0), judged like the episodes in
|
||||
* `getSeriesNextUp`: by its episodes' own season, else by its key, which is
|
||||
* all an unloaded season has.
|
||||
*/
|
||||
export function isExtrasSeason(
|
||||
seasonKey: string,
|
||||
episodes: readonly XtreamSerieEpisode[] | null | undefined
|
||||
): boolean {
|
||||
return episodes && episodes.length > 0
|
||||
? episodes.every(
|
||||
(episode) => episodeSeasonNumber(episode, seasonKey) === 0
|
||||
)
|
||||
: toSeasonNumber(seasonKey) === 0;
|
||||
}
|
||||
|
||||
function episodeSeasonNumber(
|
||||
episode: XtreamSerieEpisode,
|
||||
seasonKey: string
|
||||
): number | null {
|
||||
return toSeasonNumber(episode.season) ?? toSeasonNumber(seasonKey);
|
||||
}
|
||||
|
||||
function compareEpisodes(
|
||||
episodeA: XtreamSerieEpisode,
|
||||
episodeB: XtreamSerieEpisode
|
||||
): number {
|
||||
const episodeNumberDelta =
|
||||
Number(episodeA.episode_num) - Number(episodeB.episode_num);
|
||||
|
||||
if (Number.isFinite(episodeNumberDelta) && episodeNumberDelta !== 0) {
|
||||
return episodeNumberDelta;
|
||||
}
|
||||
|
||||
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
|
||||
}
|
||||
|
||||
function toSeasonNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
const number = Number(text);
|
||||
return text !== '' && Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function newestEntry(
|
||||
entries: readonly SeriesEpisodeEntry[]
|
||||
): SeriesEpisodeEntry | null {
|
||||
let newest: SeriesEpisodeEntry | null = null;
|
||||
for (const entry of entries) {
|
||||
if (!newest || compareEntryRecency(entry, newest) > 0) {
|
||||
newest = entry;
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/** By save time; saves without one, or at the same time, by series order. */
|
||||
function compareEntryRecency(
|
||||
entryA: SeriesEpisodeEntry,
|
||||
entryB: SeriesEpisodeEntry
|
||||
): number {
|
||||
const delta =
|
||||
getTimestamp(entryA.position?.updatedAt) -
|
||||
getTimestamp(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 {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const timestamp = Date.parse(
|
||||
SQLITE_UTC_TIMESTAMP.test(value) ? `${value.replace(' ', 'T')}Z` : value
|
||||
);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
@@ -177,6 +177,155 @@ describe('getSeriesQuickStartAction', () => {
|
||||
expect(action?.episode).toBe(nextSeasonEpisode);
|
||||
});
|
||||
|
||||
describe('after the episode watched last', () => {
|
||||
const watchedAt = (
|
||||
id: number,
|
||||
hour: number
|
||||
): [number, PlaybackPositionData] => [
|
||||
id,
|
||||
position(id, {
|
||||
positionSeconds: 95,
|
||||
updatedAt: `2026-05-10T${hour}:00:00.000Z`,
|
||||
}),
|
||||
];
|
||||
|
||||
it('goes on from a later season the user started with', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'1': [episode(101, 1, 1), episode(102, 1, 2)],
|
||||
'2': [episode(201, 2, 1), episode(202, 2, 2)],
|
||||
},
|
||||
playbackPositions: new Map([watchedAt(201, 10)]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext);
|
||||
expect(action?.episodeLabel).toBe('S02E02 · Episode 2');
|
||||
});
|
||||
|
||||
it('does not lead into the specials of season 0', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'0': [episode(1, 0, 1)],
|
||||
'1': [episode(101, 1, 1), episode(102, 1, 2)],
|
||||
},
|
||||
playbackPositions: new Map([watchedAt(101, 10)]),
|
||||
});
|
||||
|
||||
expect(action?.episodeLabel).toBe('S01E02 · Episode 2');
|
||||
});
|
||||
|
||||
it('moves past an episode left unfinished before it', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'1': [
|
||||
episode(101, 1, 1),
|
||||
episode(102, 1, 2),
|
||||
episode(103, 1, 3),
|
||||
],
|
||||
},
|
||||
playbackPositions: new Map([
|
||||
[
|
||||
101,
|
||||
position(101, {
|
||||
positionSeconds: 40,
|
||||
updatedAt: '2026-05-10T09:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
watchedAt(102, 10),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext);
|
||||
expect(action?.episodeLabel).toBe('S01E03 · Episode 3');
|
||||
});
|
||||
|
||||
it('resumes the next episode where it was left', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'1': [episode(101, 1, 1), episode(102, 1, 2)],
|
||||
},
|
||||
playbackPositions: new Map([
|
||||
[
|
||||
102,
|
||||
position(102, {
|
||||
positionSeconds: 40,
|
||||
updatedAt: '2026-05-10T09:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
watchedAt(101, 10),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume);
|
||||
expect(action?.episodeLabel).toBe('S01E02 · Episode 2');
|
||||
expect(action?.position?.positionSeconds).toBe(40);
|
||||
});
|
||||
|
||||
it('offers an episode skipped before the finale once the finale is watched', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'1': [
|
||||
episode(101, 1, 1),
|
||||
episode(102, 1, 2),
|
||||
episode(103, 1, 3),
|
||||
],
|
||||
},
|
||||
playbackPositions: new Map([
|
||||
watchedAt(101, 10),
|
||||
watchedAt(103, 11),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext);
|
||||
expect(action?.episodeLabel).toBe('S01E02 · Episode 2');
|
||||
});
|
||||
|
||||
it('completes once the run is watched, even with specials left', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'0': [episode(1, 0, 1)],
|
||||
'1': [episode(101, 1, 1), episode(102, 1, 2)],
|
||||
},
|
||||
playbackPositions: new Map([
|
||||
watchedAt(101, 10),
|
||||
watchedAt(102, 11),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Completed);
|
||||
expect(action?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('starts a series with its first regular episode, not an extra', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'0': [episode(1, 0, 1)],
|
||||
'1': [episode(101, 1, 1), episode(102, 1, 2)],
|
||||
},
|
||||
playbackPositions: new Map(),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayFirst);
|
||||
expect(action?.episode.id).toBe('101');
|
||||
});
|
||||
|
||||
it('goes back to the run after an extra', () => {
|
||||
const action = getSeriesQuickStartAction({
|
||||
seasons: {
|
||||
'0': [episode(1, 0, 1), episode(2, 0, 2)],
|
||||
'1': [episode(101, 1, 1), episode(102, 1, 2)],
|
||||
},
|
||||
playbackPositions: new Map([
|
||||
watchedAt(101, 10),
|
||||
watchedAt(1, 11),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(action?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.PlayNext);
|
||||
expect(action?.episode.id).toBe('102');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a disabled completed action when every episode is watched', () => {
|
||||
const finalEpisode = episode(102, 1, 2);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { PlaybackPositionData, XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { isPortalPlaybackInProgress } from './portal-playback-positions';
|
||||
import {
|
||||
isPortalPlaybackInProgress,
|
||||
isPortalPlaybackWatched,
|
||||
} from './portal-playback-positions';
|
||||
getSeriesNextUp,
|
||||
type SeriesEpisodeEntry,
|
||||
type SeriesProgressRequest,
|
||||
} from './series-next-up';
|
||||
|
||||
export const SERIES_QUICK_START_ACTION_KIND = {
|
||||
PlayFirst: 'play-first',
|
||||
@@ -26,101 +28,80 @@ export interface SeriesQuickStartAction {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface SeriesQuickStartRequest {
|
||||
seasons: Record<string, XtreamSerieEpisode[]>;
|
||||
playbackPositions: Map<number, PlaybackPositionData>;
|
||||
}
|
||||
|
||||
interface OrderedEpisode {
|
||||
episode: XtreamSerieEpisode;
|
||||
position: PlaybackPositionData | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const naturalCollator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
/**
|
||||
* The series page's play button, for the episode the series goes on with
|
||||
* (`getSeriesNextUp`). A series caught up after its newest watched episode
|
||||
* offers the first one skipped before it, and completes when none is left.
|
||||
*/
|
||||
export function getSeriesQuickStartAction(
|
||||
request: SeriesQuickStartRequest
|
||||
request: SeriesProgressRequest
|
||||
): SeriesQuickStartAction | null {
|
||||
const orderedEpisodes = getOrderedEpisodes(request);
|
||||
|
||||
if (orderedEpisodes.length === 0) {
|
||||
const nextUp = getSeriesNextUp(request);
|
||||
if (!nextUp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedEpisodes = orderedEpisodes
|
||||
.filter(
|
||||
({ position }) =>
|
||||
position !== null && !isPortalPlaybackWatched(position)
|
||||
)
|
||||
.sort(comparePlaybackPositionRecency);
|
||||
|
||||
const latestStartedEpisode = startedEpisodes[startedEpisodes.length - 1];
|
||||
if (latestStartedEpisode?.position) {
|
||||
const shouldResume = isPortalPlaybackInProgress(
|
||||
latestStartedEpisode.position
|
||||
);
|
||||
if (nextUp.kind === 'caught-up') {
|
||||
return nextUp.skipped
|
||||
? continueAction(nextUp.skipped)
|
||||
: createQuickStartAction({
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.Completed,
|
||||
labelKey: 'XTREAM.SERIES_WATCHED',
|
||||
icon: 'check_circle',
|
||||
episode: nextUp.last.episode,
|
||||
position: nextUp.last.position,
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
if (nextUp.kind === 'next') {
|
||||
return continueAction(nextUp.entry);
|
||||
}
|
||||
const { episode, position } = nextUp.entry;
|
||||
if (nextUp.kind === 'start') {
|
||||
return createQuickStartAction({
|
||||
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
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.PlayFirst,
|
||||
labelKey: 'XTREAM.PLAY_FIRST_EPISODE',
|
||||
icon: 'play_arrow',
|
||||
episode: latestStartedEpisode.episode,
|
||||
position: latestStartedEpisode.position,
|
||||
episode,
|
||||
position,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
const firstUnwatchedEpisode = orderedEpisodes.find(
|
||||
({ position }) => !isPortalPlaybackWatched(position)
|
||||
);
|
||||
|
||||
if (firstUnwatchedEpisode) {
|
||||
const hasWatchedEpisode = orderedEpisodes.some(({ position }) =>
|
||||
isPortalPlaybackWatched(position)
|
||||
);
|
||||
|
||||
return createQuickStartAction({
|
||||
kind: hasWatchedEpisode
|
||||
? SERIES_QUICK_START_ACTION_KIND.PlayNext
|
||||
: SERIES_QUICK_START_ACTION_KIND.PlayFirst,
|
||||
labelKey: hasWatchedEpisode
|
||||
? 'XTREAM.PLAY_NEXT_EPISODE'
|
||||
: 'XTREAM.PLAY_FIRST_EPISODE',
|
||||
icon: 'play_arrow',
|
||||
episode: firstUnwatchedEpisode.episode,
|
||||
position: firstUnwatchedEpisode.position,
|
||||
disabled: false,
|
||||
});
|
||||
if (isPortalPlaybackInProgress(position)) {
|
||||
return resumeAction(nextUp.entry);
|
||||
}
|
||||
|
||||
const finalEpisode = orderedEpisodes[orderedEpisodes.length - 1];
|
||||
if (!finalEpisode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Launched, with no progress saved yet.
|
||||
return createQuickStartAction({
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.Completed,
|
||||
labelKey: 'XTREAM.SERIES_WATCHED',
|
||||
icon: 'check_circle',
|
||||
episode: finalEpisode.episode,
|
||||
position: finalEpisode.position,
|
||||
disabled: true,
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.PlayRecent,
|
||||
labelKey: 'XTREAM.PLAY_EPISODE',
|
||||
labelParams: { episode: Number(episode.episode_num) },
|
||||
icon: 'play_arrow',
|
||||
episode,
|
||||
position,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
function continueAction(entry: SeriesEpisodeEntry): SeriesQuickStartAction {
|
||||
return isPortalPlaybackInProgress(entry.position)
|
||||
? resumeAction(entry)
|
||||
: createQuickStartAction({
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.PlayNext,
|
||||
labelKey: 'XTREAM.PLAY_NEXT_EPISODE',
|
||||
icon: 'play_arrow',
|
||||
episode: entry.episode,
|
||||
position: entry.position,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resumeAction(entry: SeriesEpisodeEntry): SeriesQuickStartAction {
|
||||
return createQuickStartAction({
|
||||
kind: SERIES_QUICK_START_ACTION_KIND.Resume,
|
||||
labelKey: 'XTREAM.RESUME_EPISODE',
|
||||
icon: 'play_arrow',
|
||||
episode: entry.episode,
|
||||
position: entry.position,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,67 +114,6 @@ function createQuickStartAction(
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderedEpisodes(
|
||||
request: SeriesQuickStartRequest
|
||||
): OrderedEpisode[] {
|
||||
const orderedEpisodes: OrderedEpisode[] = [];
|
||||
|
||||
Object.entries(request.seasons)
|
||||
.sort(([seasonA], [seasonB]) =>
|
||||
naturalCollator.compare(seasonA, seasonB)
|
||||
)
|
||||
.forEach(([, episodes]) => {
|
||||
[...episodes].sort(compareEpisodes).forEach((episode) => {
|
||||
orderedEpisodes.push({
|
||||
episode,
|
||||
position:
|
||||
request.playbackPositions.get(Number(episode.id)) ??
|
||||
null,
|
||||
order: orderedEpisodes.length,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return orderedEpisodes;
|
||||
}
|
||||
|
||||
function compareEpisodes(
|
||||
episodeA: XtreamSerieEpisode,
|
||||
episodeB: XtreamSerieEpisode
|
||||
): number {
|
||||
const episodeNumberDelta =
|
||||
Number(episodeA.episode_num) - Number(episodeB.episode_num);
|
||||
|
||||
if (Number.isFinite(episodeNumberDelta) && episodeNumberDelta !== 0) {
|
||||
return episodeNumberDelta;
|
||||
}
|
||||
|
||||
return naturalCollator.compare(episodeA.title ?? '', episodeB.title ?? '');
|
||||
}
|
||||
|
||||
function comparePlaybackPositionRecency(
|
||||
episodeA: OrderedEpisode,
|
||||
episodeB: OrderedEpisode
|
||||
): number {
|
||||
const timeA = getTimestamp(episodeA.position?.updatedAt);
|
||||
const timeB = getTimestamp(episodeB.position?.updatedAt);
|
||||
|
||||
if (timeA !== timeB) {
|
||||
return timeA - timeB;
|
||||
}
|
||||
|
||||
return episodeA.order - episodeB.order;
|
||||
}
|
||||
|
||||
function getTimestamp(value: string | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function getEpisodeLabel(episode: XtreamSerieEpisode): string {
|
||||
const episodeCode = formatSeriesEpisodeCode(
|
||||
episode.season,
|
||||
|
||||
+7
-3
@@ -1,7 +1,8 @@
|
||||
import { Injectable, Signal, inject, signal, untracked } from '@angular/core';
|
||||
import {
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
createLogger,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
stampPlaybackPositionNow,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
StalkerStore,
|
||||
@@ -275,6 +276,9 @@ export class StalkerSeriesPositionsService {
|
||||
legacyPosition: PlaybackPositionData | undefined,
|
||||
clearedLegacy: boolean
|
||||
): void {
|
||||
// Dated like the stored rows, so the episode just played ranks as
|
||||
// the newest activity for the quick start and the season tabs.
|
||||
const saved = stampPlaybackPositionNow(position);
|
||||
const removedTrackingIds = new Set([position.contentXtreamId]);
|
||||
if (clearedLegacy && legacyPosition) {
|
||||
removedTrackingIds.add(legacyPosition.contentXtreamId);
|
||||
@@ -288,9 +292,9 @@ export class StalkerSeriesPositionsService {
|
||||
(candidate) =>
|
||||
!removedTrackingIds.has(candidate.contentXtreamId)
|
||||
),
|
||||
position,
|
||||
saved,
|
||||
]);
|
||||
this.updateEpisodePlaybackPosition(position);
|
||||
this.updateEpisodePlaybackPosition(saved);
|
||||
}
|
||||
|
||||
private publishClearedSeriesPosition(
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import type { VodSeriesSeasonVm } from '@iptvnator/portal/stalker/data-access';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { getStalkerSeriesQuickStartButton } from './stalker-series-quick-start';
|
||||
|
||||
function season(
|
||||
seasonNumber: string,
|
||||
loadedEpisodes: number,
|
||||
episodesLoaded = loadedEpisodes > 0
|
||||
): VodSeriesSeasonVm {
|
||||
return {
|
||||
id: `season-${seasonNumber}`,
|
||||
video_id: 'video-1',
|
||||
name: `Season ${seasonNumber}`,
|
||||
season_number: seasonNumber,
|
||||
episodes: Array.from(
|
||||
{ length: loadedEpisodes },
|
||||
(_, index) => ({ id: `${seasonNumber}-${index + 1}` }) as never
|
||||
),
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
episodesLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
/** Season `seasonNumber`'s mapped episodes, ids `seasonNumber * 100 + n`. */
|
||||
function mapped(seasonNumber: number, count: number): XtreamSerieEpisode[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
({
|
||||
id: String(seasonNumber * 100 + index + 1),
|
||||
episode_num: index + 1,
|
||||
title: `Episode ${index + 1}`,
|
||||
season: seasonNumber,
|
||||
container_extension: 'mpg',
|
||||
info: {},
|
||||
custom_sid: 'vod-series',
|
||||
added: '',
|
||||
direct_source: '',
|
||||
}) as XtreamSerieEpisode
|
||||
);
|
||||
}
|
||||
|
||||
function watched(...ids: number[]): Map<number, PlaybackPositionData> {
|
||||
return new Map(
|
||||
ids.map((id, index) => [
|
||||
id,
|
||||
{
|
||||
contentXtreamId: id,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 1,
|
||||
positionSeconds: 95,
|
||||
durationSeconds: 100,
|
||||
updatedAt: `2026-10-05T1${index}:00:00.000Z`,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
describe('getStalkerSeriesQuickStartButton with extras (season 0)', () => {
|
||||
// Specials and season 2 not loaded yet; season 1 loaded.
|
||||
const seasons = [season('0', 0), season('1', 4), season('2', 0)];
|
||||
const mappedSeasons = { '0': [], '1': mapped(1, 4), '2': [] };
|
||||
|
||||
it('plays the next regular episode without loading Specials first', () => {
|
||||
const button = getStalkerSeriesQuickStartButton({
|
||||
isVodSeries: true,
|
||||
mappedSeasons,
|
||||
playbackPositions: watched(101, 102, 103),
|
||||
vodSeriesSeasons: seasons,
|
||||
});
|
||||
|
||||
expect(button?.lazySeason).toBeNull();
|
||||
expect(button?.action?.episode.id).toBe('104');
|
||||
});
|
||||
|
||||
it('loads the next regular season, not Specials, once the loaded run is watched', () => {
|
||||
const button = getStalkerSeriesQuickStartButton({
|
||||
isVodSeries: true,
|
||||
mappedSeasons,
|
||||
playbackPositions: watched(101, 102, 103, 104),
|
||||
vodSeriesSeasons: seasons,
|
||||
});
|
||||
|
||||
expect(button?.lazySeason?.season_number).toBe('2');
|
||||
expect(button?.episodeLabel).toBe('S02E01');
|
||||
});
|
||||
|
||||
it('loads the first regular season, not Specials, when nothing is loaded', () => {
|
||||
const button = getStalkerSeriesQuickStartButton({
|
||||
isVodSeries: true,
|
||||
mappedSeasons: { '0': [], '1': [], '2': [] },
|
||||
playbackPositions: new Map(),
|
||||
vodSeriesSeasons: [season('0', 0), season('1', 0), season('2', 0)],
|
||||
});
|
||||
|
||||
expect(button?.lazySeason?.season_number).toBe('1');
|
||||
});
|
||||
|
||||
it('loads a season the portal has not answered for, not one that came back empty', () => {
|
||||
const button = getStalkerSeriesQuickStartButton({
|
||||
isVodSeries: true,
|
||||
mappedSeasons: { '0': [], '1': [], '2': [] },
|
||||
playbackPositions: new Map(),
|
||||
vodSeriesSeasons: [
|
||||
season('0', 0),
|
||||
season('1', 0, true),
|
||||
season('2', 0),
|
||||
],
|
||||
});
|
||||
|
||||
expect(button?.lazySeason?.season_number).toBe('2');
|
||||
});
|
||||
|
||||
it('loads a pending regular season before playing or resuming an extra', () => {
|
||||
// The pinned season 1 failed or is in flight; Specials was opened.
|
||||
const onlyExtrasLoaded = {
|
||||
isVodSeries: true,
|
||||
mappedSeasons: { '0': mapped(0, 2), '1': [], '2': [] },
|
||||
vodSeriesSeasons: [season('0', 2), season('1', 0), season('2', 0)],
|
||||
};
|
||||
|
||||
const play = getStalkerSeriesQuickStartButton({
|
||||
...onlyExtrasLoaded,
|
||||
playbackPositions: new Map(),
|
||||
});
|
||||
const resume = getStalkerSeriesQuickStartButton({
|
||||
...onlyExtrasLoaded,
|
||||
playbackPositions: new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
contentXtreamId: 1,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 1,
|
||||
positionSeconds: 40,
|
||||
durationSeconds: 100,
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
for (const button of [play, resume]) {
|
||||
expect(button?.lazySeason?.season_number).toBe('1');
|
||||
expect(button?.episodeLabel).toBe('S01E01');
|
||||
}
|
||||
});
|
||||
|
||||
it('still loads Specials when every regular season came back empty', () => {
|
||||
const button = getStalkerSeriesQuickStartButton({
|
||||
isVodSeries: true,
|
||||
mappedSeasons: { '0': [], '1': [], '2': [] },
|
||||
playbackPositions: new Map(),
|
||||
vodSeriesSeasons: [
|
||||
season('0', 0),
|
||||
season('1', 0, true),
|
||||
season('2', 0, true),
|
||||
],
|
||||
});
|
||||
|
||||
expect(button?.lazySeason?.season_number).toBe('0');
|
||||
});
|
||||
});
|
||||
+52
-10
@@ -2,11 +2,13 @@ import {
|
||||
SERIES_QUICK_START_ACTION_KIND,
|
||||
formatSeriesEpisodeCode,
|
||||
getSeriesQuickStartAction,
|
||||
isExtrasSeason,
|
||||
type SeriesQuickStartAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
getVodSeriesSeasonKey,
|
||||
getVodSeriesSeasonNumber,
|
||||
isVodSeasonHydrationPending,
|
||||
type VodSeriesSeasonVm,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import type {
|
||||
@@ -99,15 +101,34 @@ function getQuickStartLazyVodSeriesSeason(
|
||||
mappedSeasons: Record<string, XtreamSerieEpisode[]>,
|
||||
seasons: ReadonlyArray<VodSeriesSeasonVm>
|
||||
): VodSeriesSeasonVm | null {
|
||||
const firstUnloadedSeason = getFirstUnloadedVodSeriesSeason(seasons);
|
||||
const firstUnloadedSeason = getFirstUnloadedVodSeriesSeason(
|
||||
seasons,
|
||||
mappedSeasons
|
||||
);
|
||||
if (!action) {
|
||||
return firstUnloadedSeason;
|
||||
}
|
||||
if (!firstUnloadedSeason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionSeasonIndex = findVodSeriesSeasonIndexForEpisode(
|
||||
action.episode,
|
||||
seasons,
|
||||
mappedSeasons
|
||||
);
|
||||
// Only extras loaded while a regular season is pending: they stand in
|
||||
// for the run until it loads, never decide what plays.
|
||||
const actionSeason = seasons[actionSeasonIndex];
|
||||
if (
|
||||
!firstUnloadedSeason ||
|
||||
action.kind === SERIES_QUICK_START_ACTION_KIND.Resume
|
||||
actionSeason &&
|
||||
isExtrasVodSeason(actionSeason, mappedSeasons) &&
|
||||
!isExtrasVodSeason(firstUnloadedSeason, mappedSeasons)
|
||||
) {
|
||||
return firstUnloadedSeason;
|
||||
}
|
||||
|
||||
if (action.kind === SERIES_QUICK_START_ACTION_KIND.Resume) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -118,11 +139,6 @@ function getQuickStartLazyVodSeriesSeason(
|
||||
const unloadedSeasonIndex = seasons.findIndex(
|
||||
(season) => season.id === firstUnloadedSeason.id
|
||||
);
|
||||
const actionSeasonIndex = findVodSeriesSeasonIndexForEpisode(
|
||||
action.episode,
|
||||
seasons,
|
||||
mappedSeasons
|
||||
);
|
||||
|
||||
return unloadedSeasonIndex !== -1 &&
|
||||
actionSeasonIndex !== -1 &&
|
||||
@@ -131,10 +147,36 @@ function getQuickStartLazyVodSeriesSeason(
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* has other seasons that hold, or may still hold, episodes: as with its next
|
||||
* episode (`getSeriesNextUp`), they never decide what plays.
|
||||
*/
|
||||
function getFirstUnloadedVodSeriesSeason(
|
||||
seasons: ReadonlyArray<VodSeriesSeasonVm>
|
||||
seasons: ReadonlyArray<VodSeriesSeasonVm>,
|
||||
mappedSeasons: Record<string, XtreamSerieEpisode[]>
|
||||
): VodSeriesSeasonVm | null {
|
||||
return seasons.find((season) => season.episodes.length === 0) ?? null;
|
||||
const hasRun = seasons.some(
|
||||
(season) =>
|
||||
!isExtrasVodSeason(season, mappedSeasons) &&
|
||||
(season.episodes.length > 0 || isVodSeasonHydrationPending(season))
|
||||
);
|
||||
return (
|
||||
seasons.find(
|
||||
(season) =>
|
||||
isVodSeasonHydrationPending(season) &&
|
||||
!(hasRun && isExtrasVodSeason(season, mappedSeasons))
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function isExtrasVodSeason(
|
||||
season: VodSeriesSeasonVm,
|
||||
mappedSeasons: Record<string, XtreamSerieEpisode[]>
|
||||
): boolean {
|
||||
const key = getVodSeriesSeasonKey(season);
|
||||
return isExtrasSeason(key, mappedSeasons[key]);
|
||||
}
|
||||
|
||||
function findVodSeriesSeasonIndexForEpisode(
|
||||
|
||||
+1
@@ -190,6 +190,7 @@
|
||||
"
|
||||
[seasonWatchBatchRunning]="seasonWatchBatchRunning()"
|
||||
[hasUnloadedSeasons]="hasUnloadedVodSeasons()"
|
||||
[unloadedSeasonKeys]="unloadedVodSeasonKeys()"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
(playbackToggleRequested)="
|
||||
|
||||
+5
@@ -1366,6 +1366,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
: {}
|
||||
);
|
||||
|
||||
/** The seasons still to load, for the season tabs' auto-selection. */
|
||||
readonly unloadedVodSeasonKeys = computed(() =>
|
||||
Object.keys(this.vodSeasonLoadStates())
|
||||
);
|
||||
|
||||
handleSeriesPlaybackToggleRequested(
|
||||
request: SeasonContainerSeriesPlaybackToggleRequest
|
||||
): Promise<void> {
|
||||
|
||||
+4
-4
@@ -429,7 +429,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
|
||||
fixture.componentInstance
|
||||
.episodePlaybackPositions()
|
||||
.get(SCOPED_A_ID)
|
||||
).toBe(watchedPosition);
|
||||
).toEqual({ ...watchedPosition, updatedAt: expect.any(String) });
|
||||
|
||||
const seasons = fixture.componentInstance.vodSeriesSeasons();
|
||||
fixture.componentInstance.vodSeriesSeasons.set(
|
||||
@@ -477,7 +477,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
|
||||
fixture.componentInstance
|
||||
.episodePlaybackPositions()
|
||||
.get(SCOPED_A_ID)
|
||||
).toBe(newerPosition);
|
||||
).toEqual({ ...newerPosition, updatedAt: expect.any(String) });
|
||||
|
||||
const latestPosition = createPosition({ positionSeconds: 80 });
|
||||
await fixture.componentInstance.handlePlaybackToggleRequested({
|
||||
@@ -889,7 +889,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
|
||||
fixture.componentInstance
|
||||
.episodePlaybackPositions()
|
||||
.get(SCOPED_A_ID)
|
||||
).toBe(savedPosition);
|
||||
).toEqual({ ...savedPosition, updatedAt: expect.any(String) });
|
||||
});
|
||||
|
||||
it('does not let a load started before a save overwrite the saved position', async () => {
|
||||
@@ -1085,7 +1085,7 @@ describe('StalkerSeriesViewComponent position compatibility', () => {
|
||||
fixture.componentInstance
|
||||
.episodePlaybackPositions()
|
||||
.get(SCOPED_A_ID)
|
||||
).toBe(runtimePosition);
|
||||
).toEqual({ ...runtimePosition, updatedAt: expect.any(String) });
|
||||
});
|
||||
|
||||
it('clears confirmed legacy before scoped watched state without resurrection after refetch', async () => {
|
||||
|
||||
+59
-1
@@ -8,6 +8,7 @@ import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
SERIES_QUICK_START_ACTION_KIND,
|
||||
type SeriesResumeTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
@@ -15,7 +16,10 @@ import {
|
||||
type StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
import { STALKER_SERIES_RESUME_TARGET } from './stalker-series-resume';
|
||||
|
||||
@@ -135,7 +139,9 @@ describe('StalkerSeriesViewComponent dashboard resume handoff', () => {
|
||||
useValue: {
|
||||
getSeriesPlaybackPositions,
|
||||
savePlaybackPosition: jest.fn(),
|
||||
savePlaybackPositionOrThrow: jest.fn(),
|
||||
clearPlaybackPosition: jest.fn(),
|
||||
clearPlaybackPositionOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -415,4 +421,56 @@ describe('StalkerSeriesViewComponent dashboard resume handoff', () => {
|
||||
90
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes the episode just played inline ahead of one watched earlier', async () => {
|
||||
// The stored row carries SQLite's write time; the page's own tick
|
||||
// carries none, yet it is the newest activity: the quick start must
|
||||
// resume it, not go on from the episode watched before.
|
||||
getSeriesPlaybackPositions.mockImplementation(async () => [
|
||||
{
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: trackingIdOf('1', 2),
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 30001,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: 1750,
|
||||
durationSeconds: 1800,
|
||||
updatedAt: '2026-10-03 08:00:00',
|
||||
} satisfies PlaybackPositionData,
|
||||
]);
|
||||
await stabilize();
|
||||
await settle();
|
||||
expect(fixture.componentInstance.quickStartAction()?.action?.kind).toBe(
|
||||
SERIES_QUICK_START_ACTION_KIND.PlayNext
|
||||
);
|
||||
|
||||
fixture.componentInstance.inlinePlayback.set({
|
||||
streamUrl: 'https://resolved.example/episode.mpg',
|
||||
title: 'Regular Series',
|
||||
thumbnail: 'poster.jpg',
|
||||
contentInfo: {
|
||||
playlistId: 'stalker-1',
|
||||
contentXtreamId: trackingIdOf('1', 1),
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 30001,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
},
|
||||
} as ResolvedPortalPlayback);
|
||||
fixture.componentInstance.handleInlineTimeUpdate({
|
||||
currentTime: 600,
|
||||
duration: 1800,
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
fixture.componentInstance
|
||||
.episodePlaybackPositions()
|
||||
.get(trackingIdOf('1', 1))?.updatedAt
|
||||
).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
const quickStart = fixture.componentInstance.quickStartAction()?.action;
|
||||
expect(quickStart?.kind).toBe(SERIES_QUICK_START_ACTION_KIND.Resume);
|
||||
expect(Number(quickStart?.episode.episode_num)).toBe(1);
|
||||
});
|
||||
});
|
||||
+1
@@ -16,6 +16,7 @@ import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data
|
||||
export class StubSeasonContainerComponent {
|
||||
readonly seasons = input<unknown>(null);
|
||||
readonly hasUnloadedSeasons = input(false);
|
||||
readonly unloadedSeasonKeys = input<readonly string[]>([]);
|
||||
readonly seriesId = input<number | string | null>(null);
|
||||
readonly playlistId = input('');
|
||||
readonly seriesTitle = input<string | undefined>(undefined);
|
||||
|
||||
+6
-3
@@ -7,6 +7,7 @@ import {
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import { PORTAL_WATCHED_PROGRESS_PERCENT } from '@iptvnator/portal/shared/util/tokens';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XTREAM_DATA_SOURCE,
|
||||
@@ -64,7 +65,8 @@ export function withPlaybackPositions() {
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if content is considered "watched" (>90% complete)
|
||||
* Check if content is considered "watched" (see
|
||||
* PORTAL_WATCHED_PROGRESS_PERCENT)
|
||||
*/
|
||||
isWatched(
|
||||
contentXtreamId: number,
|
||||
@@ -72,7 +74,7 @@ export function withPlaybackPositions() {
|
||||
): boolean {
|
||||
return (
|
||||
this.getProgressPercent(contentXtreamId, contentType) >=
|
||||
90
|
||||
PORTAL_WATCHED_PROGRESS_PERCENT
|
||||
);
|
||||
},
|
||||
|
||||
@@ -92,7 +94,8 @@ export function withPlaybackPositions() {
|
||||
contentType
|
||||
);
|
||||
const inProgress =
|
||||
position.positionSeconds > 10 && percent < 90;
|
||||
position.positionSeconds > 10 &&
|
||||
percent < PORTAL_WATCHED_PROGRESS_PERCENT;
|
||||
return inProgress;
|
||||
},
|
||||
|
||||
|
||||
+122
-10
@@ -1,12 +1,18 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import type { SeriesQuickStartAction } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type PlaybackPositionData,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SERIES_MENU_ACTION } from '@iptvnator/ui/components';
|
||||
import { SerialDetailsMenuService } from './serial-details-menu.service';
|
||||
|
||||
@@ -27,11 +33,19 @@ function quickStart(
|
||||
|
||||
describe('SerialDetailsMenuService', () => {
|
||||
const quickStartSignal = signal<SeriesQuickStartAction | null>(null);
|
||||
const recentItemsSignal = signal<{ type: string; xtream_id: number }[]>([]);
|
||||
const episodePositionsSignal = signal(
|
||||
new Map<number, PlaybackPositionData>()
|
||||
);
|
||||
const episodesSignal = signal<Record<string, unknown[]>>({});
|
||||
const openEpisodeExternally = jest.fn().mockResolvedValue(undefined);
|
||||
let service: SerialDetailsMenuService;
|
||||
|
||||
beforeEach(() => {
|
||||
quickStartSignal.set(quickStart());
|
||||
recentItemsSignal.set([]);
|
||||
episodePositionsSignal.set(new Map());
|
||||
episodesSignal.set({});
|
||||
openEpisodeExternally.mockClear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -42,11 +56,15 @@ describe('SerialDetailsMenuService', () => {
|
||||
currentPlaylist: signal({ id: 'xtream-1' }),
|
||||
loadRecentItems: jest.fn(),
|
||||
serialCategories: signal([]),
|
||||
recentItems: signal([]),
|
||||
recentItems: recentItemsSignal,
|
||||
constructEpisodeStreamUrl: () =>
|
||||
'http://xtream.example/episode.mp4',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: { removeRecentItem: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsManagedExternalPlayers: true },
|
||||
@@ -65,15 +83,18 @@ describe('SerialDetailsMenuService', () => {
|
||||
});
|
||||
service = TestBed.inject(SerialDetailsMenuService);
|
||||
service.bind({
|
||||
selectedItem: signal({
|
||||
series_id: 103,
|
||||
info: {},
|
||||
episodes: {},
|
||||
} as never),
|
||||
selectedItem: computed(
|
||||
() =>
|
||||
({
|
||||
series_id: 103,
|
||||
info: {},
|
||||
episodes: episodesSignal(),
|
||||
}) as never
|
||||
),
|
||||
quickStart: quickStartSignal,
|
||||
seasonContainer: signal(undefined),
|
||||
categoryId: signal(''),
|
||||
episodePositions: signal(new Map()),
|
||||
episodePositions: episodePositionsSignal,
|
||||
playbackActive: signal(false),
|
||||
startPending: signal(false),
|
||||
resetProgress: jest.fn(),
|
||||
@@ -109,4 +130,95 @@ describe('SerialDetailsMenuService', () => {
|
||||
await service.run(SERIES_MENU_ACTION.ExternalPlayer);
|
||||
expect(openEpisodeExternally).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Hide from Continue Watching', () => {
|
||||
const watched = (
|
||||
contentXtreamId: number,
|
||||
updatedAt = '2026-10-04T08:00:00Z'
|
||||
): [number, PlaybackPositionData] => [
|
||||
contentXtreamId,
|
||||
{
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
// Stopped during the credits: 55 s left.
|
||||
positionSeconds: 1887,
|
||||
durationSeconds: 1942,
|
||||
updatedAt,
|
||||
},
|
||||
];
|
||||
const episode = (id: number, episodeNum: number) => ({
|
||||
id: String(id),
|
||||
season: 1,
|
||||
episode_num: episodeNum,
|
||||
title: `Episode ${episodeNum}`,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
recentItemsSignal.set([{ type: 'series', xtream_id: 103 }]);
|
||||
episodesSignal.set({
|
||||
'1': [episode(1001, 1), episode(1002, 2), episode(1003, 3)],
|
||||
});
|
||||
});
|
||||
|
||||
it('is offered once an episode is finished while the next one remains, as the rail lists the series with it', () => {
|
||||
episodePositionsSignal.set(new Map([watched(1001)]));
|
||||
|
||||
expect(rowIds()).toContain(
|
||||
SERIES_MENU_ACTION.HideFromContinueWatching
|
||||
);
|
||||
});
|
||||
|
||||
it('is not offered once every episode is watched, as the rail no longer lists the series', () => {
|
||||
episodePositionsSignal.set(
|
||||
new Map([watched(1001), watched(1002), watched(1003)])
|
||||
);
|
||||
quickStartSignal.set(
|
||||
quickStart({ kind: 'completed', disabled: true })
|
||||
);
|
||||
|
||||
expect(rowIds()).not.toContain(
|
||||
SERIES_MENU_ACTION.HideFromContinueWatching
|
||||
);
|
||||
});
|
||||
|
||||
it('is not offered once the run is watched, even with an extra watched after it', () => {
|
||||
const extra = (id: number, episodeNum: number) => ({
|
||||
...episode(id, episodeNum),
|
||||
season: 0,
|
||||
});
|
||||
episodesSignal.set({
|
||||
'0': [extra(1090, 1), extra(1091, 2)],
|
||||
'1': [episode(1001, 1), episode(1002, 2), episode(1003, 3)],
|
||||
});
|
||||
episodePositionsSignal.set(
|
||||
new Map([
|
||||
watched(1001),
|
||||
watched(1002),
|
||||
watched(1003),
|
||||
watched(1090, '2026-10-05T08:00:00Z'),
|
||||
])
|
||||
);
|
||||
|
||||
expect(rowIds()).not.toContain(
|
||||
SERIES_MENU_ACTION.HideFromContinueWatching
|
||||
);
|
||||
});
|
||||
|
||||
it('is not offered once the last episode is watched, though the page still offers one skipped before it', () => {
|
||||
episodePositionsSignal.set(
|
||||
new Map([
|
||||
watched(1001, '2026-10-03T08:00:00Z'),
|
||||
watched(1003, '2026-10-04T08:00:00Z'),
|
||||
])
|
||||
);
|
||||
// Quick start names episode 2, the one skipped.
|
||||
quickStartSignal.set(quickStart({ kind: 'play-next' }));
|
||||
|
||||
expect(rowIds()).not.toContain(
|
||||
SERIES_MENU_ACTION.HideFromContinueWatching
|
||||
);
|
||||
expect(rowIds()).toContain(SERIES_MENU_ACTION.ExternalPlayer);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
createLogger,
|
||||
formatSeriesEpisodeCode,
|
||||
getSeriesNextUp,
|
||||
type SeriesQuickStartAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type ExternalPlayerName,
|
||||
type PlaybackPositionData,
|
||||
type PlayerContentInfo,
|
||||
type ResolvedPortalPlayback,
|
||||
type XtreamCategory,
|
||||
@@ -34,7 +36,9 @@ interface SerialDetailsMenuBindings {
|
||||
readonly seasonContainer: Signal<SeasonContainerComponent | undefined>;
|
||||
/** The route's category id; the name comes from the store's list. */
|
||||
readonly categoryId: Signal<string>;
|
||||
readonly episodePositions: Signal<ReadonlyMap<number, unknown>>;
|
||||
readonly episodePositions: Signal<
|
||||
ReadonlyMap<number, PlaybackPositionData>
|
||||
>;
|
||||
/** An episode plays or launches: its next tick would undo a reset. */
|
||||
readonly playbackActive: Signal<boolean>;
|
||||
/** A forced launch has not published its session yet: bulk watched actions would include it. */
|
||||
@@ -122,16 +126,26 @@ export class SerialDetailsMenuService {
|
||||
};
|
||||
});
|
||||
|
||||
/** Whether the series row sits in this playlist's recently viewed list. */
|
||||
/** Whether the series is on the dashboard's Continue Watching rail. */
|
||||
private readonly inContinueWatching = computed(() => {
|
||||
const seriesId = this.seriesId();
|
||||
if (!seriesId || !this.dataSource) {
|
||||
return false;
|
||||
}
|
||||
// The rail keeps a series while an unwatched episode follows the
|
||||
// one watched last; this page's quick start may still offer an
|
||||
// episode skipped before it.
|
||||
const caughtUp =
|
||||
getSeriesNextUp({
|
||||
seasons: this.bindings()?.selectedItem()?.episodes ?? {},
|
||||
playbackPositions:
|
||||
this.bindings()?.episodePositions() ?? new Map(),
|
||||
})?.kind === 'caught-up';
|
||||
// Xtream ids collide across live, movies and series: the row has to
|
||||
// be the series' own.
|
||||
return (
|
||||
this.hasProgress() &&
|
||||
!caughtUp &&
|
||||
this.xtreamStore
|
||||
.recentItems()
|
||||
.some(
|
||||
|
||||
+46
@@ -3,6 +3,7 @@ import type {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { getSeriesQuickStartAction } from '@iptvnator/portal/shared/util';
|
||||
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
|
||||
import type { XtreamSerieDetailsView } from './serial-details-playback.service';
|
||||
|
||||
@@ -111,6 +112,51 @@ describe('SerialDetailsPlaybackPositionState', () => {
|
||||
});
|
||||
|
||||
describe('bulk mutators', () => {
|
||||
it('dates the rows the page writes, so the episode just played ranks newest', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-10-04T09:00:00.000Z') });
|
||||
try {
|
||||
// Stored rows carry the store's time; a player tick, an
|
||||
// external-player update and a toggle do not.
|
||||
await loadPositions([
|
||||
position(1001, {
|
||||
positionSeconds: 600,
|
||||
updatedAt: '2026-10-03 08:00:00',
|
||||
}),
|
||||
]);
|
||||
const tick = position(1002, { positionSeconds: 600 });
|
||||
delete tick.updatedAt;
|
||||
|
||||
state.update(tick);
|
||||
|
||||
expect(state.positions().get(1002)?.updatedAt).toBe(
|
||||
'2026-10-04T09:00:00.000Z'
|
||||
);
|
||||
// The series page resumes the episode just played, not the
|
||||
// one stored yesterday.
|
||||
expect(
|
||||
getSeriesQuickStartAction({
|
||||
seasons: seriesView().episodes,
|
||||
playbackPositions: state.positions(),
|
||||
})
|
||||
).toMatchObject({ kind: 'resume', episode: { id: '1002' } });
|
||||
|
||||
state.updateMany([{ ...tick, contentXtreamId: 2001 }]);
|
||||
expect(state.positions().get(2001)?.updatedAt).toBe(
|
||||
'2026-10-04T09:00:00.000Z'
|
||||
);
|
||||
// The caller's row is not modified, and a dated row keeps its date.
|
||||
expect(tick.updatedAt).toBeUndefined();
|
||||
state.update(
|
||||
position(1001, { updatedAt: '2026-10-05 07:00:00' })
|
||||
);
|
||||
expect(state.positions().get(1001)?.updatedAt).toBe(
|
||||
'2026-10-05 07:00:00'
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('updates many positions with a single map replacement', async () => {
|
||||
await loadPositions([position(1001)]);
|
||||
const before = state.positions();
|
||||
|
||||
+12
-3
@@ -1,5 +1,8 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type { SeriesResumeTarget } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
stampPlaybackPositionNow,
|
||||
type SeriesResumeTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
PlaybackPositionData,
|
||||
@@ -113,7 +116,10 @@ export class SerialDetailsPlaybackPositionState {
|
||||
|
||||
update(position: PlaybackPositionData): void {
|
||||
const updated = new Map(this.positions());
|
||||
updated.set(position.contentXtreamId, position);
|
||||
updated.set(
|
||||
position.contentXtreamId,
|
||||
stampPlaybackPositionNow(position)
|
||||
);
|
||||
this.positions.set(updated);
|
||||
}
|
||||
|
||||
@@ -123,7 +129,10 @@ export class SerialDetailsPlaybackPositionState {
|
||||
}
|
||||
const updated = new Map(this.positions());
|
||||
for (const position of positions) {
|
||||
updated.set(position.contentXtreamId, position);
|
||||
updated.set(
|
||||
position.contentXtreamId,
|
||||
stampPlaybackPositionNow(position)
|
||||
);
|
||||
}
|
||||
this.positions.set(updated);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Signal, computed, effect, signal } from '@angular/core';
|
||||
import { getPortalPlaybackProgressPercent } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
getPortalPlaybackProgressPercent,
|
||||
PORTAL_WATCHED_PROGRESS_PERCENT,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
VodSourceDescriptor,
|
||||
@@ -60,7 +63,7 @@ export function isResumablePosition(
|
||||
position: PlaybackPositionData | null
|
||||
): boolean {
|
||||
const progress = getPortalPlaybackProgressPercent(position);
|
||||
return progress > 0 && progress < 90;
|
||||
return progress > 0 && progress < PORTAL_WATCHED_PROGRESS_PERCENT;
|
||||
}
|
||||
|
||||
export function createPrimaryActionPosition(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { PORTAL_WATCHED_PROGRESS_PERCENT } from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-progress-capsule',
|
||||
@@ -53,5 +54,7 @@ import {
|
||||
})
|
||||
export class ProgressCapsuleComponent {
|
||||
readonly progress = input.required<number>();
|
||||
readonly isWatched = computed(() => this.progress() >= 90);
|
||||
readonly isWatched = computed(
|
||||
() => this.progress() >= PORTAL_WATCHED_PROGRESS_PERCENT
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export interface SeasonAutoSelectSources {
|
||||
) => PlaybackPositionData | undefined;
|
||||
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
|
||||
readonly hasUnloadedSeasons: Signal<boolean>;
|
||||
/** Stalker lazy-VOD: the seasons whose lists are not loaded yet. */
|
||||
readonly unloadedSeasonKeys: Signal<readonly string[]>;
|
||||
readonly episodeCounts: Signal<Record<string, number>>;
|
||||
readonly watchedCounts: Signal<Record<string, number>>;
|
||||
readonly emitSeasonSelected: (seasonKey: string) => void;
|
||||
@@ -95,7 +97,10 @@ export function createSeasonAutoSelectState(
|
||||
playingSeasonKey: sources.playingSeasonKey(),
|
||||
seasons: sources.seasons(),
|
||||
positionOf: sources.positionOf,
|
||||
hasUnloadedSeasons: sources.hasUnloadedSeasons(),
|
||||
hasUnloadedSeasons:
|
||||
sources.hasUnloadedSeasons() ||
|
||||
sources.unloadedSeasonKeys().length > 0,
|
||||
unloadedSeasonKeys: sources.unloadedSeasonKeys(),
|
||||
episodeCounts: sources.episodeCounts(),
|
||||
watchedCounts: sources.watchedCounts(),
|
||||
});
|
||||
|
||||
@@ -191,6 +191,18 @@ describe('resolveAutoSelectedSeason', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the first season still to load past one answered empty', () => {
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': [], '2': [], '3': ['301'] },
|
||||
{},
|
||||
{ hasUnloadedSeasons: true, unloadedSeasonKeys: ['2'] }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
|
||||
it('pins the first key while seasons are still unloaded', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
@@ -210,4 +222,100 @@ describe('resolveAutoSelectedSeason', () => {
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
describe('extras (season 0)', () => {
|
||||
it('do not open first while the series has other seasons', () => {
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({ '0': ['1'], '1': ['101', '102'] })
|
||||
)
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
it('do not pull the page to an extra left unfinished', () => {
|
||||
const ctx = context(
|
||||
{ '0': ['1'], '1': ['101', '102'] },
|
||||
{
|
||||
'1': inProgress(1, '2026-07-09T00:00:00.000Z'),
|
||||
'101': watched(101),
|
||||
}
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('are not the unwatched season once the run is watched', () => {
|
||||
const ctx = context(
|
||||
{ '0': ['1'], '1': ['101'], '2': ['201'] },
|
||||
{ '101': watched(101), '201': watched(201) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('are not the first season to load while seasons are unloaded', () => {
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '0': [], '1': [], '2': [] },
|
||||
{},
|
||||
{ hasUnloadedSeasons: true }
|
||||
)
|
||||
)
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
it('are the run of a series filed under season 0 alone', () => {
|
||||
expect(
|
||||
resolveAutoSelectedSeason(context({ '0': ['1', '2'] }))
|
||||
).toBe('0');
|
||||
});
|
||||
|
||||
it('open when every regular season came back empty', () => {
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({ '0': ['1'], '1': [], '2': [] })
|
||||
)
|
||||
).toBe('0');
|
||||
});
|
||||
|
||||
it('open when every regular season came back empty and only they are still to load', () => {
|
||||
// The aggregate flag is true because of the extras alone: the
|
||||
// run has nothing to show, so the page loads the extras.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '0': [], '1': [], '2': [] },
|
||||
{},
|
||||
{
|
||||
hasUnloadedSeasons: true,
|
||||
unloadedSeasonKeys: ['0'],
|
||||
}
|
||||
)
|
||||
)
|
||||
).toBe('0');
|
||||
// While a regular season is still to load, it comes first.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '0': [], '1': [], '2': [] },
|
||||
{},
|
||||
{
|
||||
hasUnloadedSeasons: true,
|
||||
unloadedSeasonKeys: ['0', '2'],
|
||||
}
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
|
||||
it('still open while one is playing', () => {
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '0': ['1'], '1': ['101'] },
|
||||
{},
|
||||
{ playingSeasonKey: '0' }
|
||||
)
|
||||
)
|
||||
).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { isPortalPlaybackInProgress } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
isExtrasSeason,
|
||||
isPortalPlaybackInProgress,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
@@ -15,6 +18,12 @@ export interface AutoSeasonContext {
|
||||
) => PlaybackPositionData | undefined;
|
||||
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
|
||||
hasUnloadedSeasons: boolean;
|
||||
/**
|
||||
* Stalker lazy-VOD: the seasons whose lists are not loaded yet (pending
|
||||
* or loading). `hasUnloadedSeasons` alone cannot tell one of them from a
|
||||
* season the portal answered empty.
|
||||
*/
|
||||
unloadedSeasonKeys?: readonly string[];
|
||||
episodeCounts: Record<string, number>;
|
||||
watchedCounts: Record<string, number>;
|
||||
}
|
||||
@@ -23,8 +32,10 @@ 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. Pure, so the container's auto-select effect stays a thin
|
||||
* wrapper (see `SeasonContainerComponent.selectedSeason`).
|
||||
* 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`).
|
||||
*/
|
||||
export function resolveAutoSelectedSeason(
|
||||
context: AutoSeasonContext
|
||||
@@ -36,8 +47,21 @@ export function resolveAutoSelectedSeason(
|
||||
if (context.playingSeasonKey) {
|
||||
return context.playingSeasonKey;
|
||||
}
|
||||
const unloaded = new Set(context.unloadedSeasonKeys ?? []);
|
||||
const runKeys = keys.filter(
|
||||
(key) => !isExtrasSeason(key, context.seasons[key])
|
||||
);
|
||||
// A run that came back empty does not hide the extras; one still to
|
||||
// load may. Without per-season states the aggregate flag stands in.
|
||||
const runMayHaveEpisodes =
|
||||
runKeys.some(
|
||||
(key) => unloaded.has(key) || (context.episodeCounts[key] ?? 0) > 0
|
||||
) ||
|
||||
(unloaded.size === 0 && context.hasUnloadedSeasons);
|
||||
const steering = runKeys.length > 0 && runMayHaveEpisodes ? runKeys : keys;
|
||||
return (
|
||||
findMostRecentInProgressSeason(context) ?? resolveDefaultSeason(context)
|
||||
findMostRecentInProgressSeason(context, steering) ??
|
||||
resolveDefaultSeason(context, steering, unloaded)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,17 +71,21 @@ export function resolveAutoSelectedSeason(
|
||||
* non-empty season, where new episodes land (issue #1441). Loaded-but-empty
|
||||
* seasons (a valid Stalker answer) are never picked over one that has
|
||||
* episodes. Stalker lazy-VOD series with unhydrated seasons keep the first
|
||||
* season: their watched state is unknown, so skipping past them would be a
|
||||
* guess.
|
||||
* season still to load or holding episodes: the watched state of a pending
|
||||
* season is unknown, so skipping past it would be a guess, while a season
|
||||
* answered empty before it has nothing to show.
|
||||
*/
|
||||
function resolveDefaultSeason({
|
||||
keys,
|
||||
hasUnloadedSeasons,
|
||||
episodeCounts,
|
||||
watchedCounts,
|
||||
}: AutoSeasonContext): string {
|
||||
function resolveDefaultSeason(
|
||||
{ hasUnloadedSeasons, episodeCounts, watchedCounts }: AutoSeasonContext,
|
||||
keys: readonly string[],
|
||||
unloaded: ReadonlySet<string>
|
||||
): string {
|
||||
if (hasUnloadedSeasons) {
|
||||
return keys[0];
|
||||
return (
|
||||
keys.find(
|
||||
(key) => unloaded.has(key) || (episodeCounts[key] ?? 0) > 0
|
||||
) ?? keys[0]
|
||||
);
|
||||
}
|
||||
const firstUnwatched = keys.find((key) => {
|
||||
const total = episodeCounts[key] ?? 0;
|
||||
@@ -72,13 +100,16 @@ function resolveDefaultSeason({
|
||||
return latestWithEpisodes ?? keys[0];
|
||||
}
|
||||
|
||||
function findMostRecentInProgressSeason({
|
||||
seasons,
|
||||
positionOf,
|
||||
}: AutoSeasonContext): string | null {
|
||||
function findMostRecentInProgressSeason(
|
||||
{ seasons, positionOf }: AutoSeasonContext,
|
||||
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 ?? []) {
|
||||
const position = positionOf(episode);
|
||||
if (!isPortalPlaybackInProgress(position)) {
|
||||
|
||||
@@ -113,6 +113,11 @@ export class SeasonContainerComponent implements OnInit {
|
||||
* count from the series action label.
|
||||
*/
|
||||
readonly hasUnloadedSeasons = input(false);
|
||||
/**
|
||||
* The seasons whose lists are not loaded yet (Stalker lazy-VOD), so the
|
||||
* auto-selection can tell them from seasons the portal answered empty.
|
||||
*/
|
||||
readonly unloadedSeasonKeys = input<readonly string[]>([]);
|
||||
|
||||
readonly episodeClicked = output<XtreamSerieEpisode>();
|
||||
readonly playbackToggleRequested =
|
||||
@@ -259,6 +264,7 @@ export class SeasonContainerComponent implements OnInit {
|
||||
playbackPositions: this.playbackPositions,
|
||||
positionOf: (episode) => this.getEpisodePosition(episode),
|
||||
hasUnloadedSeasons: this.hasUnloadedSeasons,
|
||||
unloadedSeasonKeys: this.unloadedSeasonKeys,
|
||||
episodeCounts: this.episodeCounts,
|
||||
watchedCounts: this.watchedCounts,
|
||||
emitSeasonSelected: (seasonKey) =>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
resolvePortalActivityWatchKind,
|
||||
type PlaybackPositionData,
|
||||
type PlaylistMeta,
|
||||
type PortalActivityItem,
|
||||
type PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS,
|
||||
dashboardRecentItemKey,
|
||||
planDashboardSeriesLookups,
|
||||
resolveDashboardSeriesContinuation,
|
||||
selectSeriesContinuationCandidates,
|
||||
type DashboardSeriesContinuation,
|
||||
} from './dashboard-series-continuation.util';
|
||||
import {
|
||||
DashboardSeriesEpisodesService,
|
||||
dashboardSeriesEpisodesKey,
|
||||
sameDashboardSeriesEpisodesRequests,
|
||||
type DashboardSeriesEpisodesRequest,
|
||||
} from './dashboard-series-episodes.service';
|
||||
|
||||
/** What the rail reads from the dashboard's history and playback positions. */
|
||||
export interface DashboardContinueWatchingDeps {
|
||||
/** The history's movies and series, newest first. */
|
||||
readonly items: Signal<readonly PortalRecentItem[]>;
|
||||
/** The history has loaded once. */
|
||||
readonly historyLoaded: Signal<boolean>;
|
||||
readonly playlists: Signal<readonly PlaylistMeta[]>;
|
||||
/** A title's stored position (a series: its newest episode's). */
|
||||
readonly storedPosition: (
|
||||
item: PortalActivityItem
|
||||
) => PlaybackPositionData | null;
|
||||
/** Every episode row of a series: what comes next depends on all of them. */
|
||||
readonly episodeRows: (
|
||||
playlistId: string,
|
||||
seriesXtreamId: number
|
||||
) => readonly PlaybackPositionData[] | undefined;
|
||||
/** Playlists whose positions have loaded; null before any load. */
|
||||
readonly positionsLoadedFor: Signal<ReadonlySet<string> | null>;
|
||||
/**
|
||||
* Counts the position reloads that landed (dashboard entry, a changed
|
||||
* history, "Mark watched"): each is a lookup round, which retries failed
|
||||
* episode lookups and refreshes lists past their age.
|
||||
*/
|
||||
readonly positionReloads: Signal<number>;
|
||||
}
|
||||
|
||||
export interface DashboardContinueWatching {
|
||||
/**
|
||||
* Continue Watching tells unfinished titles from finished ones by their
|
||||
* playback positions, so it waits until the history and the positions of
|
||||
* every playlist in it have loaded once, and until the episode lists of
|
||||
* series whose newest episode is watched or an extra are in (for at most
|
||||
* CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS): rendering every title first
|
||||
* and dropping the finished ones a moment later would shift the page.
|
||||
* Stays true afterwards; later reloads update the rail in place.
|
||||
*/
|
||||
readonly settled: Signal<boolean>;
|
||||
/**
|
||||
* Recent movies and series the user has not finished, newest first. A
|
||||
* movie leaves once its position reaches the watched threshold, whether
|
||||
* playback got there or the user marked it. A series whose newest
|
||||
* episode is watched, or is an extra, stays with the episode it
|
||||
* continues with and leaves once none follows the one watched last
|
||||
* (extras aside); one that cannot be looked up keeps its place. The full
|
||||
* history stays on the global recent page.
|
||||
*/
|
||||
readonly items: Signal<PortalRecentItem[]>;
|
||||
/** What a looked-up series continues with; none for other titles. */
|
||||
continuationFor(
|
||||
item: PortalActivityItem
|
||||
): DashboardSeriesContinuation | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Continue Watching rail's list and its loading gate. A series whose
|
||||
* newest episode is watched, or is an extra, goes on with an episode only
|
||||
* the portal's episode list names, so the series to look past are fetched
|
||||
* through `DashboardSeriesEpisodesService` and resolved with
|
||||
* `resolveDashboardSeriesContinuation`. Call from an injection context.
|
||||
*/
|
||||
export function createDashboardContinueWatching(
|
||||
deps: DashboardContinueWatchingDeps
|
||||
): DashboardContinueWatching {
|
||||
const seriesEpisodes = inject(DashboardSeriesEpisodesService);
|
||||
const lookupWaitOver = signal(false);
|
||||
let lookupWaitStarted = false;
|
||||
|
||||
/**
|
||||
* A title listed without an episode lookup: a movie not watched yet, or
|
||||
* a series, which keeps its place until a lookup says it is finished.
|
||||
*/
|
||||
const listedWithoutLookup = (item: PortalRecentItem): boolean =>
|
||||
resolvePortalActivityWatchKind(item) === 'series' ||
|
||||
!isPortalPlaybackWatched(deps.storedPosition(item));
|
||||
|
||||
/** Xtream series whose newest episode is watched or an extra. */
|
||||
const candidates = computed(() =>
|
||||
selectSeriesContinuationCandidates(deps.items(), deps.storedPosition)
|
||||
);
|
||||
|
||||
/**
|
||||
* Which candidates to look up, and what each continues with
|
||||
* (`planDashboardSeriesLookups`): newest first until the rail's titles
|
||||
* are known, so a finished series frees its slot for an older one.
|
||||
*/
|
||||
const lookupPlan = computed(() => {
|
||||
const episodes = seriesEpisodes.episodes();
|
||||
return planDashboardSeriesLookups({
|
||||
items: deps.items(),
|
||||
candidates: candidates(),
|
||||
listedWithoutLookup,
|
||||
resolve: (candidate) =>
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate,
|
||||
deps.episodeRows(
|
||||
candidate.item.playlist_id,
|
||||
candidate.seriesXtreamId
|
||||
) ?? [candidate.newest],
|
||||
episodes.get(
|
||||
dashboardSeriesEpisodesKey(
|
||||
candidate.item.playlist_id,
|
||||
candidate.seriesXtreamId
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The episode lists to fetch: the plan's candidates, with credentials.
|
||||
* Equal lists keep the previous value, so playlist store churn
|
||||
* (favourites, refreshes, M3U playback) does not run the lookup effect
|
||||
* again.
|
||||
*/
|
||||
const requests = computed<DashboardSeriesEpisodesRequest[]>(
|
||||
() => {
|
||||
const playlists = new Map(
|
||||
deps.playlists().map((playlist) => [playlist._id, playlist])
|
||||
);
|
||||
const { window, continuations } = lookupPlan();
|
||||
const next: DashboardSeriesEpisodesRequest[] = [];
|
||||
for (const { item, seriesXtreamId } of window) {
|
||||
const playlist = playlists.get(item.playlist_id);
|
||||
if (
|
||||
playlist?.serverUrl &&
|
||||
playlist.username &&
|
||||
playlist.password
|
||||
) {
|
||||
const continuation = continuations.get(
|
||||
dashboardRecentItemKey(item)
|
||||
);
|
||||
next.push({
|
||||
playlistId: item.playlist_id,
|
||||
seriesId: seriesXtreamId,
|
||||
credentials: {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
// The list predates the episode played last.
|
||||
...(continuation?.kind === 'unknown' &&
|
||||
continuation.stale
|
||||
? { refresh: true }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ equal: sameDashboardSeriesEpisodesRequests }
|
||||
);
|
||||
|
||||
const lookupsSettled = computed(() => {
|
||||
const episodes = seriesEpisodes.episodes();
|
||||
return requests().every(({ playlistId, seriesId }) => {
|
||||
const status = episodes.get(
|
||||
dashboardSeriesEpisodesKey(playlistId, seriesId)
|
||||
)?.status;
|
||||
return status === 'loaded' || status === 'failed';
|
||||
});
|
||||
});
|
||||
|
||||
/** What each looked-up series continues with, by recent item key. */
|
||||
const continuations = computed(() => lookupPlan().continuations);
|
||||
|
||||
const settled = linkedSignal<boolean, boolean>({
|
||||
source: () => {
|
||||
const loaded = deps.positionsLoadedFor();
|
||||
return (
|
||||
deps.historyLoaded() &&
|
||||
deps
|
||||
.items()
|
||||
.every((item) => loaded?.has(item.playlist_id) ?? false) &&
|
||||
(lookupsSettled() || lookupWaitOver())
|
||||
);
|
||||
},
|
||||
computation: (ready, previous) => previous?.value === true || ready,
|
||||
}).asReadonly();
|
||||
|
||||
const items = computed<PortalRecentItem[]>(() => {
|
||||
if (!settled()) {
|
||||
return [];
|
||||
}
|
||||
const known = continuations();
|
||||
return deps.items().filter((item) => {
|
||||
const continuation = known.get(dashboardRecentItemKey(item));
|
||||
return continuation
|
||||
? continuation.kind !== 'finished'
|
||||
: listedWithoutLookup(item);
|
||||
});
|
||||
});
|
||||
|
||||
// Runs when the list of series to look up changes, and on each positions
|
||||
// reload: the deliberate moments a failed lookup is tried again.
|
||||
effect(() => {
|
||||
const pending = requests();
|
||||
const round = deps.positionReloads();
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
untracked(() => {
|
||||
seriesEpisodes.request(pending, round);
|
||||
if (!lookupWaitStarted) {
|
||||
lookupWaitStarted = true;
|
||||
setTimeout(
|
||||
() => lookupWaitOver.set(true),
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
settled,
|
||||
items,
|
||||
continuationFor: (item) =>
|
||||
continuations().get(dashboardRecentItemKey(item)),
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
DashboardFavoriteItem,
|
||||
GlobalRecentItem,
|
||||
} from './dashboard-data.service';
|
||||
import {
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT,
|
||||
CONTINUE_WATCHING_VISIBLE_ITEMS,
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS,
|
||||
} from './dashboard-series-continuation.util';
|
||||
import {
|
||||
DashboardSeriesEpisodesService,
|
||||
dashboardSeriesEpisodesKey,
|
||||
type DashboardSeriesEpisodes,
|
||||
type DashboardSeriesEpisodesRequest,
|
||||
} from './dashboard-series-episodes.service';
|
||||
|
||||
describe('DashboardDataService', () => {
|
||||
let service: DashboardDataService;
|
||||
@@ -159,6 +170,7 @@ describe('DashboardDataService', () => {
|
||||
|
||||
const playbackPositionsMock = {
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
savePlaybackPositionOrThrow: jest.fn().mockResolvedValue(undefined),
|
||||
getPlaybackPosition: jest.fn().mockResolvedValue(null),
|
||||
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
getAllPlaybackPositions: jest
|
||||
@@ -167,6 +179,32 @@ describe('DashboardDataService', () => {
|
||||
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Episode lists answer from `seriesEpisodeAnswers`; a series with no
|
||||
// answer stays loading, as a slow portal would.
|
||||
const seriesEpisodeEntries = signal<
|
||||
ReadonlyMap<string, DashboardSeriesEpisodes>
|
||||
>(new Map());
|
||||
const seriesEpisodeAnswers = new Map<string, DashboardSeriesEpisodes>();
|
||||
const seriesEpisodesMock = {
|
||||
episodes: seriesEpisodeEntries.asReadonly(),
|
||||
request: jest.fn((requests: DashboardSeriesEpisodesRequest[]) => {
|
||||
seriesEpisodeEntries.update((entries) => {
|
||||
const next = new Map(entries);
|
||||
for (const { playlistId, seriesId } of requests) {
|
||||
const key = dashboardSeriesEpisodesKey(
|
||||
playlistId,
|
||||
seriesId
|
||||
);
|
||||
next.set(
|
||||
key,
|
||||
seriesEpisodeAnswers.get(key) ?? { status: 'loading' }
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
const createTestingModuleProviders = () => ({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
@@ -193,6 +231,10 @@ describe('DashboardDataService', () => {
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: playbackPositionsMock,
|
||||
},
|
||||
{
|
||||
provide: DashboardSeriesEpisodesService,
|
||||
useValue: seriesEpisodesMock,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -253,6 +295,9 @@ describe('DashboardDataService', () => {
|
||||
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockClear();
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([]);
|
||||
seriesEpisodeEntries.set(new Map());
|
||||
seriesEpisodeAnswers.clear();
|
||||
seriesEpisodesMock.request.mockClear();
|
||||
|
||||
TestBed.configureTestingModule(createTestingModuleProviders());
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
@@ -1388,6 +1433,825 @@ describe('DashboardDataService', () => {
|
||||
expect(failOnLinearScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Continue Watching', () => {
|
||||
const PLAYLIST = 'xtream-cw';
|
||||
|
||||
const recentRow = (
|
||||
id: number,
|
||||
title: string,
|
||||
type: 'movie' | 'series',
|
||||
xtreamId: number,
|
||||
viewedAt: string,
|
||||
playlistId = PLAYLIST
|
||||
) => ({
|
||||
id,
|
||||
category_id: 1,
|
||||
title,
|
||||
rating: '7.0',
|
||||
viewed_at: viewedAt,
|
||||
poster_url: `https://example.com/${xtreamId}.png`,
|
||||
xtream_id: xtreamId,
|
||||
type,
|
||||
playlist_id: playlistId,
|
||||
playlist_name: 'Xtream CW',
|
||||
});
|
||||
const vodPosition = (
|
||||
contentXtreamId: number,
|
||||
positionSeconds: number,
|
||||
durationSeconds: number
|
||||
): PlaybackPositionData => ({
|
||||
contentXtreamId,
|
||||
contentType: 'vod',
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
playlistId: PLAYLIST,
|
||||
});
|
||||
const episodePosition = (
|
||||
contentXtreamId: number,
|
||||
seriesXtreamId: number,
|
||||
positionSeconds: number,
|
||||
durationSeconds: number,
|
||||
updatedAt: string
|
||||
): PlaybackPositionData => ({
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: contentXtreamId % 100,
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
playlistId: PLAYLIST,
|
||||
updatedAt,
|
||||
});
|
||||
const titles = () =>
|
||||
service.continueWatchingItems().map((item) => item.title);
|
||||
/** The portal's episode list for a series: season 1, these ids. */
|
||||
const answerEpisodes = (seriesId: number, ids: number[]) =>
|
||||
seriesEpisodeAnswers.set(
|
||||
dashboardSeriesEpisodesKey(PLAYLIST, seriesId),
|
||||
{
|
||||
status: 'loaded',
|
||||
seasons: {
|
||||
'1': ids.map((id, index) => ({
|
||||
id: String(id),
|
||||
season: 1,
|
||||
episode_num: index + 1,
|
||||
title: `Episode ${index + 1}`,
|
||||
})) as never,
|
||||
},
|
||||
}
|
||||
);
|
||||
/** History, then positions, then the episode lookups they trigger. */
|
||||
const loadDashboard = async () => {
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
TestBed.tick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
playbackPositionsMock.savePlaybackPositionOrThrow.mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal(),
|
||||
{
|
||||
_id: PLAYLIST,
|
||||
title: 'Xtream CW',
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://cw.example.com',
|
||||
username: 'cw-user',
|
||||
password: 'cw-pass',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists only the titles the user has not finished', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
recentRow(
|
||||
1,
|
||||
'Credits Movie',
|
||||
'movie',
|
||||
101,
|
||||
'2026-10-04T10:00:00Z'
|
||||
),
|
||||
recentRow(
|
||||
2,
|
||||
'Halfway Movie',
|
||||
'movie',
|
||||
102,
|
||||
'2026-10-04T09:00:00Z'
|
||||
),
|
||||
recentRow(
|
||||
3,
|
||||
'Next Episode Series',
|
||||
'series',
|
||||
200,
|
||||
'2026-10-04T08:30:00Z'
|
||||
),
|
||||
recentRow(
|
||||
4,
|
||||
'Ongoing Series',
|
||||
'series',
|
||||
300,
|
||||
'2026-10-04T08:00:00Z'
|
||||
),
|
||||
recentRow(
|
||||
5,
|
||||
'Finished Series',
|
||||
'series',
|
||||
400,
|
||||
'2026-10-04T07:30:00Z'
|
||||
),
|
||||
recentRow(
|
||||
6,
|
||||
'Threshold Movie',
|
||||
'movie',
|
||||
103,
|
||||
'2026-10-04T06:00:00Z'
|
||||
),
|
||||
recentRow(
|
||||
7,
|
||||
'Almost Movie',
|
||||
'movie',
|
||||
104,
|
||||
'2026-10-04T05:00:00Z'
|
||||
),
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
|
||||
// Stopped while the credits rolled: 61 s of 59 min left.
|
||||
vodPosition(101, 3491, 3552),
|
||||
vodPosition(102, 2700, 5400),
|
||||
// Episodes 1 and 2 watched; episode 3 is next.
|
||||
episodePosition(201, 200, 1887, 1942, '2026-10-03T08:00:00Z'),
|
||||
episodePosition(202, 200, 1887, 1942, '2026-10-04T08:00:00Z'),
|
||||
// Latest episode started after an earlier one was finished.
|
||||
episodePosition(301, 300, 1887, 1942, '2026-10-02T08:00:00Z'),
|
||||
episodePosition(302, 300, 600, 1942, '2026-10-04T07:00:00Z'),
|
||||
// Every episode watched.
|
||||
episodePosition(401, 400, 1887, 1942, '2026-10-03T07:00:00Z'),
|
||||
episodePosition(402, 400, 1887, 1942, '2026-10-04T07:00:00Z'),
|
||||
vodPosition(103, 4860, 5400), // 90%
|
||||
vodPosition(104, 4800, 5400), // 89%
|
||||
]);
|
||||
answerEpisodes(200, [201, 202, 203]);
|
||||
answerEpisodes(400, [401, 402]);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(titles()).toEqual([
|
||||
'Halfway Movie',
|
||||
'Next Episode Series',
|
||||
'Ongoing Series',
|
||||
'Almost Movie',
|
||||
]);
|
||||
// Finished titles stay in the watch history.
|
||||
expect(service.globalRecentVodItems()).toHaveLength(7);
|
||||
// Only series whose newest episode is watched are looked up.
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({ seriesId: 200 }),
|
||||
expect.objectContaining({ seriesId: 400 }),
|
||||
],
|
||||
expect.any(Number)
|
||||
);
|
||||
|
||||
// The series goes on with episode 3, from the beginning.
|
||||
const nextEpisode = service
|
||||
.continueWatchingItems()
|
||||
.find((item) => item.title === 'Next Episode Series');
|
||||
if (!nextEpisode) {
|
||||
throw new Error('expected the series with a next episode');
|
||||
}
|
||||
const position = service.getPlaybackPositionForItem(nextEpisode);
|
||||
expect(position).toMatchObject({
|
||||
contentXtreamId: 203,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 200,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
positionSeconds: 0,
|
||||
});
|
||||
expect(position?.durationSeconds).toBeUndefined();
|
||||
expect(
|
||||
service.getRecentItemResumeNavigation(nextEpisode)?.state
|
||||
).toMatchObject({
|
||||
openCollectionDetailItem: {
|
||||
seriesResume: {
|
||||
seriesXtreamId: 200,
|
||||
contentXtreamId: 203,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('a series whose newest episode is watched', () => {
|
||||
beforeEach(() => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
recentRow(
|
||||
3,
|
||||
'The Penguin',
|
||||
'series',
|
||||
200,
|
||||
'2026-10-04T08:30:00Z'
|
||||
),
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
||||
// Episodes 1-4 watched, the newest last night.
|
||||
[201, 202, 203, 204].map((id) =>
|
||||
episodePosition(
|
||||
id,
|
||||
200,
|
||||
3491,
|
||||
3552,
|
||||
`2026-10-0${id - 200}T08:00:00Z`
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the portal for its episodes with the playlist credentials', async () => {
|
||||
answerEpisodes(200, [201, 202, 203, 204, 205]);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
playlistId: PLAYLIST,
|
||||
seriesId: 200,
|
||||
credentials: {
|
||||
serverUrl: 'https://cw.example.com',
|
||||
username: 'cw-user',
|
||||
password: 'cw-pass',
|
||||
},
|
||||
},
|
||||
],
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(titles()).toEqual(['The Penguin']);
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).toMatchObject({
|
||||
contentXtreamId: 205,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('asks for the list again when it predates the episode watched last', async () => {
|
||||
// The cached list ends before the newest episode played: it
|
||||
// cannot tell "finale" from "a 5th episode is out now".
|
||||
answerEpisodes(200, [201, 202, 203]);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(titles()).toEqual(['The Penguin']);
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).toMatchObject({ contentXtreamId: 204 });
|
||||
expect(seriesEpisodesMock.request).toHaveBeenLastCalledWith(
|
||||
[expect.objectContaining({ seriesId: 200, refresh: true })],
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the episode list on first open, for a bounded time', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
await loadDashboard();
|
||||
|
||||
// The answer decides between "next episode" and "gone".
|
||||
expect(service.continueWatchingSettled()).toBe(false);
|
||||
expect(titles()).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS
|
||||
);
|
||||
|
||||
// Still unknown: the series keeps its place and its
|
||||
// newest episode.
|
||||
expect(service.continueWatchingSettled()).toBe(true);
|
||||
expect(titles()).toEqual(['The Penguin']);
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).toMatchObject({ contentXtreamId: 204 });
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps its place when the episode list cannot be loaded', async () => {
|
||||
seriesEpisodeAnswers.set(
|
||||
dashboardSeriesEpisodesKey(PLAYLIST, 200),
|
||||
{ status: 'failed' }
|
||||
);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(service.continueWatchingSettled()).toBe(true);
|
||||
expect(titles()).toEqual(['The Penguin']);
|
||||
});
|
||||
|
||||
it('looks a failed list up again only when positions reload', async () => {
|
||||
const key = dashboardSeriesEpisodesKey(PLAYLIST, 200);
|
||||
seriesEpisodeAnswers.set(key, { status: 'failed' });
|
||||
await loadDashboard();
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Playlist store churn elsewhere in the app: an M3U
|
||||
// playlist added, every playlist object replaced.
|
||||
playlistsSignal.set([
|
||||
...playlistsSignal().map((playlist) => ({ ...playlist })),
|
||||
{
|
||||
_id: 'm3u-new',
|
||||
title: 'New M3U',
|
||||
count: 1,
|
||||
importDate: '2026-10-04T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
} as never,
|
||||
]);
|
||||
TestBed.tick();
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The dashboard is opened again: the portal answers now.
|
||||
answerEpisodes(200, [201, 202, 203, 204, 205]);
|
||||
await service.reloadPlaybackPositions();
|
||||
TestBed.tick();
|
||||
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).toMatchObject({ contentXtreamId: 205 });
|
||||
});
|
||||
|
||||
it('looks the list up again with the credentials of an edited playlist', async () => {
|
||||
seriesEpisodeAnswers.set(
|
||||
dashboardSeriesEpisodesKey(PLAYLIST, 200),
|
||||
{ status: 'failed' }
|
||||
);
|
||||
await loadDashboard();
|
||||
|
||||
playlistsSignal.set(
|
||||
playlistsSignal().map((playlist) =>
|
||||
playlist._id === PLAYLIST
|
||||
? { ...playlist, password: 'new-pass' }
|
||||
: playlist
|
||||
)
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(2);
|
||||
expect(seriesEpisodesMock.request).toHaveBeenLastCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
credentials: expect.objectContaining({
|
||||
password: 'new-pass',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves once its last episode is watched', async () => {
|
||||
answerEpisodes(200, [201, 202, 203, 204]);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(titles()).toEqual([]);
|
||||
expect(service.globalRecentVodItems()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps to the run when an extra left unfinished is the newest activity', async () => {
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
||||
[
|
||||
episodePosition(
|
||||
201,
|
||||
200,
|
||||
3491,
|
||||
3552,
|
||||
'2026-10-01T08:00:00Z'
|
||||
),
|
||||
{
|
||||
...episodePosition(
|
||||
290,
|
||||
200,
|
||||
600,
|
||||
1800,
|
||||
'2026-10-02T08:00:00Z'
|
||||
),
|
||||
seasonNumber: 0,
|
||||
episodeNumber: 1,
|
||||
},
|
||||
]
|
||||
);
|
||||
seriesEpisodeAnswers.set(
|
||||
dashboardSeriesEpisodesKey(PLAYLIST, 200),
|
||||
{
|
||||
status: 'loaded',
|
||||
seasons: {
|
||||
'0': [
|
||||
{
|
||||
id: '290',
|
||||
season: 0,
|
||||
episode_num: 1,
|
||||
title: 'Behind the scenes',
|
||||
},
|
||||
],
|
||||
'1': [201, 202].map((id, index) => ({
|
||||
id: String(id),
|
||||
season: 1,
|
||||
episode_num: index + 1,
|
||||
title: `Episode ${index + 1}`,
|
||||
})),
|
||||
} as never,
|
||||
}
|
||||
);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ seriesId: 200 })],
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).toMatchObject({ contentXtreamId: 202, positionSeconds: 0 });
|
||||
});
|
||||
|
||||
it('takes the later episode as the newest when two were saved in the same second', async () => {
|
||||
// Episode 5 left early, then episode 6 finished within the
|
||||
// same second (the database keeps whole seconds).
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
||||
[
|
||||
episodePosition(
|
||||
205,
|
||||
200,
|
||||
30,
|
||||
3552,
|
||||
'2026-10-05 08:00:00'
|
||||
),
|
||||
episodePosition(
|
||||
206,
|
||||
200,
|
||||
3491,
|
||||
3552,
|
||||
'2026-10-05 08:00:00'
|
||||
),
|
||||
]
|
||||
);
|
||||
answerEpisodes(200, [201, 202, 203, 204, 205, 206, 207]);
|
||||
|
||||
await loadDashboard();
|
||||
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).toMatchObject({ contentXtreamId: 207 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('with more series to look up than the rail shows', () => {
|
||||
/** `count` series, newest first, each with episode x1 watched. */
|
||||
const historyOfSeries = (count: number) => {
|
||||
const seriesIds = Array.from(
|
||||
{ length: count },
|
||||
(_, i) => 500 + i
|
||||
);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue(
|
||||
seriesIds.map((seriesId, i) =>
|
||||
recentRow(
|
||||
seriesId,
|
||||
`Series ${i}`,
|
||||
'series',
|
||||
seriesId,
|
||||
new Date(
|
||||
Date.UTC(2026, 9, 4, 12) - i * 60_000
|
||||
).toISOString()
|
||||
)
|
||||
)
|
||||
);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
||||
seriesIds.map((seriesId) =>
|
||||
episodePosition(
|
||||
seriesId * 10 + 1,
|
||||
seriesId,
|
||||
3491,
|
||||
3552,
|
||||
'2026-10-04T08:00:00Z'
|
||||
)
|
||||
)
|
||||
);
|
||||
return seriesIds;
|
||||
};
|
||||
/** Lookups answer at once here: let the window grow. */
|
||||
const settleLookups = () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
TestBed.tick();
|
||||
}
|
||||
};
|
||||
const finished = (seriesId: number) =>
|
||||
answerEpisodes(seriesId, [seriesId * 10 + 1]);
|
||||
const continuing = (seriesId: number) =>
|
||||
answerEpisodes(seriesId, [
|
||||
seriesId * 10 + 1,
|
||||
seriesId * 10 + 2,
|
||||
]);
|
||||
|
||||
it('looks up series until the rail is filled, and keeps older ones in place', async () => {
|
||||
const seriesIds = historyOfSeries(
|
||||
CONTINUE_WATCHING_VISIBLE_ITEMS + 1
|
||||
);
|
||||
seriesIds.forEach(continuing);
|
||||
|
||||
await loadDashboard();
|
||||
settleLookups();
|
||||
|
||||
expect(seriesEpisodesMock.request).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
seriesEpisodesMock.request.mock.calls[0][0]
|
||||
).toHaveLength(CONTINUE_WATCHING_VISIBLE_ITEMS);
|
||||
// Not looked up: it keeps its place, never hidden on a guess.
|
||||
expect(titles()).toEqual(
|
||||
seriesIds.map((_, i) => `Series ${i}`)
|
||||
);
|
||||
});
|
||||
|
||||
it('looks past finished series for one that goes on, in the same round', async () => {
|
||||
const seriesIds = historyOfSeries(
|
||||
CONTINUE_WATCHING_VISIBLE_ITEMS + 1
|
||||
);
|
||||
seriesIds.slice(0, -1).forEach(finished);
|
||||
continuing(seriesIds[seriesIds.length - 1]);
|
||||
|
||||
await loadDashboard();
|
||||
settleLookups();
|
||||
|
||||
expect(titles()).toEqual([
|
||||
`Series ${CONTINUE_WATCHING_VISIBLE_ITEMS}`,
|
||||
]);
|
||||
const calls = seriesEpisodesMock.request.mock.calls;
|
||||
expect(calls[calls.length - 1][0]).toHaveLength(
|
||||
seriesIds.length
|
||||
);
|
||||
// Growing the window is no new round: nothing is retried.
|
||||
const rounds = calls.map((call) => (call as unknown[])[1]);
|
||||
expect(new Set(rounds).size).toBe(1);
|
||||
});
|
||||
|
||||
it(`looks up ${CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT} series at most and keeps the rest in place`, async () => {
|
||||
const seriesIds = historyOfSeries(
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT + 5
|
||||
);
|
||||
seriesIds.forEach(finished);
|
||||
|
||||
await loadDashboard();
|
||||
settleLookups();
|
||||
|
||||
const calls = seriesEpisodesMock.request.mock.calls;
|
||||
expect(calls[calls.length - 1][0]).toHaveLength(
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT
|
||||
);
|
||||
expect(titles()).toEqual(
|
||||
seriesIds
|
||||
.slice(CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT)
|
||||
.map(
|
||||
(_, i) =>
|
||||
`Series ${CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT + i}`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lists nothing until the positions of every recent playlist have loaded', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
recentRow(
|
||||
1,
|
||||
'Credits Movie',
|
||||
'movie',
|
||||
101,
|
||||
'2026-10-04T10:00:00Z'
|
||||
),
|
||||
recentRow(
|
||||
2,
|
||||
'Halfway Movie',
|
||||
'movie',
|
||||
102,
|
||||
'2026-10-04T09:00:00Z'
|
||||
),
|
||||
]);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue([
|
||||
vodPosition(101, 3491, 3552),
|
||||
vodPosition(102, 2700, 5400),
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
// Without positions a finished title looks like an unfinished one.
|
||||
expect(service.continueWatchingSettled()).toBe(false);
|
||||
expect(titles()).toEqual([]);
|
||||
|
||||
await service.reloadPlaybackPositions();
|
||||
|
||||
expect(service.continueWatchingSettled()).toBe(true);
|
||||
expect(titles()).toEqual(['Halfway Movie']);
|
||||
|
||||
// Once settled, a title from a not yet loaded playlist shows at
|
||||
// once instead of holding the whole rail back again.
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
recentRow(
|
||||
3,
|
||||
'Other Source Movie',
|
||||
'movie',
|
||||
501,
|
||||
'2026-10-04T11:00:00Z',
|
||||
'xtream-1'
|
||||
),
|
||||
recentRow(
|
||||
1,
|
||||
'Credits Movie',
|
||||
'movie',
|
||||
101,
|
||||
'2026-10-04T10:00:00Z'
|
||||
),
|
||||
recentRow(
|
||||
2,
|
||||
'Halfway Movie',
|
||||
'movie',
|
||||
102,
|
||||
'2026-10-04T09:00:00Z'
|
||||
),
|
||||
]);
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
expect(service.continueWatchingSettled()).toBe(true);
|
||||
expect(titles()).toEqual(['Other Source Movie', 'Halfway Movie']);
|
||||
});
|
||||
|
||||
it('settles at once when the history holds no movies or series', async () => {
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
expect(service.continueWatchingSettled()).toBe(true);
|
||||
expect(titles()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('in the PWA', () => {
|
||||
const pwaRow = (id: number, title: string, xtreamId: number) => ({
|
||||
id,
|
||||
category_id: 1,
|
||||
title,
|
||||
viewed_at: '2026-10-04T09:00:00.000Z',
|
||||
poster_url: `https://example.com/${xtreamId}.png`,
|
||||
xtream_id: xtreamId,
|
||||
type: 'movie',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Constructed without the Electron bridge: the history comes
|
||||
// from the Xtream data source, after the playlists are in.
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule(createTestingModuleProviders());
|
||||
service = TestBed.inject(DashboardDataService);
|
||||
TestBed.tick();
|
||||
});
|
||||
|
||||
it('waits for the history read before listing anything', async () => {
|
||||
const pending = createPendingItems<ReturnType<typeof pwaRow>>();
|
||||
xtreamDataSourceMock.getRecentItems.mockImplementation(
|
||||
(playlistId: string) =>
|
||||
playlistId === PLAYLIST
|
||||
? pending.promise
|
||||
: Promise.resolve([])
|
||||
);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockResolvedValue(
|
||||
[vodPosition(101, 3491, 3552), vodPosition(102, 2700, 5400)]
|
||||
);
|
||||
|
||||
const reload = service.reloadGlobalRecentItems();
|
||||
TestBed.tick();
|
||||
// The playlists alone used to count as the loaded history:
|
||||
// with no movie known yet the rail settled empty, then listed
|
||||
// the finished one until its position arrived.
|
||||
expect(service.globalRecentLoaded()).toBe(false);
|
||||
expect(service.continueWatchingSettled()).toBe(false);
|
||||
|
||||
pending.resolve([
|
||||
pwaRow(1, 'Credits Movie', 101),
|
||||
pwaRow(2, 'Halfway Movie', 102),
|
||||
]);
|
||||
await reload;
|
||||
expect(service.globalRecentLoaded()).toBe(true);
|
||||
expect(titles()).toEqual([]);
|
||||
|
||||
await service.reloadPlaybackPositions();
|
||||
expect(titles()).toEqual(['Halfway Movie']);
|
||||
});
|
||||
|
||||
it('settles when the history read fails', async () => {
|
||||
xtreamDataSourceMock.getRecentItems.mockRejectedValue(
|
||||
new Error('IndexedDB unavailable')
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.reloadGlobalRecentItems()
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(service.globalRecentLoaded()).toBe(true);
|
||||
expect(service.globalRecentLoading()).toBe(false);
|
||||
expect(service.continueWatchingSettled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the newest positions when an older reload finishes last', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
recentRow(
|
||||
1,
|
||||
'Credits Movie',
|
||||
'movie',
|
||||
101,
|
||||
'2026-10-04T10:00:00Z'
|
||||
),
|
||||
]);
|
||||
await service.reloadGlobalRecentItems();
|
||||
let resolveStale: (rows: PlaybackPositionData[]) => void = () =>
|
||||
undefined;
|
||||
playbackPositionsMock.getAllPlaybackPositions
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<PlaybackPositionData[]>((resolve) => {
|
||||
resolveStale = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce([vodPosition(101, 3491, 3552)]);
|
||||
|
||||
const staleReload = service.reloadPlaybackPositions();
|
||||
await service.reloadPlaybackPositions();
|
||||
expect(titles()).toEqual([]);
|
||||
|
||||
// The older read answers last, from before the movie finished.
|
||||
resolveStale([vodPosition(101, 1800, 3552)]);
|
||||
await staleReload;
|
||||
|
||||
const [movie] = service.globalRecentVodItems();
|
||||
expect(
|
||||
service.getPlaybackPositionForItem(movie)?.positionSeconds
|
||||
).toBe(3491);
|
||||
expect(titles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a title the user marks as watched', async () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
|
||||
recentRow(
|
||||
2,
|
||||
'Halfway Movie',
|
||||
'movie',
|
||||
102,
|
||||
'2026-10-04T09:00:00Z'
|
||||
),
|
||||
]);
|
||||
let stored = vodPosition(102, 2700, 5400);
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
() => Promise.resolve([stored])
|
||||
);
|
||||
playbackPositionsMock.savePlaybackPositionOrThrow.mockImplementation(
|
||||
(_playlistId: string, data: PlaybackPositionData) => {
|
||||
stored = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
);
|
||||
await service.reloadGlobalRecentItems();
|
||||
await service.reloadPlaybackPositions();
|
||||
expect(titles()).toEqual(['Halfway Movie']);
|
||||
|
||||
await expect(
|
||||
service.markRecentItemWatched(
|
||||
service.continueWatchingItems()[0]
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(titles()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => {
|
||||
// Seed two favorites: a movie and a live channel.
|
||||
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
isTypeInKind as isTypeInKindUtil,
|
||||
type DashboardContentKind,
|
||||
} from './dashboard-navigation.util';
|
||||
import { createDashboardContinueWatching } from './dashboard-continue-watching';
|
||||
|
||||
export type { DashboardContentKind };
|
||||
|
||||
@@ -96,11 +97,24 @@ function newestPlaybackPosition(
|
||||
if (!candidate) {
|
||||
return current;
|
||||
}
|
||||
return (candidate.updatedAt ?? '') > (current.updatedAt ?? '')
|
||||
const candidateAt = candidate.updatedAt ?? '';
|
||||
const currentAt = current.updatedAt ?? '';
|
||||
if (candidateAt !== currentAt) {
|
||||
return candidateAt > currentAt ? candidate : current;
|
||||
}
|
||||
// Saved in the same second (a season marked watched, a quick skip):
|
||||
// the later episode, as `getSeriesNextUp` breaks the tie.
|
||||
return episodeOrder(candidate) > episodeOrder(current)
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
function episodeOrder(position: PlaybackPositionData): number {
|
||||
return (
|
||||
(position.seasonNumber ?? 0) * 100_000 + (position.episodeNumber ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
export type GlobalRecentItem = PortalRecentItem;
|
||||
/** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */
|
||||
@@ -169,9 +183,9 @@ export class DashboardDataService {
|
||||
);
|
||||
private readonly globalRecentLoadingState = signal(true);
|
||||
private readonly globalRecentLoadedState = signal(false);
|
||||
private readonly globalRecentDbLoadedState = signal(
|
||||
!this.hasPortalActivityStorage
|
||||
);
|
||||
// The portal history read, from SQLite or (PWA) the Xtream data source;
|
||||
// the history is loaded once it and the playlist inventory are in.
|
||||
private readonly globalRecentDbLoadedState = signal(false);
|
||||
private readonly globalFavoritesLoadingState = signal(true);
|
||||
private readonly globalFavoritesLoadedState = signal(false);
|
||||
private readonly xtreamGlobalFavoritesLoadedState = signal(false);
|
||||
@@ -287,11 +301,57 @@ export class DashboardDataService {
|
||||
private readonly playbackPositionsBySeriesMap = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
/** Every episode row per series: what comes next depends on all of them. */
|
||||
private readonly seriesEpisodePositionsMap = signal<
|
||||
Map<string, PlaybackPositionData[]>
|
||||
>(new Map());
|
||||
|
||||
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
|
||||
|
||||
/** Playlists whose rows the position maps hold; null before any load. */
|
||||
private readonly playbackPositionPlaylistIds =
|
||||
signal<ReadonlySet<string> | null>(null);
|
||||
private playbackPositionsLoadGeneration = 0;
|
||||
/**
|
||||
* Counts the position reloads that landed (dashboard entry, a changed
|
||||
* history, "Mark watched"): each is a lookup round, which retries failed
|
||||
* episode lookups and refreshes lists past their age.
|
||||
*/
|
||||
private readonly playbackPositionsReloads = signal(0);
|
||||
|
||||
/** The Continue Watching rail's list and loading gate. */
|
||||
private readonly continueWatching = createDashboardContinueWatching({
|
||||
items: this.globalRecentVodItems,
|
||||
historyLoaded: this.globalRecentLoaded,
|
||||
playlists: this.playlists,
|
||||
storedPosition: (item) => this.storedPlaybackPositionForItem(item),
|
||||
episodeRows: (playlistId, seriesXtreamId) =>
|
||||
this.seriesEpisodePositionsMap().get(
|
||||
seriesPlaybackPositionMapKey(playlistId, seriesXtreamId)
|
||||
),
|
||||
positionsLoadedFor: this.playbackPositionPlaylistIds,
|
||||
positionReloads: this.playbackPositionsReloads,
|
||||
});
|
||||
|
||||
readonly continueWatchingSettled = this.continueWatching.settled;
|
||||
readonly continueWatchingItems = this.continueWatching.items;
|
||||
|
||||
/**
|
||||
* The position a dashboard card shows and resumes: the stored one, or
|
||||
* for a series whose newest episode is watched or an extra, the episode
|
||||
* it continues with (a not-started next episode begins at 0:00).
|
||||
*/
|
||||
getPlaybackPositionForItem(
|
||||
item: PortalActivityItem
|
||||
): PlaybackPositionData | null {
|
||||
const continuation = this.continueWatching.continuationFor(item);
|
||||
return continuation?.kind === 'continue'
|
||||
? continuation.position
|
||||
: this.storedPlaybackPositionForItem(item);
|
||||
}
|
||||
|
||||
private storedPlaybackPositionForItem(
|
||||
item: PortalActivityItem
|
||||
): PlaybackPositionData | null {
|
||||
// The progress model, not the routing type: a Stalker embedded-VOD
|
||||
// row routes as a movie but tracks episodes under its parent id,
|
||||
@@ -339,6 +399,9 @@ export class DashboardDataService {
|
||||
* on heavy libraries.
|
||||
*/
|
||||
async reloadPlaybackPositions(): Promise<void> {
|
||||
// Latest reload wins: an older one finishing last would put back the
|
||||
// positions of a smaller history and leave newer titles unsettled.
|
||||
const generation = ++this.playbackPositionsLoadGeneration;
|
||||
const playlistIds = new Set<string>();
|
||||
for (const item of this.globalRecentItems()) {
|
||||
if (item.type === 'movie' || item.type === 'series') {
|
||||
@@ -348,11 +411,15 @@ export class DashboardDataService {
|
||||
if (playlistIds.size === 0) {
|
||||
this.playbackPositionsMap.set(new Map());
|
||||
this.playbackPositionsBySeriesMap.set(new Map());
|
||||
this.seriesEpisodePositionsMap.set(new Map());
|
||||
this.playbackPositionPlaylistIds.set(playlistIds);
|
||||
this.playbackPositionsReloads.update((count) => count + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map<string, PlaybackPositionData>();
|
||||
const nextBySeries = new Map<string, PlaybackPositionData>();
|
||||
const nextEpisodesBySeries = new Map<string, PlaybackPositionData[]>();
|
||||
for (const playlistId of playlistIds) {
|
||||
try {
|
||||
const positions =
|
||||
@@ -383,6 +450,12 @@ export class DashboardDataService {
|
||||
position
|
||||
) as PlaybackPositionData
|
||||
);
|
||||
const seriesRows = nextEpisodesBySeries.get(seriesKey);
|
||||
if (seriesRows) {
|
||||
seriesRows.push(position);
|
||||
} else {
|
||||
nextEpisodesBySeries.set(seriesKey, [position]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -394,9 +467,17 @@ export class DashboardDataService {
|
||||
}
|
||||
}
|
||||
|
||||
if (generation !== this.playbackPositionsLoadGeneration) {
|
||||
return;
|
||||
}
|
||||
this.ngZone.run(() => {
|
||||
this.playbackPositionsMap.set(next);
|
||||
this.playbackPositionsBySeriesMap.set(nextBySeries);
|
||||
this.seriesEpisodePositionsMap.set(nextEpisodesBySeries);
|
||||
// A playlist whose load failed counts as loaded: its titles
|
||||
// show without progress rather than holding the rail back.
|
||||
this.playbackPositionPlaylistIds.set(playlistIds);
|
||||
this.playbackPositionsReloads.update((count) => count + 1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -507,12 +588,20 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
if (!this.hasPortalActivityStorage) {
|
||||
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamGlobalRecentItems.set(recentItems)
|
||||
);
|
||||
this.globalRecentDbLoadedState.set(true);
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
try {
|
||||
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
|
||||
this.ngZone.run(() =>
|
||||
this.xtreamGlobalRecentItems.set(recentItems)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload PWA recent items',
|
||||
err
|
||||
);
|
||||
} finally {
|
||||
this.globalRecentDbLoadedState.set(true);
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
PortalRecentItem,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT,
|
||||
CONTINUE_WATCHING_VISIBLE_ITEMS,
|
||||
planDashboardSeriesLookups,
|
||||
resolveDashboardSeriesContinuation,
|
||||
selectSeriesContinuationCandidates,
|
||||
type DashboardSeriesCandidate,
|
||||
} from './dashboard-series-continuation.util';
|
||||
import type { DashboardSeriesEpisodes } from './dashboard-series-episodes.service';
|
||||
|
||||
const PLAYLIST = 'xtream-1';
|
||||
const SERIES = 900;
|
||||
|
||||
function episode(
|
||||
id: number,
|
||||
season: number | undefined,
|
||||
episodeNum: number
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: String(id),
|
||||
season,
|
||||
episode_num: episodeNum,
|
||||
title: `Episode ${episodeNum}`,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
function row(
|
||||
contentXtreamId: number,
|
||||
positionSeconds: number,
|
||||
updatedAt = '2026-10-04 08:00:00'
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: SERIES,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: contentXtreamId % 10,
|
||||
positionSeconds,
|
||||
durationSeconds: 3552,
|
||||
playlistId: PLAYLIST,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const loaded = (
|
||||
seasons: Record<string, XtreamSerieEpisode[]>
|
||||
): DashboardSeriesEpisodes => ({ status: 'loaded', seasons });
|
||||
|
||||
// Watched: stopped with only the credits left.
|
||||
const WATCHED = 3491;
|
||||
|
||||
function candidate(newest: PlaybackPositionData): DashboardSeriesCandidate {
|
||||
return {
|
||||
item: {
|
||||
id: 1,
|
||||
title: 'The Penguin',
|
||||
type: 'series',
|
||||
source: 'xtream',
|
||||
playlist_id: PLAYLIST,
|
||||
xtream_id: SERIES,
|
||||
viewed_at: '2026-10-04',
|
||||
} as PortalRecentItem,
|
||||
seriesXtreamId: SERIES,
|
||||
newest,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveDashboardSeriesContinuation', () => {
|
||||
const season1 = [
|
||||
episode(1, 1, 1),
|
||||
episode(2, 1, 2),
|
||||
episode(3, 1, 3),
|
||||
episode(4, 1, 4),
|
||||
episode(5, 1, 5),
|
||||
];
|
||||
|
||||
it('goes on with the next episode once the newest one is watched', () => {
|
||||
const rows = [1, 2, 3, 4].map((id) => row(id, WATCHED));
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[3]),
|
||||
rows,
|
||||
loaded({ '1': season1 })
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'continue',
|
||||
position: {
|
||||
contentXtreamId: 5,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: SERIES,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
positionSeconds: 0,
|
||||
playlistId: PLAYLIST,
|
||||
updatedAt: rows[3].updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('goes on with the next season after a season finale', () => {
|
||||
const rows = [1, 2].map((id) => row(id, WATCHED));
|
||||
|
||||
const continuation = resolveDashboardSeriesContinuation(
|
||||
candidate(rows[1]),
|
||||
rows,
|
||||
loaded({
|
||||
'1': [episode(1, 1, 1), episode(2, 1, 2)],
|
||||
'2': [episode(21, 2, 1), episode(22, 2, 2)],
|
||||
})
|
||||
);
|
||||
|
||||
expect(continuation).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: {
|
||||
contentXtreamId: 21,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('is finished once every episode is watched', () => {
|
||||
const rows = [1, 2, 3, 4, 5].map((id) => row(id, WATCHED));
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[4]),
|
||||
rows,
|
||||
loaded({ '1': season1 })
|
||||
)
|
||||
).toEqual({ kind: 'finished' });
|
||||
});
|
||||
|
||||
it('moves past an episode left unfinished before the newest watched one', () => {
|
||||
const rows = [
|
||||
row(1, WATCHED, '2026-10-01 08:00:00'),
|
||||
row(2, 1200, '2026-10-02 08:00:00'),
|
||||
row(3, WATCHED, '2026-10-03 08:00:00'),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[2]),
|
||||
rows,
|
||||
loaded({ '1': season1 })
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: { contentXtreamId: 4, positionSeconds: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes the next episode where it was left', () => {
|
||||
const unfinished = row(2, 1200, '2026-10-01 08:00:00');
|
||||
// Episode 1 watched again after episode 2 was left.
|
||||
const rows = [row(1, WATCHED, '2026-10-03 08:00:00'), unfinished];
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[0]),
|
||||
rows,
|
||||
loaded({ '1': season1 })
|
||||
)
|
||||
).toEqual({ kind: 'continue', position: unfinished });
|
||||
});
|
||||
|
||||
it('goes on from a later season the user started with', () => {
|
||||
const rows = [row(21, WATCHED)];
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[0]),
|
||||
rows,
|
||||
loaded({
|
||||
'1': [episode(1, 1, 1), episode(2, 1, 2)],
|
||||
'2': [episode(21, 2, 1), episode(22, 2, 2)],
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: { contentXtreamId: 22, seasonNumber: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('goes on past a skipped episode and is finished after the finale', () => {
|
||||
const rows = [1, 2, 4].map((id) =>
|
||||
row(id, WATCHED, `2026-10-0${id} 08:00:00`)
|
||||
);
|
||||
const resolve = (watched: PlaybackPositionData[]) =>
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(watched[watched.length - 1]),
|
||||
watched,
|
||||
loaded({ '1': season1 })
|
||||
);
|
||||
|
||||
expect(resolve(rows)).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: { contentXtreamId: 5 },
|
||||
});
|
||||
expect(
|
||||
resolve([...rows, row(5, WATCHED, '2026-10-05 08:00:00')])
|
||||
).toEqual({ kind: 'finished' });
|
||||
});
|
||||
|
||||
it('does not lead into the specials of season 0', () => {
|
||||
const rows = [row(2, WATCHED)];
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[0]),
|
||||
rows,
|
||||
loaded({
|
||||
'0': [episode(91, 0, 1)],
|
||||
'1': [episode(1, 1, 1), episode(2, 1, 2)],
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'finished' });
|
||||
});
|
||||
|
||||
describe('with extras (season 0)', () => {
|
||||
const seasons = loaded({
|
||||
'0': [episode(91, 0, 1), episode(92, 0, 2)],
|
||||
'1': season1,
|
||||
});
|
||||
const extra = (positionSeconds: number): PlaybackPositionData => ({
|
||||
...row(91, positionSeconds, '2026-10-02 08:00:00'),
|
||||
seasonNumber: 0,
|
||||
episodeNumber: 1,
|
||||
});
|
||||
|
||||
it('goes back to the run after an extra, watched or left unfinished', () => {
|
||||
const watchedRun = row(1, WATCHED, '2026-10-01 08:00:00');
|
||||
|
||||
for (const newest of [extra(WATCHED), extra(1200)]) {
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(newest),
|
||||
[watchedRun, newest],
|
||||
seasons
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: {
|
||||
contentXtreamId: 2,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
positionSeconds: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('starts the run when only an extra was played', () => {
|
||||
const newest = extra(WATCHED);
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(newest),
|
||||
[newest],
|
||||
seasons
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: { contentXtreamId: 1, seasonNumber: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('is finished after the finale, extras left or not', () => {
|
||||
const rows = [1, 2, 3, 4, 5].map((id) =>
|
||||
row(id, WATCHED, `2026-10-0${id} 08:00:00`)
|
||||
);
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[4]),
|
||||
rows,
|
||||
seasons
|
||||
)
|
||||
).toEqual({ kind: 'finished' });
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the season from the episode list when an episode lacks one', () => {
|
||||
const rows = [row(1, WATCHED)];
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[0]),
|
||||
rows,
|
||||
loaded({
|
||||
'3': [episode(1, undefined, 1), episode(2, undefined, 2)],
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'continue',
|
||||
position: { contentXtreamId: 2, seasonNumber: 3, episodeNumber: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call a series finished on a list cached before its newest episode', () => {
|
||||
// Episodes 1-2 were listed and watched; episode 3 came out and was
|
||||
// watched while that list stood. The provider may list a 4th by now.
|
||||
const rows = [1, 2, 3].map((id) => row(id, WATCHED));
|
||||
|
||||
expect(
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[2]),
|
||||
rows,
|
||||
loaded({ '1': [episode(1, 1, 1), episode(2, 1, 2)] })
|
||||
)
|
||||
).toEqual({ kind: 'unknown', stale: true });
|
||||
});
|
||||
|
||||
it('does not know while the episode list is missing, empty or malformed', () => {
|
||||
const rows = [row(4, WATCHED)];
|
||||
const resolve = (episodes: DashboardSeriesEpisodes | null) =>
|
||||
resolveDashboardSeriesContinuation(
|
||||
candidate(rows[0]),
|
||||
rows,
|
||||
episodes
|
||||
);
|
||||
|
||||
expect(resolve(null)).toEqual({ kind: 'unknown' });
|
||||
expect(resolve({ status: 'loading' })).toEqual({ kind: 'unknown' });
|
||||
expect(resolve({ status: 'failed' })).toEqual({ kind: 'unknown' });
|
||||
expect(resolve(loaded({}))).toEqual({ kind: 'unknown' });
|
||||
// The list does not hold the episode played (renumbered ids): it
|
||||
// is stale, so the caller fetches it again.
|
||||
expect(
|
||||
resolve(loaded({ '1': [episode(7, 1, 1), episode(8, 1, 2)] }))
|
||||
).toEqual({ kind: 'unknown', stale: true });
|
||||
expect(
|
||||
resolve(
|
||||
loaded({ '1': 'not a list' } as unknown as Record<
|
||||
string,
|
||||
XtreamSerieEpisode[]
|
||||
>)
|
||||
)
|
||||
).toEqual({ kind: 'unknown' });
|
||||
expect(
|
||||
resolve(
|
||||
loaded({
|
||||
'1': [episode(4, 1, 4), { ...episode(5, 1, 5), id: 'x5' }],
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'unknown' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSeriesContinuationCandidates', () => {
|
||||
const item = (
|
||||
id: number,
|
||||
overrides: Partial<PortalRecentItem> = {}
|
||||
): PortalRecentItem =>
|
||||
({
|
||||
id,
|
||||
title: `Title ${id}`,
|
||||
type: 'series',
|
||||
source: 'xtream',
|
||||
playlist_id: PLAYLIST,
|
||||
xtream_id: id,
|
||||
viewed_at: '2026-10-04',
|
||||
...overrides,
|
||||
}) as PortalRecentItem;
|
||||
|
||||
it('keeps Xtream series whose newest episode is watched or an extra, newest first', () => {
|
||||
const positions = new Map<number, PlaybackPositionData | null>([
|
||||
[1, row(11, WATCHED)],
|
||||
// In progress: it simply continues.
|
||||
[2, row(12, 600)],
|
||||
[3, row(13, WATCHED)],
|
||||
// A movie leaves on its own once watched.
|
||||
[4, { ...row(14, WATCHED), contentType: 'vod' }],
|
||||
// Stalker episode lists cannot be looked up.
|
||||
[5, row(15, WATCHED)],
|
||||
// Without its series id there is nothing to look up.
|
||||
[6, { ...row(16, WATCHED), seriesXtreamId: undefined }],
|
||||
[7, null],
|
||||
// An extra, even unfinished: it never takes the series' place.
|
||||
[8, { ...row(18, 600), seasonNumber: 0 }],
|
||||
]);
|
||||
const items = [
|
||||
item(1),
|
||||
item(2),
|
||||
item(3),
|
||||
item(4, { type: 'movie' }),
|
||||
item(5, { source: 'stalker' }),
|
||||
item(6),
|
||||
item(7),
|
||||
item(8),
|
||||
];
|
||||
|
||||
const candidates = selectSeriesContinuationCandidates(
|
||||
items,
|
||||
(recent) => positions.get(recent.id as number) ?? null
|
||||
);
|
||||
|
||||
expect(candidates.map((c) => [c.item.id, c.seriesXtreamId])).toEqual([
|
||||
[1, SERIES],
|
||||
[3, SERIES],
|
||||
[8, SERIES],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planDashboardSeriesLookups', () => {
|
||||
const recent = (id: number, type: 'movie' | 'series'): PortalRecentItem =>
|
||||
({
|
||||
id,
|
||||
title: `Title ${id}`,
|
||||
type,
|
||||
source: 'xtream',
|
||||
playlist_id: PLAYLIST,
|
||||
xtream_id: id,
|
||||
viewed_at: '2026-10-04',
|
||||
}) as PortalRecentItem;
|
||||
const asCandidate = (item: PortalRecentItem): DashboardSeriesCandidate => ({
|
||||
item,
|
||||
seriesXtreamId: item.xtream_id as number,
|
||||
newest: row(1, WATCHED),
|
||||
});
|
||||
const plan = (
|
||||
items: PortalRecentItem[],
|
||||
finished: (candidate: DashboardSeriesCandidate) => boolean
|
||||
) =>
|
||||
planDashboardSeriesLookups({
|
||||
items,
|
||||
candidates: items
|
||||
.filter((item) => item.type === 'series')
|
||||
.map(asCandidate),
|
||||
listedWithoutLookup: () => true,
|
||||
resolve: (candidate) =>
|
||||
finished(candidate)
|
||||
? { kind: 'finished' }
|
||||
: { kind: 'unknown' },
|
||||
});
|
||||
const ids = (list: readonly DashboardSeriesCandidate[]) =>
|
||||
list.map((candidate) => candidate.item.id);
|
||||
|
||||
it("stops once the rail's titles are listed, counting titles that need no lookup", () => {
|
||||
const movies = Array.from(
|
||||
{ length: CONTINUE_WATCHING_VISIBLE_ITEMS - 2 },
|
||||
(_, i) => recent(i + 1, 'movie')
|
||||
);
|
||||
const series = [101, 102, 103].map((id) => recent(id, 'series'));
|
||||
|
||||
const result = plan([...movies, ...series], () => false);
|
||||
|
||||
expect(ids(result.window)).toEqual([101, 102]);
|
||||
expect(result.continuations.has(`${PLAYLIST}::series::103`)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('looks past finished series for the next one', () => {
|
||||
const series = Array.from(
|
||||
{ length: CONTINUE_WATCHING_VISIBLE_ITEMS + 1 },
|
||||
(_, i) => recent(i + 1, 'series')
|
||||
);
|
||||
|
||||
const result = plan(
|
||||
series,
|
||||
(candidate) =>
|
||||
(candidate.item.id as number) <= CONTINUE_WATCHING_VISIBLE_ITEMS
|
||||
);
|
||||
|
||||
expect(result.window).toHaveLength(series.length);
|
||||
});
|
||||
|
||||
it(`looks up ${CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT} series at most`, () => {
|
||||
const series = Array.from(
|
||||
{ length: CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT + 1 },
|
||||
(_, i) => recent(i + 1, 'series')
|
||||
);
|
||||
|
||||
const result = plan(series, () => true);
|
||||
|
||||
expect(result.window).toHaveLength(
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT
|
||||
);
|
||||
expect(result.continuations.size).toBe(
|
||||
CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
getSeriesNextUp,
|
||||
isPortalPlaybackWatched,
|
||||
type SeriesEpisodeEntry,
|
||||
type SeriesNextUp,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
resolvePortalActivityWatchKind,
|
||||
type PlaybackPositionData,
|
||||
type PortalRecentItem,
|
||||
type XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { DashboardSeriesEpisodes } from './dashboard-series-episodes.service';
|
||||
|
||||
/**
|
||||
* Titles the rail can show (its RAIL_ITEM_LIMIT): series are looked up, newest
|
||||
* first, until this many titles are known to stay listed.
|
||||
*/
|
||||
export const CONTINUE_WATCHING_VISIBLE_ITEMS = 20;
|
||||
|
||||
/** Series looked up at most, so a long history cannot flood the portal. */
|
||||
export const CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT = 40;
|
||||
|
||||
/**
|
||||
* How long, from the first lookup, Continue Watching waits for episode lists
|
||||
* before showing what it knows: a slow portal must not hold the rail back.
|
||||
*/
|
||||
export const CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Where a series stands once its newest episode is watched, or is an extra:
|
||||
* - `continue`: the episode it goes on with (`getSeriesNextUp`): the first
|
||||
* one not watched after the episode watched last, resumed where it was
|
||||
* left if it was started. Extras (season 0) never change it;
|
||||
* - `finished`: no unwatched episode follows the one watched last, even if
|
||||
* an earlier one was skipped or extras are left;
|
||||
* - `unknown`: its episode list is missing (loading, failed, empty) or does
|
||||
* not hold the episode played last (`stale`: the list predates it, so it
|
||||
* is fetched again). The series then keeps its newest episode rather than
|
||||
* vanish on a guess.
|
||||
*/
|
||||
export type DashboardSeriesContinuation =
|
||||
| { readonly kind: 'unknown'; readonly stale?: boolean }
|
||||
| { readonly kind: 'finished' }
|
||||
| { readonly kind: 'continue'; readonly position: PlaybackPositionData };
|
||||
|
||||
export interface DashboardSeriesCandidate {
|
||||
readonly item: PortalRecentItem;
|
||||
readonly seriesXtreamId: number;
|
||||
/** The series' newest episode row: watched, or an extra. */
|
||||
readonly newest: PlaybackPositionData;
|
||||
}
|
||||
|
||||
export function dashboardRecentItemKey(
|
||||
item: Pick<PortalRecentItem, 'playlist_id' | 'type' | 'xtream_id' | 'id'>
|
||||
): string {
|
||||
return `${item.playlist_id}::${item.type}::${item.xtream_id ?? item.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Xtream series in the history whose newest episode is watched, or is an
|
||||
* extra (season 0), which never takes a series' place even left unfinished:
|
||||
* whether they stay on Continue Watching, and with which episode, depends
|
||||
* on the episode list. Series from other providers cannot be looked up and
|
||||
* keep their place.
|
||||
*/
|
||||
export function selectSeriesContinuationCandidates(
|
||||
items: readonly PortalRecentItem[],
|
||||
storedPosition: (item: PortalRecentItem) => PlaybackPositionData | null
|
||||
): DashboardSeriesCandidate[] {
|
||||
const candidates: DashboardSeriesCandidate[] = [];
|
||||
for (const item of items) {
|
||||
if (
|
||||
item.source !== 'xtream' ||
|
||||
resolvePortalActivityWatchKind(item) !== 'series'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const newest = storedPosition(item);
|
||||
const seriesXtreamId = Number(newest?.seriesXtreamId);
|
||||
if (
|
||||
newest?.contentType === 'episode' &&
|
||||
(isPortalPlaybackWatched(newest) || newest.seasonNumber === 0) &&
|
||||
Number.isInteger(seriesXtreamId) &&
|
||||
seriesXtreamId > 0
|
||||
) {
|
||||
candidates.push({ item, seriesXtreamId, newest });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export interface DashboardSeriesLookupPlan {
|
||||
/** The candidates to look up, newest first. */
|
||||
readonly window: readonly DashboardSeriesCandidate[];
|
||||
/** What each looked-up candidate continues with, by recent item key. */
|
||||
readonly continuations: ReadonlyMap<string, DashboardSeriesContinuation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which series to look up: candidates in history order until
|
||||
* CONTINUE_WATCHING_VISIBLE_ITEMS titles stay listed, counting movies and
|
||||
* series that need no lookup, and candidates not known to be finished. Past
|
||||
* those the rail has no slot left; a finished candidate frees its slot, so
|
||||
* the window grows as answers arrive. At most
|
||||
* CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT are looked up. A candidate never
|
||||
* looked up gets no continuation and keeps its place: limiting lookups must
|
||||
* not hide a series with episodes left.
|
||||
*/
|
||||
export function planDashboardSeriesLookups(input: {
|
||||
readonly items: readonly PortalRecentItem[];
|
||||
readonly candidates: readonly DashboardSeriesCandidate[];
|
||||
readonly listedWithoutLookup: (item: PortalRecentItem) => boolean;
|
||||
readonly resolve: (
|
||||
candidate: DashboardSeriesCandidate
|
||||
) => DashboardSeriesContinuation;
|
||||
}): DashboardSeriesLookupPlan {
|
||||
const candidates = new Map(
|
||||
input.candidates.map((candidate) => [
|
||||
dashboardRecentItemKey(candidate.item),
|
||||
candidate,
|
||||
])
|
||||
);
|
||||
const window: DashboardSeriesCandidate[] = [];
|
||||
const continuations = new Map<string, DashboardSeriesContinuation>();
|
||||
let listed = 0;
|
||||
for (const item of input.items) {
|
||||
if (listed >= CONTINUE_WATCHING_VISIBLE_ITEMS) {
|
||||
break;
|
||||
}
|
||||
const key = dashboardRecentItemKey(item);
|
||||
const candidate = candidates.get(key);
|
||||
if (
|
||||
!candidate ||
|
||||
continuations.has(key) ||
|
||||
window.length >= CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT
|
||||
) {
|
||||
const known = continuations.get(key);
|
||||
if (
|
||||
known
|
||||
? known.kind !== 'finished'
|
||||
: input.listedWithoutLookup(item)
|
||||
) {
|
||||
listed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
window.push(candidate);
|
||||
const continuation = input.resolve(candidate);
|
||||
continuations.set(key, continuation);
|
||||
if (continuation.kind !== 'finished') {
|
||||
listed++;
|
||||
}
|
||||
}
|
||||
return { window, continuations };
|
||||
}
|
||||
|
||||
export function resolveDashboardSeriesContinuation(
|
||||
candidate: DashboardSeriesCandidate,
|
||||
seriesPositions: readonly PlaybackPositionData[],
|
||||
episodes: DashboardSeriesEpisodes | null | undefined
|
||||
): DashboardSeriesContinuation {
|
||||
if (episodes?.status !== 'loaded') {
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
// A list cached before the newest episode was played cannot say what
|
||||
// follows it: the provider may list more by now.
|
||||
const listed = listedEpisodeIds(episodes.seasons);
|
||||
if (!listed.includes(candidate.newest.contentXtreamId)) {
|
||||
return listed.length > 0
|
||||
? { kind: 'unknown', stale: true }
|
||||
: { kind: 'unknown' };
|
||||
}
|
||||
let nextUp: SeriesNextUp | null;
|
||||
try {
|
||||
nextUp = getSeriesNextUp({
|
||||
seasons: episodes.seasons,
|
||||
playbackPositions: new Map(
|
||||
seriesPositions.map((row) => [row.contentXtreamId, row])
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// A malformed episode list says nothing about what comes next.
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
// `start`: none of the episodes played is in the list.
|
||||
if (!nextUp || nextUp.kind === 'start') {
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
if (nextUp.kind === 'caught-up') {
|
||||
return { kind: 'finished' };
|
||||
}
|
||||
const { entry } = nextUp;
|
||||
if (entry.position && !isPortalPlaybackWatched(entry.position)) {
|
||||
return { kind: 'continue', position: entry.position };
|
||||
}
|
||||
const episodeId = Number(entry.episode?.id);
|
||||
if (!Number.isInteger(episodeId) || episodeId <= 0) {
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
return {
|
||||
kind: 'continue',
|
||||
position: nextEpisodeRow(candidate, episodeId, entry),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every episode id the list names; a malformed season names none. */
|
||||
function listedEpisodeIds(
|
||||
seasons: Readonly<Record<string, readonly XtreamSerieEpisode[]>>
|
||||
): number[] {
|
||||
const ids: number[] = [];
|
||||
for (const episodes of Object.values(seasons)) {
|
||||
if (Array.isArray(episodes)) {
|
||||
for (const episode of episodes) {
|
||||
ids.push(Number(episode?.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** The row an episode not started yet stands for: 0:00 and no progress. */
|
||||
function nextEpisodeRow(
|
||||
candidate: DashboardSeriesCandidate,
|
||||
episodeId: number,
|
||||
entry: SeriesEpisodeEntry
|
||||
): PlaybackPositionData {
|
||||
const seasonNumber = toEpisodeNumber(entry.seasonNumber);
|
||||
const episodeNumber = toEpisodeNumber(entry.episode.episode_num);
|
||||
return {
|
||||
contentXtreamId: episodeId,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: candidate.seriesXtreamId,
|
||||
...(seasonNumber !== null && episodeNumber !== null
|
||||
? { seasonNumber, episodeNumber }
|
||||
: {}),
|
||||
positionSeconds: 0,
|
||||
playlistId: candidate.newest.playlistId,
|
||||
updatedAt: candidate.newest.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toEpisodeNumber(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number >= 0 ? number : null;
|
||||
}
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { XtreamApiService } from '@iptvnator/portal/xtream/data-access';
|
||||
import type { XtreamSerieDetails } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
DASHBOARD_SERIES_EPISODES_MAX_AGE_MS,
|
||||
DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS,
|
||||
DashboardSeriesEpisodesService,
|
||||
dashboardSeriesEpisodesKey,
|
||||
sameDashboardSeriesEpisodesRequests,
|
||||
type DashboardSeriesEpisodesRequest,
|
||||
} from './dashboard-series-episodes.service';
|
||||
|
||||
const credentials = {
|
||||
serverUrl: 'http://provider.example',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
|
||||
function request(seriesId: number): DashboardSeriesEpisodesRequest {
|
||||
return { playlistId: 'xtream-1', seriesId, credentials };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('DashboardSeriesEpisodesService', () => {
|
||||
let getSeriesInfo: jest.Mock;
|
||||
let service: DashboardSeriesEpisodesService;
|
||||
const status = (seriesId: number) =>
|
||||
service.episodes().get(dashboardSeriesEpisodesKey('xtream-1', seriesId))
|
||||
?.status;
|
||||
|
||||
beforeEach(() => {
|
||||
getSeriesInfo = jest.fn();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: XtreamApiService, useValue: { getSeriesInfo } },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardSeriesEpisodesService);
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('loads each series once with its playlist credentials', async () => {
|
||||
const pending = deferred<XtreamSerieDetails>();
|
||||
getSeriesInfo.mockReturnValue(pending.promise);
|
||||
|
||||
service.request([request(900)]);
|
||||
service.request([request(900)]);
|
||||
expect(status(900)).toBe('loading');
|
||||
|
||||
const seasons = { '1': [{ id: '901', episode_num: 1 }] };
|
||||
pending.resolve({ episodes: seasons } as unknown as XtreamSerieDetails);
|
||||
await pending.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
// A background lookup: a failure must not raise an error toast.
|
||||
expect(getSeriesInfo).toHaveBeenCalledWith(credentials, 900, {
|
||||
suppressErrorLog: true,
|
||||
});
|
||||
expect(
|
||||
service.episodes().get(dashboardSeriesEpisodesKey('xtream-1', 900))
|
||||
).toEqual({ status: 'loaded', seasons });
|
||||
|
||||
service.request([request(900)]);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs at most two lookups at once', async () => {
|
||||
const answers = [1, 2, 3].map(() => deferred<XtreamSerieDetails>());
|
||||
answers.forEach((answer) =>
|
||||
getSeriesInfo.mockReturnValueOnce(answer.promise)
|
||||
);
|
||||
|
||||
service.request([request(1), request(2), request(3)]);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(status(3)).toBe('loading');
|
||||
|
||||
answers[0].resolve({ episodes: {} } as unknown as XtreamSerieDetails);
|
||||
await answers[0].promise;
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(getSeriesInfo).toHaveBeenLastCalledWith(credentials, 3, {
|
||||
suppressErrorLog: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('in lookup rounds', () => {
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve));
|
||||
const entry = (seriesId: number) =>
|
||||
service
|
||||
.episodes()
|
||||
.get(dashboardSeriesEpisodesKey('xtream-1', seriesId));
|
||||
const seasons = (...ids: number[]) => ({
|
||||
'1': ids.map((id, index) => ({
|
||||
id: String(id),
|
||||
episode_num: index + 1,
|
||||
})),
|
||||
});
|
||||
let now: number;
|
||||
|
||||
beforeEach(() => {
|
||||
now = Date.UTC(2026, 9, 5, 8);
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
});
|
||||
|
||||
it('retries a failed lookup only in a later round, after the retry delay', async () => {
|
||||
getSeriesInfo.mockRejectedValueOnce(
|
||||
new Error('Portal is not responding')
|
||||
);
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
expect(status(900)).toBe('failed');
|
||||
|
||||
// More series to look past in the same round: no retry.
|
||||
service.request([request(900), request(901)], 1);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(getSeriesInfo).toHaveBeenLastCalledWith(credentials, 901, {
|
||||
suppressErrorLog: true,
|
||||
});
|
||||
|
||||
// A later round too soon after the failure: the portal is left
|
||||
// alone, whatever the user does on the dashboard.
|
||||
service.request([request(900)], 2);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS;
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 3);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
await flush();
|
||||
expect(status(900)).toBe('loaded');
|
||||
});
|
||||
|
||||
it('stops asking a source after two failures in a row, until the retry delay has passed', async () => {
|
||||
getSeriesInfo
|
||||
.mockRejectedValueOnce(new Error('Portal is not responding'))
|
||||
.mockRejectedValueOnce(new Error('Portal is not responding'));
|
||||
service.request([1, 2, 3, 4].map(request), 1);
|
||||
await flush();
|
||||
|
||||
// The first two answered for the portal: the rest are not asked.
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect([1, 2, 3, 4].map(status)).toEqual([
|
||||
'failed',
|
||||
'failed',
|
||||
'failed',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
// Another source is unaffected.
|
||||
const other: DashboardSeriesEpisodesRequest = {
|
||||
playlistId: 'xtream-2',
|
||||
seriesId: 5,
|
||||
credentials: {
|
||||
...credentials,
|
||||
serverUrl: 'http://other.example',
|
||||
},
|
||||
};
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(51) });
|
||||
service.request([other], 1);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
service
|
||||
.episodes()
|
||||
.get(dashboardSeriesEpisodesKey('xtream-2', 5))?.status
|
||||
).toBe('loaded');
|
||||
|
||||
// A later round before the delay: still left alone.
|
||||
service.request([1, 2, 3, 4].map(request), 2);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS;
|
||||
getSeriesInfo.mockResolvedValue({ episodes: seasons(9) });
|
||||
service.request([1, 2, 3, 4].map(request), 3);
|
||||
await flush();
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(7);
|
||||
expect([1, 2, 3, 4].map(status)).toEqual([
|
||||
'loaded',
|
||||
'loaded',
|
||||
'loaded',
|
||||
'loaded',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets a recovered source be asked again even when lookups were skipped meanwhile', async () => {
|
||||
getSeriesInfo
|
||||
.mockRejectedValueOnce(new Error('Portal is not responding'))
|
||||
.mockRejectedValueOnce(new Error('Portal is not responding'));
|
||||
service.request([1, 2].map(request), 1);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A series becomes eligible while the source is left alone: it
|
||||
// is skipped, and that must not push the source's cooldown out.
|
||||
now += DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS * 0.8;
|
||||
service.request([1, 2, 3].map(request), 2);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(status(3)).toBe('failed');
|
||||
|
||||
// The portal is back: the next round after the delay asks for
|
||||
// every series, the skipped one included.
|
||||
now += DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS * 0.4;
|
||||
getSeriesInfo.mockResolvedValue({ episodes: seasons(9) });
|
||||
service.request([1, 2, 3].map(request), 3);
|
||||
await flush();
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(5);
|
||||
expect([1, 2, 3].map(status)).toEqual([
|
||||
'loaded',
|
||||
'loaded',
|
||||
'loaded',
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks a source that stopped answering again at once with a corrected password', async () => {
|
||||
getSeriesInfo
|
||||
.mockRejectedValueOnce(new Error('Wrong password'))
|
||||
.mockRejectedValueOnce(new Error('Wrong password'));
|
||||
service.request([1, 2, 3].map(request), 1);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(status(3)).toBe('failed');
|
||||
|
||||
const corrected = (seriesId: number) => ({
|
||||
...request(seriesId),
|
||||
credentials: { ...credentials, password: 'corrected' },
|
||||
});
|
||||
getSeriesInfo.mockResolvedValue({ episodes: seasons(9) });
|
||||
service.request([1, 2, 3].map(corrected), 1);
|
||||
await flush();
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(5);
|
||||
expect([1, 2, 3].map(status)).toEqual([
|
||||
'loaded',
|
||||
'loaded',
|
||||
'loaded',
|
||||
]);
|
||||
});
|
||||
|
||||
it('tries a password corrected while the lookup was in flight once the old one fails', async () => {
|
||||
const corrected = (seriesId: number) => ({
|
||||
...request(seriesId),
|
||||
credentials: { ...credentials, password: 'corrected' },
|
||||
});
|
||||
const stale = deferred<XtreamSerieDetails>();
|
||||
getSeriesInfo.mockReturnValueOnce(stale.promise);
|
||||
service.request([request(900)], 1);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The playlist is edited while the lookup is out.
|
||||
service.request([corrected(900)], 1);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
stale.reject(new Error('Wrong password'));
|
||||
await flush();
|
||||
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(getSeriesInfo).toHaveBeenLastCalledWith(
|
||||
corrected(900).credentials,
|
||||
900,
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
expect(status(900)).toBe('loaded');
|
||||
|
||||
// A list that loaded anyway needs no second lookup.
|
||||
const pending = deferred<XtreamSerieDetails>();
|
||||
getSeriesInfo.mockReturnValueOnce(pending.promise);
|
||||
service.request(
|
||||
[
|
||||
{
|
||||
...request(910),
|
||||
credentials: { ...credentials, password: 'corrected' },
|
||||
},
|
||||
],
|
||||
1
|
||||
);
|
||||
service.request(
|
||||
[
|
||||
{
|
||||
...request(910),
|
||||
credentials: { ...credentials, password: 'newer' },
|
||||
},
|
||||
],
|
||||
1
|
||||
);
|
||||
pending.resolve({
|
||||
episodes: seasons(911),
|
||||
} as unknown as XtreamSerieDetails);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(status(910)).toBe('loaded');
|
||||
});
|
||||
|
||||
it('fetches a list again when asked to, once per retry delay, in a later round', async () => {
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
|
||||
// The list predates the episode played last, but just loaded.
|
||||
const refresh = { ...request(900), refresh: true };
|
||||
service.request([refresh], 2);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS;
|
||||
// Without the hint the list is still current at this age.
|
||||
service.request([request(900)], 3);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
getSeriesInfo.mockResolvedValueOnce({
|
||||
episodes: seasons(901, 902),
|
||||
});
|
||||
service.request([refresh], 4);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901, 902),
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes a list whose refresh failed at once with a corrected password', async () => {
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_MAX_AGE_MS;
|
||||
getSeriesInfo.mockRejectedValueOnce(new Error('Wrong password'));
|
||||
service.request([request(900)], 2);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901),
|
||||
});
|
||||
|
||||
// The playlist is edited: no waiting for the retry delay.
|
||||
getSeriesInfo.mockResolvedValueOnce({
|
||||
episodes: seasons(901, 902),
|
||||
});
|
||||
service.request(
|
||||
[
|
||||
{
|
||||
...request(900),
|
||||
credentials: { ...credentials, password: 'corrected' },
|
||||
},
|
||||
],
|
||||
2
|
||||
);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901, 902),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a password corrected for the new source when the old source answers late', async () => {
|
||||
const moved = (password = credentials.password) => ({
|
||||
...request(900),
|
||||
credentials: {
|
||||
...credentials,
|
||||
serverUrl: 'http://moved.example',
|
||||
password,
|
||||
},
|
||||
});
|
||||
const oldAnswer = deferred<XtreamSerieDetails>();
|
||||
const movedAnswer = deferred<XtreamSerieDetails>();
|
||||
getSeriesInfo
|
||||
.mockReturnValueOnce(oldAnswer.promise)
|
||||
.mockReturnValueOnce(movedAnswer.promise);
|
||||
service.request([request(900)], 1);
|
||||
// The playlist moves to another server while the lookup is out,
|
||||
// and its password is corrected while the new lookup is out.
|
||||
service.request([moved()], 1);
|
||||
service.request([moved('corrected')], 1);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The old server answers late: nothing of the new one is lost.
|
||||
oldAnswer.reject(new Error('Gone'));
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
movedAnswer.reject(new Error('Wrong password'));
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(getSeriesInfo).toHaveBeenLastCalledWith(
|
||||
moved('corrected').credentials,
|
||||
900,
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
expect(status(900)).toBe('loaded');
|
||||
});
|
||||
|
||||
it('leaves a list whose refresh failed alone until the retry delay has passed', async () => {
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_MAX_AGE_MS;
|
||||
getSeriesInfo.mockRejectedValueOnce(new Error('Too many requests'));
|
||||
service.request([request(900)], 2);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Still past its age, but the refresh just failed.
|
||||
service.request([request(900)], 3);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901),
|
||||
});
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS;
|
||||
getSeriesInfo.mockResolvedValueOnce({
|
||||
episodes: seasons(901, 902),
|
||||
});
|
||||
service.request([request(900)], 4);
|
||||
await flush();
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901, 902),
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a failed lookup at once with a corrected password, without refetching loaded lists', async () => {
|
||||
const corrected = (seriesId: number) => ({
|
||||
...request(seriesId),
|
||||
credentials: { ...credentials, password: 'corrected' },
|
||||
});
|
||||
getSeriesInfo
|
||||
.mockRejectedValueOnce(new Error('Wrong password'))
|
||||
.mockResolvedValueOnce({ episodes: seasons(911) });
|
||||
service.request([request(900), request(910)], 1);
|
||||
await flush();
|
||||
expect(status(900)).toBe('failed');
|
||||
expect(status(910)).toBe('loaded');
|
||||
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([corrected(900), corrected(910)], 1);
|
||||
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(3);
|
||||
expect(getSeriesInfo).toHaveBeenLastCalledWith(
|
||||
corrected(900).credentials,
|
||||
900,
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
await flush();
|
||||
expect(status(900)).toBe('loaded');
|
||||
});
|
||||
|
||||
it('refreshes a list past its age in a later round, the old one standing meanwhile', async () => {
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
|
||||
// Still current, even in a later round.
|
||||
service.request([request(900)], 2);
|
||||
now += DASHBOARD_SERIES_EPISODES_MAX_AGE_MS;
|
||||
// Past its age, but the same round.
|
||||
service.request([request(900)], 2);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
const refresh = deferred<XtreamSerieDetails>();
|
||||
getSeriesInfo.mockReturnValueOnce(refresh.promise);
|
||||
service.request([request(900)], 3);
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901),
|
||||
});
|
||||
|
||||
refresh.resolve({
|
||||
episodes: seasons(901, 902),
|
||||
} as unknown as XtreamSerieDetails);
|
||||
await flush();
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901, 902),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the list when its refresh fails', async () => {
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
|
||||
now += DASHBOARD_SERIES_EPISODES_MAX_AGE_MS;
|
||||
getSeriesInfo.mockRejectedValueOnce(new Error('Too many requests'));
|
||||
service.request([request(900)], 2);
|
||||
await flush();
|
||||
|
||||
expect(getSeriesInfo).toHaveBeenCalledTimes(2);
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901),
|
||||
});
|
||||
});
|
||||
|
||||
it('looks a series up again for another source, and ignores the old answer', async () => {
|
||||
const moved = {
|
||||
...request(900),
|
||||
credentials: {
|
||||
...credentials,
|
||||
serverUrl: 'http://moved.example',
|
||||
},
|
||||
};
|
||||
getSeriesInfo.mockResolvedValueOnce({ episodes: seasons(901) });
|
||||
service.request([request(900)], 1);
|
||||
await flush();
|
||||
|
||||
// Another server: the old provider's list must not stand.
|
||||
const movedAnswer = deferred<XtreamSerieDetails>();
|
||||
const backAnswer = deferred<XtreamSerieDetails>();
|
||||
getSeriesInfo
|
||||
.mockReturnValueOnce(movedAnswer.promise)
|
||||
.mockReturnValueOnce(backAnswer.promise);
|
||||
service.request([moved], 1);
|
||||
expect(entry(900)).toEqual({ status: 'loading' });
|
||||
expect(getSeriesInfo).toHaveBeenLastCalledWith(
|
||||
moved.credentials,
|
||||
900,
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
// And back, with the answer for the moved source still out.
|
||||
service.request([request(900)], 1);
|
||||
|
||||
movedAnswer.resolve({
|
||||
episodes: seasons(7),
|
||||
} as unknown as XtreamSerieDetails);
|
||||
await flush();
|
||||
expect(entry(900)).toEqual({ status: 'loading' });
|
||||
|
||||
backAnswer.resolve({
|
||||
episodes: seasons(901, 902),
|
||||
} as unknown as XtreamSerieDetails);
|
||||
await flush();
|
||||
expect(entry(900)).toEqual({
|
||||
status: 'loaded',
|
||||
seasons: seasons(901, 902),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sameDashboardSeriesEpisodesRequests', () => {
|
||||
it('tells request lists apart by series, order, credentials and refresh hint only', () => {
|
||||
const same = sameDashboardSeriesEpisodesRequests;
|
||||
|
||||
expect(same([request(1), request(2)], [request(1), request(2)])).toBe(
|
||||
true
|
||||
);
|
||||
expect(same([request(1), request(2)], [request(2), request(1)])).toBe(
|
||||
false
|
||||
);
|
||||
expect(same([request(1)], [request(1), request(2)])).toBe(false);
|
||||
expect(
|
||||
same([request(1)], [{ ...request(1), playlistId: 'xtream-2' }])
|
||||
).toBe(false);
|
||||
expect(same([request(1)], [{ ...request(1), refresh: true }])).toBe(
|
||||
false
|
||||
);
|
||||
expect(same([{ ...request(1), refresh: false }], [request(1)])).toBe(
|
||||
true
|
||||
);
|
||||
for (const field of ['serverUrl', 'username', 'password'] as const) {
|
||||
expect(
|
||||
same(
|
||||
[request(1)],
|
||||
[
|
||||
{
|
||||
...request(1),
|
||||
credentials: { ...credentials, [field]: 'changed' },
|
||||
},
|
||||
]
|
||||
)
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util/logger';
|
||||
import {
|
||||
XtreamApiService,
|
||||
type XtreamCredentials,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** The episode list of one Xtream series, as far as the dashboard knows it. */
|
||||
export type DashboardSeriesEpisodes =
|
||||
| { readonly status: 'loading' }
|
||||
| { readonly status: 'failed' }
|
||||
| {
|
||||
readonly status: 'loaded';
|
||||
readonly seasons: Readonly<Record<string, XtreamSerieEpisode[]>>;
|
||||
};
|
||||
|
||||
export interface DashboardSeriesEpisodesRequest {
|
||||
readonly playlistId: string;
|
||||
readonly seriesId: number;
|
||||
readonly credentials: XtreamCredentials;
|
||||
/**
|
||||
* The loaded list predates the episode played last: fetch it again in
|
||||
* the next round, once DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS has
|
||||
* passed since it loaded, instead of waiting for its age.
|
||||
*/
|
||||
readonly refresh?: boolean;
|
||||
}
|
||||
|
||||
/** At most this many lookups run at once, so a visit cannot flood the portal. */
|
||||
const MAX_CONCURRENT_LOOKUPS = 2;
|
||||
|
||||
/**
|
||||
* How long a loaded list counts as current. A provider adds episodes while
|
||||
* the app stays open, so an older list is fetched again in the next lookup
|
||||
* round, the old one standing until the new one arrives.
|
||||
*/
|
||||
export const DASHBOARD_SERIES_EPISODES_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long a failed lookup, and a source whose lookups keep failing, are
|
||||
* left alone. Lookup rounds follow the user (each dashboard entry is one),
|
||||
* so without this a portal that is down or expired would be asked for
|
||||
* every series on every visit.
|
||||
*/
|
||||
export const DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Failures in a row after which a source's remaining lookups are skipped
|
||||
* for the retry delay: the portal, not the series, is the problem.
|
||||
*/
|
||||
const SOURCE_FAILURES_TO_TRIP = 2;
|
||||
|
||||
export function dashboardSeriesEpisodesKey(
|
||||
playlistId: string,
|
||||
seriesId: number
|
||||
): string {
|
||||
return `${playlistId}::${seriesId}`;
|
||||
}
|
||||
|
||||
/** Another server or account is another catalog. */
|
||||
function sourceOf(request: DashboardSeriesEpisodesRequest): string {
|
||||
return `${request.credentials.serverUrl}::${request.credentials.username}`;
|
||||
}
|
||||
|
||||
/** Same series, same credentials, same refresh hint, same order. */
|
||||
export function sameDashboardSeriesEpisodesRequests(
|
||||
requestsA: readonly DashboardSeriesEpisodesRequest[],
|
||||
requestsB: readonly DashboardSeriesEpisodesRequest[]
|
||||
): boolean {
|
||||
return (
|
||||
requestsA.length === requestsB.length &&
|
||||
requestsA.every((a, index) => {
|
||||
const b = requestsB[index];
|
||||
return (
|
||||
a.playlistId === b.playlistId &&
|
||||
a.seriesId === b.seriesId &&
|
||||
a.credentials.serverUrl === b.credentials.serverUrl &&
|
||||
a.credentials.username === b.credentials.username &&
|
||||
a.credentials.password === b.credentials.password &&
|
||||
!!a.refresh === !!b.refresh
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode lists for the series Continue Watching has to look past: a series
|
||||
* whose newest episode is watched, or is an extra, goes on with an episode
|
||||
* only the portal's `get_series_info` names. Lists are kept per session and
|
||||
* source. A failed lookup, or a list older than
|
||||
* DASHBOARD_SERIES_EPISODES_MAX_AGE_MS, is tried again in a later lookup
|
||||
* round, once DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS has passed since the
|
||||
* failure. A source that fails SOURCE_FAILURES_TO_TRIP lookups in a row is
|
||||
* not asked for the rest of its series until then either. Lookups run in
|
||||
* the background, so a failure is logged, never shown.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardSeriesEpisodesService {
|
||||
private readonly api = inject(XtreamApiService);
|
||||
private readonly logger = createLogger('DashboardSeriesEpisodes');
|
||||
private readonly entries = signal<
|
||||
ReadonlyMap<string, DashboardSeriesEpisodes>
|
||||
>(new Map());
|
||||
private readonly queue: DashboardSeriesEpisodesRequest[] = [];
|
||||
/** The source each series was last asked for, and the round it was last asked in. */
|
||||
private readonly sources = new Map<string, string>();
|
||||
private readonly rounds = new Map<string, number>();
|
||||
/** The password of each series' last attempt: a corrected one retries a failed attempt. */
|
||||
private readonly passwords = new Map<string, string>();
|
||||
private readonly loadedAt = new Map<string, number>();
|
||||
/** When each series' last lookup failed, until one succeeds. */
|
||||
private readonly failedAt = new Map<string, number>();
|
||||
/** Each source's failures in a row, and when the last one happened. */
|
||||
private readonly sourceFailures = new Map<
|
||||
string,
|
||||
{ count: number; at: number }
|
||||
>();
|
||||
private readonly inFlight = new Set<string>();
|
||||
/**
|
||||
* A request that arrived with another password while its series was in
|
||||
* flight: asked again once the attempt with the old one has failed.
|
||||
*/
|
||||
private readonly followUps = new Map<
|
||||
string,
|
||||
DashboardSeriesEpisodesRequest
|
||||
>();
|
||||
private active = 0;
|
||||
|
||||
readonly episodes = this.entries.asReadonly();
|
||||
|
||||
/**
|
||||
* Queues every series whose list is missing or comes from another
|
||||
* source. A failed lookup, or a list past its age, is asked again only
|
||||
* in a later `round` (the caller's deliberate lookup rounds) and after
|
||||
* the retry delay: the same round asking again, for more series, never
|
||||
* repeats a request. A failed lookup is also asked again at once with a
|
||||
* corrected password. A list being refreshed stands until the new one
|
||||
* arrives.
|
||||
*/
|
||||
request(
|
||||
requests: readonly DashboardSeriesEpisodesRequest[],
|
||||
round = 0
|
||||
): void {
|
||||
const next = new Map(this.entries());
|
||||
const now = Date.now();
|
||||
let queued = false;
|
||||
for (const request of requests) {
|
||||
const key = dashboardSeriesEpisodesKey(
|
||||
request.playlistId,
|
||||
request.seriesId
|
||||
);
|
||||
const source = sourceOf(request);
|
||||
const entry = next.get(key);
|
||||
const sameSource = this.sources.get(key) === source;
|
||||
if (sameSource && this.inFlight.has(`${key}#${source}`)) {
|
||||
if (this.passwords.get(key) !== request.credentials.password) {
|
||||
this.followUps.set(key, request);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const laterRound = round > (this.rounds.get(key) ?? -1);
|
||||
this.rounds.set(
|
||||
key,
|
||||
Math.max(round, this.rounds.get(key) ?? round)
|
||||
);
|
||||
const passwordChanged =
|
||||
this.passwords.get(key) !== request.credentials.password;
|
||||
// The last attempt failed (a first lookup or a refresh of a list
|
||||
// that stands) and the password differs: a deliberate retry.
|
||||
const correctedPassword =
|
||||
sameSource && passwordChanged && this.failedAt.has(key);
|
||||
const retryDue =
|
||||
now - (this.failedAt.get(key) ?? -Infinity) >=
|
||||
DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS;
|
||||
const listAge = now - (this.loadedAt.get(key) ?? 0);
|
||||
const due =
|
||||
!entry ||
|
||||
!sameSource ||
|
||||
correctedPassword ||
|
||||
(laterRound &&
|
||||
retryDue &&
|
||||
(entry.status === 'failed' ||
|
||||
(entry.status === 'loaded' &&
|
||||
listAge >=
|
||||
(request.refresh
|
||||
? DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS
|
||||
: DASHBOARD_SERIES_EPISODES_MAX_AGE_MS))));
|
||||
if (!due) {
|
||||
continue;
|
||||
}
|
||||
if (correctedPassword) {
|
||||
// A corrected password is a deliberate retry of the source.
|
||||
this.sourceFailures.delete(source);
|
||||
}
|
||||
if (!sameSource || entry?.status !== 'loaded') {
|
||||
next.set(key, { status: 'loading' });
|
||||
}
|
||||
this.sources.set(key, source);
|
||||
this.passwords.set(key, request.credentials.password);
|
||||
this.inFlight.add(`${key}#${source}`);
|
||||
this.queue.push(request);
|
||||
queued = true;
|
||||
}
|
||||
if (queued) {
|
||||
this.entries.set(next);
|
||||
this.pump();
|
||||
}
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
while (this.active < MAX_CONCURRENT_LOOKUPS) {
|
||||
const request = this.queue.shift();
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
this.active++;
|
||||
void this.load(request).finally(() => {
|
||||
this.active--;
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async load(request: DashboardSeriesEpisodesRequest): Promise<void> {
|
||||
const key = dashboardSeriesEpisodesKey(
|
||||
request.playlistId,
|
||||
request.seriesId
|
||||
);
|
||||
const source = sourceOf(request);
|
||||
let seasons: Readonly<Record<string, XtreamSerieEpisode[]>> | null =
|
||||
null;
|
||||
const skipped = this.isTripped(source);
|
||||
if (skipped) {
|
||||
// The source failed its last lookups: asking for more series
|
||||
// would only repeat the answer.
|
||||
this.logger.debug('Skipped a series lookup on a failing source');
|
||||
} else {
|
||||
try {
|
||||
const details = await this.api.getSeriesInfo(
|
||||
request.credentials,
|
||||
request.seriesId,
|
||||
{ suppressErrorLog: true }
|
||||
);
|
||||
seasons = details?.episodes ?? {};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Could not load the episodes of a series',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
this.inFlight.delete(`${key}#${source}`);
|
||||
// A follow-up belongs to the source it was asked for: one queued for
|
||||
// another source stands until that source's own attempt is over.
|
||||
const queued = this.followUps.get(key);
|
||||
const followUp =
|
||||
queued && sourceOf(queued) === source ? queued : undefined;
|
||||
if (followUp) {
|
||||
this.followUps.delete(key);
|
||||
}
|
||||
// The series was asked for from another source meanwhile.
|
||||
if (this.sources.get(key) !== source) {
|
||||
return;
|
||||
}
|
||||
if (seasons) {
|
||||
this.loadedAt.set(key, now);
|
||||
this.failedAt.delete(key);
|
||||
this.sourceFailures.delete(source);
|
||||
if (followUp) {
|
||||
this.passwords.set(key, followUp.credentials.password);
|
||||
}
|
||||
this.entries.update((entries) =>
|
||||
new Map(entries).set(key, { status: 'loaded', seasons })
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (skipped) {
|
||||
// Due again with the source, whose cooldown a skip must not
|
||||
// extend: a round after it could never reach a recovered portal.
|
||||
this.failedAt.set(key, this.sourceFailures.get(source)?.at ?? now);
|
||||
} else {
|
||||
this.recordFailure(key, source);
|
||||
}
|
||||
// A failed refresh keeps the list it had.
|
||||
if (this.entries().get(key)?.status !== 'loaded') {
|
||||
this.entries.update((entries) =>
|
||||
new Map(entries).set(key, { status: 'failed' })
|
||||
);
|
||||
}
|
||||
if (followUp) {
|
||||
// The password was corrected while the old one was being tried.
|
||||
this.request([followUp], this.rounds.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
private isTripped(source: string): boolean {
|
||||
const failures = this.sourceFailures.get(source);
|
||||
return (
|
||||
!!failures &&
|
||||
failures.count >= SOURCE_FAILURES_TO_TRIP &&
|
||||
Date.now() - failures.at < DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
private recordFailure(key: string, source: string): void {
|
||||
const now = Date.now();
|
||||
this.failedAt.set(key, now);
|
||||
const failures = this.sourceFailures.get(source);
|
||||
this.sourceFailures.set(source, {
|
||||
count: (failures?.count ?? 0) + 1,
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,16 @@ rails also require the underlying data slice to have at least one item.
|
||||
newest unfinished title, a favourite (or recent) channel with a programme on
|
||||
air, and favourite / recently added titles. Contract:
|
||||
`docs/architecture/workspace-dashboard.md#cinematic-hero`.
|
||||
- `continueWatching` shows recent movies and series from
|
||||
`DashboardDataService.globalRecentVodItems()` using cover cards with playback
|
||||
progress when a saved resume position is available.
|
||||
- `continueWatching` shows recent movies and series the user has not finished,
|
||||
from `DashboardDataService.continueWatchingItems()`, using cover cards with
|
||||
playback progress when a saved resume position is available. A movie leaves
|
||||
the rail once its position reaches `PORTAL_WATCHED_PROGRESS_PERCENT` (90%,
|
||||
so the end credits count as watched). A series whose newest episode is
|
||||
watched stays with the episode it continues with (the first one not
|
||||
watched after the one watched last, from the portal's episode list) and
|
||||
leaves once none follows it. Extras (season 0) never change that. Finished
|
||||
titles stay in Global Recent. Contract:
|
||||
`docs/architecture/workspace-dashboard.md#data-flow`.
|
||||
- `liveFavorites` shows favorited live TV channels from
|
||||
`DashboardDataService.globalFavoriteLiveItems()` using the channel layout
|
||||
with EPG title, time range, and progress when XMLTV data is available.
|
||||
|
||||
+34
@@ -2,6 +2,7 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
PortalActivityItem,
|
||||
@@ -99,6 +100,7 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
let favoritesLoading: ReturnType<typeof signal<boolean>>;
|
||||
let addedLoading: ReturnType<typeof signal<boolean>>;
|
||||
let liveAwaiting: ReturnType<typeof signal<boolean>>;
|
||||
let positionsSettled: ReturnType<typeof signal<boolean>>;
|
||||
const positions = new Map<string | number, PlaybackPositionData>([
|
||||
[
|
||||
1,
|
||||
@@ -136,6 +138,17 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
globalRecentItems: recentItems,
|
||||
globalRecentVodItems: () =>
|
||||
recentItems().filter((i) => i.type !== 'live'),
|
||||
continueWatchingSettled: () => positionsSettled(),
|
||||
continueWatchingItems: () =>
|
||||
positionsSettled()
|
||||
? recentItems().filter(
|
||||
(i) =>
|
||||
i.type !== 'live' &&
|
||||
!isPortalPlaybackWatched(
|
||||
positions.get(i.id) ?? null
|
||||
)
|
||||
)
|
||||
: [],
|
||||
globalFavoriteItems: favorites,
|
||||
xtreamRecentlyAddedItems: addedItems,
|
||||
getPlaybackPositionForItem: (
|
||||
@@ -209,6 +222,7 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
favoritesLoading = signal(false);
|
||||
addedLoading = signal(false);
|
||||
liveAwaiting = signal(false);
|
||||
positionsSettled = signal(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -479,6 +493,26 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
expect(create().loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the skeleton until playback positions tell finished titles apart', () => {
|
||||
// History only: until the positions arrive the newest row could be a
|
||||
// finished title, so neither a resume slide nor the history fallback
|
||||
// may stand in for the slide that follows.
|
||||
recentItems.set([watchedMovie, series]);
|
||||
favorites.set([]);
|
||||
candidates.set([]);
|
||||
addedItems.set([]);
|
||||
positionsSettled.set(false);
|
||||
const presenter = create();
|
||||
expect(presenter.slides()).toEqual([]);
|
||||
expect(presenter.loading()).toBe(true);
|
||||
|
||||
positionsSettled.set(true);
|
||||
expect(
|
||||
presenter.slides().map((slide) => [slide.kind, slide.title])
|
||||
).toEqual([['continue', 'Big Pharma']]);
|
||||
expect(presenter.loading()).toBe(false);
|
||||
});
|
||||
|
||||
describe('with a live channel as the only candidate', () => {
|
||||
let onAirNow: ReturnType<typeof signal<boolean>>;
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
isPortalPlaybackWatched,
|
||||
splitSeasonSuffix,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { splitSeasonSuffix } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
playlistDisplayLabel,
|
||||
resolvePortalActivityWatchKind,
|
||||
@@ -107,6 +104,7 @@ export class DashboardHeroSlidesPresenter {
|
||||
source: () =>
|
||||
this.slides().length === 0 &&
|
||||
(this.data.globalRecentLoading() ||
|
||||
!this.data.continueWatchingSettled() ||
|
||||
this.data.globalFavoritesLoading() ||
|
||||
this.data.xtreamRecentlyAddedLoading() ||
|
||||
(!this.liveAnswerWaitOver() &&
|
||||
@@ -128,14 +126,7 @@ export class DashboardHeroSlidesPresenter {
|
||||
|
||||
private readonly sources = computed(() =>
|
||||
pickDashboardHeroSources({
|
||||
continueItems: this.data
|
||||
.globalRecentVodItems()
|
||||
.filter(
|
||||
(item) =>
|
||||
!isPortalPlaybackWatched(
|
||||
this.data.getPlaybackPositionForItem(item)
|
||||
)
|
||||
),
|
||||
continueItems: this.data.continueWatchingItems(),
|
||||
live: this.liveSlide()?.candidate ?? null,
|
||||
reserveLive: this.liveEpg.heroLiveCandidates().length > 0,
|
||||
favorites: this.data
|
||||
@@ -144,7 +135,11 @@ export class DashboardHeroSlidesPresenter {
|
||||
(item) => item.type === 'movie' || item.type === 'series'
|
||||
),
|
||||
recentlyAdded: this.data.xtreamRecentlyAddedItems(),
|
||||
mostRecent: this.data.globalRecentItems()[0] ?? null,
|
||||
// Until the resume candidates are known the newest row may be one
|
||||
// of them; featuring it as the fallback would swap the slide.
|
||||
mostRecent: this.data.continueWatchingSettled()
|
||||
? (this.data.globalRecentItems()[0] ?? null)
|
||||
: null,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -160,6 +160,11 @@ type DashboardLiveFavoriteRailSettings = Pick<
|
||||
|
||||
export interface DashboardRecentContentSkeletonInput {
|
||||
readonly continueWatchingCount: number;
|
||||
/**
|
||||
* Continue Watching waits for the history and, to tell finished titles
|
||||
* apart, for their playback positions; the live rail only for the history.
|
||||
*/
|
||||
readonly continueWatchingLoading: boolean;
|
||||
readonly globalRecentLoading: boolean;
|
||||
readonly recentLiveCount: number;
|
||||
}
|
||||
@@ -168,13 +173,13 @@ export function shouldShowRecentContentSkeleton(
|
||||
rails: DashboardRecentRailSettings,
|
||||
input: DashboardRecentContentSkeletonInput
|
||||
): boolean {
|
||||
if (!input.globalRecentLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(rails.continueWatching && input.continueWatchingCount === 0) ||
|
||||
(rails.recentlyWatchedLive && input.recentLiveCount === 0)
|
||||
(rails.continueWatching &&
|
||||
input.continueWatchingLoading &&
|
||||
input.continueWatchingCount === 0) ||
|
||||
(rails.recentlyWatchedLive &&
|
||||
input.globalRecentLoading &&
|
||||
input.recentLiveCount === 0)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
<lib-dashboard-rail
|
||||
[label]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
|
||||
[items]="continueWatchingCards()"
|
||||
[totalCount]="data.globalRecentVodItems().length"
|
||||
[totalCount]="data.continueWatchingItems().length"
|
||||
[seeAllLink]="['/workspace/global-recent']"
|
||||
[seeAllState]="continueWatchingSeeAllState()"
|
||||
[testId]="'dashboard-continue-watching-rail'"
|
||||
|
||||
+39
@@ -607,6 +607,7 @@ describe('recent content skeleton helper', () => {
|
||||
},
|
||||
{
|
||||
continueWatchingCount: 0,
|
||||
continueWatchingLoading: true,
|
||||
recentLiveCount: 0,
|
||||
globalRecentLoading: true,
|
||||
}
|
||||
@@ -623,6 +624,7 @@ describe('recent content skeleton helper', () => {
|
||||
},
|
||||
{
|
||||
continueWatchingCount: 0,
|
||||
continueWatchingLoading: true,
|
||||
recentLiveCount: 0,
|
||||
globalRecentLoading: true,
|
||||
}
|
||||
@@ -630,6 +632,32 @@ describe('recent content skeleton helper', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the Continue Watching skeleton while playback positions load after the history', () => {
|
||||
const waitingForPositions = {
|
||||
continueWatchingCount: 0,
|
||||
continueWatchingLoading: true,
|
||||
recentLiveCount: 0,
|
||||
globalRecentLoading: false,
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldShowRecentContentSkeleton(
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
waitingForPositions
|
||||
)
|
||||
).toBe(true);
|
||||
// The live rail only waits for the history itself.
|
||||
expect(
|
||||
shouldShowRecentContentSkeleton(
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
continueWatching: false,
|
||||
},
|
||||
waitingForPositions
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the loading skeleton when no enabled recent rail is waiting for data', () => {
|
||||
expect(
|
||||
shouldShowRecentContentSkeleton(
|
||||
@@ -640,6 +668,7 @@ describe('recent content skeleton helper', () => {
|
||||
},
|
||||
{
|
||||
continueWatchingCount: 0,
|
||||
continueWatchingLoading: true,
|
||||
recentLiveCount: 0,
|
||||
globalRecentLoading: true,
|
||||
}
|
||||
@@ -649,10 +678,20 @@ describe('recent content skeleton helper', () => {
|
||||
expect(
|
||||
shouldShowRecentContentSkeleton(DEFAULT_DASHBOARD_RAILS_SETTINGS, {
|
||||
continueWatchingCount: 1,
|
||||
continueWatchingLoading: true,
|
||||
recentLiveCount: 1,
|
||||
globalRecentLoading: true,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowRecentContentSkeleton(DEFAULT_DASHBOARD_RAILS_SETTINGS, {
|
||||
continueWatchingCount: 0,
|
||||
continueWatchingLoading: false,
|
||||
recentLiveCount: 0,
|
||||
globalRecentLoading: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -147,7 +147,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
readonly continueWatchingBaseCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
.globalRecentVodItems()
|
||||
.continueWatchingItems()
|
||||
.filter(isContinueWatchingRecentItem)
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
.map((item) => this.toRecentCard(item));
|
||||
@@ -180,6 +180,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
readonly showRecentContentSkeleton = computed(() =>
|
||||
shouldShowRecentContentSkeleton(this.dashboardRails(), {
|
||||
continueWatchingCount: this.continueWatchingCards().length,
|
||||
continueWatchingLoading: !this.data.continueWatchingSettled(),
|
||||
globalRecentLoading: this.data.globalRecentLoading(),
|
||||
recentLiveCount: this.recentLiveCards().length,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user