mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:17:41 -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
@@ -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.
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
+10
-32
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user