mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user