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

@@ -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;
}