perf(dashboard): load playback positions at once and start TMDB rails after Continue Watching settles (#1877)

Continue Watching and the hero gate on every playlist's playback positions (#1841). Those were requested one playlist after another and queued behind the TMDB rails' batched title match, which held the DB worker for up to 95 s on a 3.9M-title library. The positions are now loaded for all playlists at once (dashboard-playback-positions.ts), and the trending and recommendation rails start only after Continue Watching has settled (rails/dashboard-tmdb-rails.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-11 09:01:59 +02:00
1 parent 7d7d691ce4
commit 05fc8f9a2e
8 files changed
+443 -163

No files matched your search

@@ -0,0 +1,6 @@
---
type: perf
area: dashboard
---
Continue Watching and the hero banner no longer sit on a skeleton for many seconds after startup on large libraries: the dashboard reads every playlist's watch progress in one go, and the TMDB trending and recommendation rails wait for it before they search the catalog.
+15 -5
View File
@@ -318,9 +318,12 @@ all, and none inside the hero.
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`
Portal playback positions are bulk-loaded per playlist
(`loadDashboardPlaybackPositions`), every playlist at once (one `Promise.all`, not one await per playlist: the
single-threaded DB worker then answers the set in one pass, and a
long request it is busy with delays the set once, not once 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
kind, `resolvePortalActivityWatchKind`, not its routing `type`. The
shape that needs the distinction is a Stalker embedded-VOD row: its
@@ -443,8 +446,15 @@ all, and none inside the hero.
5. `recommendationCards` / `trendingCards` — the two TMDB rails. Both
need the TMDB opt-in AND the Electron DB worker that answers
`DB_MATCH_TITLES` (each is hidden in the PWA), and both load after
`globalFavoritesLoaded()` so the batched title match never competes
for the worker at startup. `recommendationCards` is seeded from
`globalFavoritesLoaded()` AND `continueWatchingSettled()`
(`connectDashboardTmdbRails` in `rails/dashboard-tmdb-rails.ts`) so
the batched title match never competes
for the worker at startup: one match runs a ranked FTS query per
title and a common single word holds the worker for seconds on a
large catalog; issued before the playback positions, it queued them
behind it and kept the Continue Watching and hero skeletons up for
as long as it ran (measured 14 s warm and 97 s cold on a 3.9M-title
library). `recommendationCards` is seeded from
recently watched movies/series and only shows titles present in an
imported library, hiding itself below five matched cards; its rail
label names the seed ("Because you watched X") when exactly one
@@ -1108,6 +1108,73 @@ describe('DashboardDataService', () => {
});
});
it('asks every playlist for its playback positions at once, not one after another', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 101,
category_id: 18,
title: 'Atlantic City',
rating: '7.3',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/atlantic.png',
xtream_id: 4242,
type: 'movie',
playlist_id: 'xtream-A',
playlist_name: 'Xtream A',
},
{
id: 102,
category_id: 19,
title: 'Color Orchard S02E04',
rating: '8.1',
viewed_at: '2026-04-22T10:00:00.000Z',
poster_url: 'https://example.com/orchard.png',
xtream_id: 909,
type: 'series',
playlist_id: 'xtream-B',
playlist_name: 'Xtream B',
},
]);
// Hold every answer: asked one after another, the second playlist
// would not be asked until the first had answered, and a DB worker
// busy with a long request would delay the set once per playlist.
const pending = new Map<string, (rows: PlaybackPositionData[]) => void>();
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
(playlistId: string) =>
new Promise<PlaybackPositionData[]>((resolve) => {
pending.set(playlistId, resolve);
})
);
await service.reloadGlobalRecentItems();
const reload = service.reloadPlaybackPositions();
expect(new Set(pending.keys())).toEqual(
new Set(['xtream-A', 'xtream-B'])
);
pending.get('xtream-A')?.([
{
contentXtreamId: 4242,
contentType: 'vod',
positionSeconds: 3600,
durationSeconds: 6000,
playlistId: 'xtream-A',
} as PlaybackPositionData,
]);
pending.get('xtream-B')?.([]);
await reload;
const movie = service
.globalRecentVodItems()
.find((item) => item.playlist_id === 'xtream-A');
expect(movie).toBeDefined();
expect(
service.getPlaybackPositionForItem(movie as GlobalRecentItem)
).toMatchObject({ contentXtreamId: 4242, positionSeconds: 3600 });
});
it('resolves episode metadata for a Stalker VOD is_series recent item', async () => {
playlistsSignal.set([
...createDefaultPlaylists(),
@@ -66,55 +66,16 @@ import {
type DashboardContentKind,
} from './dashboard-navigation.util';
import { createDashboardContinueWatching } from './dashboard-continue-watching';
import {
emptyDashboardPlaybackPositionMaps,
loadDashboardPlaybackPositions,
newestPlaybackPosition,
playbackPositionMapKey,
seriesPlaybackPositionMapKey,
} from './dashboard-playback-positions';
export type { DashboardContentKind };
// Compound key for looking up a playback position by recent item — a single
// playlist can contain the same xtream-id for a VOD and an episode (rare,
// but the schema allows it), so contentType is part of the key.
function playbackPositionMapKey(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): string {
return `${playlistId}::${contentXtreamId}::${contentType}`;
}
function seriesPlaybackPositionMapKey(
playlistId: string,
seriesXtreamId: number
): string {
return `${playlistId}::${seriesXtreamId}`;
}
function newestPlaybackPosition(
current: PlaybackPositionData | null | undefined,
candidate: PlaybackPositionData | null | undefined
): PlaybackPositionData | null {
if (!current) {
return candidate ?? null;
}
if (!candidate) {
return current;
}
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. */
@@ -292,18 +253,13 @@ export class DashboardDataService {
// change) so the hero + Continue Watching cards can show "X min left"
// and a progress bar. Live channels never have positions; M3U content
// doesn't either. Map starts empty and degrades cleanly on missing data.
private readonly playbackPositionsMap = signal<
Map<string, PlaybackPositionData>
>(new Map());
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());
private readonly playbackPositionMaps = signal(
emptyDashboardPlaybackPositionMaps()
);
readonly playbackPositions$ = this.playbackPositionsMap.asReadonly();
readonly playbackPositions$ = computed(
() => this.playbackPositionMaps().byContent
);
/** Playlists whose rows the position maps hold; null before any load. */
private readonly playbackPositionPlaylistIds =
@@ -323,7 +279,7 @@ export class DashboardDataService {
playlists: this.playlists,
storedPosition: (item) => this.storedPlaybackPositionForItem(item),
episodeRows: (playlistId, seriesXtreamId) =>
this.seriesEpisodePositionsMap().get(
this.playbackPositionMaps().episodesBySeries.get(
seriesPlaybackPositionMapKey(playlistId, seriesXtreamId)
),
positionsLoadedFor: this.playbackPositionPlaylistIds,
@@ -371,7 +327,7 @@ export class DashboardDataService {
xtreamId,
'vod'
);
return this.playbackPositionsMap().get(key) ?? null;
return this.playbackPositionMaps().byContent.get(key) ?? null;
}
// Series recent_items rows carry either the series id (the series
@@ -379,21 +335,21 @@ export class DashboardDataService {
// the episode id. Match both shapes through keyed maps so card renders
// do not scan every saved playback position.
const episodePosition =
this.playbackPositionsMap().get(
this.playbackPositionMaps().byContent.get(
playbackPositionMapKey(item.playlist_id, xtreamId, 'episode')
) ?? null;
const seriesPosition =
this.playbackPositionsBySeriesMap().get(
this.playbackPositionMaps().newestBySeries.get(
seriesPlaybackPositionMapKey(item.playlist_id, xtreamId)
) ?? null;
return newestPlaybackPosition(episodePosition, seriesPosition);
}
/**
* Refresh the in-memory positions map for every playlist that owns at
* least one VOD/series recent item. Per-playlist bulk fetch is one IPC
* round-trip each (vs N+1 per content item), so this stays cheap even
* on heavy libraries.
* Refresh the in-memory positions for every playlist that owns at least
* one VOD/series recent item (`loadDashboardPlaybackPositions`: one bulk
* IPC per playlist, all at once). Continue Watching and the hero wait
* for this set before they render.
*/
async reloadPlaybackPositions(): Promise<void> {
// Latest reload wins: an older one finishing last would put back the
@@ -405,72 +361,19 @@ export class DashboardDataService {
playlistIds.add(item.playlist_id);
}
}
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 =
await this.playbackPositions.getAllPlaybackPositions(
playlistId
);
for (const position of positions) {
next.set(
playbackPositionMapKey(
playlistId,
position.contentXtreamId,
position.contentType
),
position
);
if (
position.contentType === 'episode' &&
Number.isFinite(position.seriesXtreamId)
) {
const seriesKey = seriesPlaybackPositionMapKey(
playlistId,
position.seriesXtreamId as number
);
nextBySeries.set(
seriesKey,
newestPlaybackPosition(
nextBySeries.get(seriesKey),
position
) as PlaybackPositionData
);
const seriesRows = nextEpisodesBySeries.get(seriesKey);
if (seriesRows) {
seriesRows.push(position);
} else {
nextEpisodesBySeries.set(seriesKey, [position]);
}
}
}
} catch (err) {
console.warn(
'[DashboardData] Failed to load playback positions for playlist',
playlistId,
err
);
}
}
const maps =
playlistIds.size === 0
? emptyDashboardPlaybackPositionMaps()
: await loadDashboardPlaybackPositions(
this.playbackPositions,
playlistIds
);
if (generation !== this.playbackPositionsLoadGeneration) {
return;
}
this.ngZone.run(() => {
this.playbackPositionsMap.set(next);
this.playbackPositionsBySeriesMap.set(nextBySeries);
this.seriesEpisodePositionsMap.set(nextEpisodesBySeries);
this.playbackPositionMaps.set(maps);
// A playlist whose load failed counts as loaded: its titles
// show without progress rather than holding the rail back.
this.playbackPositionPlaylistIds.set(playlistIds);
@@ -0,0 +1,141 @@
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
/** What the loader reads positions through (PORTAL_PLAYBACK_POSITIONS). */
export interface DashboardPlaybackPositionSource {
getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]>;
}
/** The dashboard's playback positions, indexed the ways its cards read them. */
export interface DashboardPlaybackPositionMaps {
/** By `${playlistId}::${contentXtreamId}::${contentType}`. */
readonly byContent: Map<string, PlaybackPositionData>;
/** A series' newest episode row, by `${playlistId}::${seriesXtreamId}`. */
readonly newestBySeries: Map<string, PlaybackPositionData>;
/** Every episode row of a series: what comes next depends on all of them. */
readonly episodesBySeries: Map<string, PlaybackPositionData[]>;
}
// Compound key for looking up a playback position by recent item — a single
// playlist can contain the same xtream-id for a VOD and an episode (rare,
// but the schema allows it), so contentType is part of the key.
export function playbackPositionMapKey(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): string {
return `${playlistId}::${contentXtreamId}::${contentType}`;
}
export function seriesPlaybackPositionMapKey(
playlistId: string,
seriesXtreamId: number
): string {
return `${playlistId}::${seriesXtreamId}`;
}
export function newestPlaybackPosition(
current: PlaybackPositionData | null | undefined,
candidate: PlaybackPositionData | null | undefined
): PlaybackPositionData | null {
if (!current) {
return candidate ?? null;
}
if (!candidate) {
return current;
}
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)
);
}
export function emptyDashboardPlaybackPositionMaps(): DashboardPlaybackPositionMaps {
return {
byContent: new Map(),
newestBySeries: new Map(),
episodesBySeries: new Map(),
};
}
/**
* Loads the playback positions of every given playlist, all at once: one
* bulk IPC per playlist, all dispatched together, so the single-threaded DB
* worker answers the set in one pass and a long request it is busy with (a
* catalog import, a TMDB title match) delays the set once rather than once
* per playlist. A playlist whose load fails contributes no rows: its titles
* show without progress rather than holding the rail back.
*/
export async function loadDashboardPlaybackPositions(
source: DashboardPlaybackPositionSource,
playlistIds: Iterable<string>
): Promise<DashboardPlaybackPositionMaps> {
const loaded = await Promise.all(
[...playlistIds].map(async (playlistId) => {
try {
return {
playlistId,
positions: await source.getAllPlaybackPositions(playlistId),
};
} catch (err) {
console.warn(
'[DashboardData] Failed to load playback positions for playlist',
playlistId,
err
);
return { playlistId, positions: [] };
}
})
);
const maps = emptyDashboardPlaybackPositionMaps();
for (const { playlistId, positions } of loaded) {
for (const position of positions) {
maps.byContent.set(
playbackPositionMapKey(
playlistId,
position.contentXtreamId,
position.contentType
),
position
);
if (
position.contentType !== 'episode' ||
!Number.isFinite(position.seriesXtreamId)
) {
continue;
}
const seriesKey = seriesPlaybackPositionMapKey(
playlistId,
position.seriesXtreamId as number
);
maps.newestBySeries.set(
seriesKey,
newestPlaybackPosition(
maps.newestBySeries.get(seriesKey),
position
) as PlaybackPositionData
);
const seriesRows = maps.episodesBySeries.get(seriesKey);
if (seriesRows) {
seriesRows.push(position);
} else {
maps.episodesBySeries.set(seriesKey, [position]);
}
}
}
return maps;
}
@@ -0,0 +1,101 @@
import {
EnvironmentInjector,
createEnvironmentInjector,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
connectDashboardTmdbRails,
type DashboardTmdbRailsHost,
} from './dashboard-tmdb-rails';
function connect() {
const state = {
trending: signal(true),
recommendations: signal(true),
favoritesLoaded: signal(false),
continueWatchingSettled: signal(false),
recentVod: signal<unknown[]>([]),
};
const trendingLoad = jest.fn(() => Promise.resolve());
const recommendationsLoad = jest.fn(() => Promise.resolve());
const host: DashboardTmdbRailsHost = {
dashboardRails: () => ({
tmdbTrending: state.trending(),
tmdbRecommendations: state.recommendations(),
}),
languageTick: () => 0,
data: {
globalFavoritesLoaded: () => state.favoritesLoaded(),
continueWatchingSettled: () => state.continueWatchingSettled(),
globalRecentVodItems: () => state.recentVod(),
globalFavoriteItems: () => [],
playlists: () => [],
},
trendingService: { load: trendingLoad },
recommendationsService: { load: recommendationsLoad },
};
const injector = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
injector.runInContext(() => connectDashboardTmdbRails(host));
TestBed.tick();
return { state, trendingLoad, recommendationsLoad, injector };
}
describe('connectDashboardTmdbRails', () => {
it('keeps both title matches back until Continue Watching has settled', () => {
const { state, trendingLoad, recommendationsLoad } = connect();
state.favoritesLoaded.set(true);
TestBed.tick();
// Favorites alone used to start the matches, which then held the
// DB worker while the playback positions waited behind them.
expect(trendingLoad).not.toHaveBeenCalled();
expect(recommendationsLoad).not.toHaveBeenCalled();
state.continueWatchingSettled.set(true);
TestBed.tick();
expect(trendingLoad).toHaveBeenCalledTimes(1);
expect(recommendationsLoad).toHaveBeenCalledTimes(1);
});
it('waits for the favorites as well', () => {
const { state, trendingLoad, recommendationsLoad } = connect();
state.continueWatchingSettled.set(true);
TestBed.tick();
expect(trendingLoad).not.toHaveBeenCalled();
expect(recommendationsLoad).not.toHaveBeenCalled();
});
it('never loads a rail the user turned off', () => {
const { state, trendingLoad, recommendationsLoad } = connect();
state.trending.set(false);
state.recommendations.set(false);
state.favoritesLoaded.set(true);
state.continueWatchingSettled.set(true);
TestBed.tick();
expect(trendingLoad).not.toHaveBeenCalled();
expect(recommendationsLoad).not.toHaveBeenCalled();
});
it('reloads recommendations when the history changes, trending only once', () => {
const { state, trendingLoad, recommendationsLoad } = connect();
state.favoritesLoaded.set(true);
state.continueWatchingSettled.set(true);
TestBed.tick();
state.recentVod.set([{ id: 1 }]);
TestBed.tick();
expect(trendingLoad).toHaveBeenCalledTimes(1);
expect(recommendationsLoad).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,74 @@
import { effect, untracked } from '@angular/core';
import type { DashboardRailsSettings } from '@iptvnator/shared/interfaces';
/**
* What the TMDB rails' loading reads from the dashboard; the rails
* component satisfies it structurally, so the gate and its effects live
* here rather than in that (already oversized) component.
*/
export interface DashboardTmdbRailsHost {
readonly dashboardRails: () => Pick<
DashboardRailsSettings,
'tmdbTrending' | 'tmdbRecommendations'
>;
readonly languageTick: () => unknown;
readonly data: {
readonly globalFavoritesLoaded: () => boolean;
readonly continueWatchingSettled: () => boolean;
readonly globalRecentVodItems: () => unknown;
readonly globalFavoriteItems: () => unknown;
readonly playlists: () => unknown;
};
readonly trendingService: { load(): Promise<void> };
readonly recommendationsService: { load(): Promise<void> };
}
/**
* Whether a TMDB rail may start loading. Its batched title match
* (`DB_MATCH_TITLES`) holds the single-threaded DB worker for seconds per
* common word on a large catalog, so it waits until the dashboard's own
* reads are through: the favorites, and the per-playlist playback positions
* that Continue Watching and the hero gate on. A match issued before those
* positions queues them behind it and keeps both skeletons up for as long
* as the match runs.
*/
export function shouldLoadTmdbRail(
enabled: boolean,
host: Pick<DashboardTmdbRailsHost, 'data'>
): boolean {
return (
enabled &&
host.data.globalFavoritesLoaded() &&
host.data.continueWatchingSettled()
);
}
/** Starts the TMDB rails' loads when allowed. Call from an injection context. */
export function connectDashboardTmdbRails(host: DashboardTmdbRailsHost): void {
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
effect(() => {
if (!shouldLoadTmdbRail(host.dashboardRails().tmdbTrending, host)) {
return;
}
untracked(() => void host.trendingService.load());
});
// Recommendations rail: same gating as trending, plus tracked reads of
// the seed source, the favorites (both feed the exclusion set) and the
// playlist set (feeds the catalog key) so a newly watched/favorited
// title or an imported/deleted playlist re-runs the load — the service
// keys loads by seed + exclusion + catalog set and skips no-ops.
effect(() => {
if (
!shouldLoadTmdbRail(host.dashboardRails().tmdbRecommendations, host)
) {
return;
}
host.data.globalRecentVodItems();
host.data.globalFavoriteItems();
host.data.playlists();
// Language feeds the service's load key (localized payloads)
host.languageTick();
untracked(() => void host.recommendationsService.load());
});
}
@@ -50,6 +50,7 @@ import {
resolveSourceExpiryBadge,
} from '@iptvnator/workspace/dashboard/data-access';
import { createDashboardRailSkeletons } from './dashboard-rail-skeletons';
import { connectDashboardTmdbRails } from './dashboard-tmdb-rails';
import { DashboardRailComponent } from './dashboard-rail.component';
import { DashboardRailSkeletonComponent } from './dashboard-rail-skeleton.component';
import type {
@@ -354,38 +355,15 @@ export class WorkspaceDashboardRailsComponent {
untracked(() => void this.sourceExpiry.refresh(playlists));
});
// Trending rail: needs the TMDB opt-in and the Electron DB worker.
// Deferred until the dashboard's own recent/favorites data is in so
// the batched title match never competes for the worker at startup.
effect(() => {
if (
!this.dashboardRails().tmdbTrending ||
!this.data.globalFavoritesLoaded()
) {
return;
}
untracked(() => void this.trendingService.load());
});
// Recommendations rail: same gating as trending, plus tracked
// reads of the seed source, the favorites (both feed the
// exclusion set) and the playlist set (feeds the catalog key) so
// a newly watched/favorited title or an imported/deleted playlist
// re-runs the load — the service keys loads by seed + exclusion +
// catalog set and skips no-ops.
effect(() => {
if (
!this.dashboardRails().tmdbRecommendations ||
!this.data.globalFavoritesLoaded()
) {
return;
}
this.data.globalRecentVodItems();
this.data.globalFavoriteItems();
this.data.playlists();
// Language feeds the service's load key (localized payloads)
this.languageTick();
untracked(() => void this.recommendationsService.load());
// Trending and recommendations: after the favorites and Continue
// Watching, so their title match never holds the DB worker while
// the playback positions wait (see connectDashboardTmdbRails).
connectDashboardTmdbRails({
dashboardRails: this.dashboardRails,
languageTick: this.languageTick,
data: this.data,
trendingService: this.trendingService,
recommendationsService: this.recommendationsService,
});
}