From 05fc8f9a2e8d72242d13449a8793ea88481aa060 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:01:59 +0200 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5.5 --- ...ashboard-continue-watching-startup-wait.md | 6 + docs/architecture/workspace-dashboard.md | 20 ++- .../src/lib/dashboard-data.service.spec.ts | 67 ++++++++ .../src/lib/dashboard-data.service.ts | 155 ++++-------------- .../src/lib/dashboard-playback-positions.ts | 141 ++++++++++++++++ .../lib/rails/dashboard-tmdb-rails.spec.ts | 101 ++++++++++++ .../src/lib/rails/dashboard-tmdb-rails.ts | 74 +++++++++ .../workspace-dashboard-rails.component.ts | 42 ++--- 8 files changed, 443 insertions(+), 163 deletions(-) create mode 100644 .changes/dashboard-continue-watching-startup-wait.md create mode 100644 libs/workspace/dashboard/data-access/src/lib/dashboard-playback-positions.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.ts diff --git a/.changes/dashboard-continue-watching-startup-wait.md b/.changes/dashboard-continue-watching-startup-wait.md new file mode 100644 index 000000000..67a396e82 --- /dev/null +++ b/.changes/dashboard-continue-watching-startup-wait.md @@ -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. diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index f09271da9..9ecd4a49c 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 692f720f9..8e9dbc518 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -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 void>(); + playbackPositionsMock.getAllPlaybackPositions.mockImplementation( + (playlistId: string) => + new Promise((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(), diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index d00d483e9..80f52340d 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 - >(new Map()); - private readonly playbackPositionsBySeriesMap = signal< - Map - >(new Map()); - /** Every episode row per series: what comes next depends on all of them. */ - private readonly seriesEpisodePositionsMap = signal< - Map - >(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 { // 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(); - const nextBySeries = new Map(); - const nextEpisodesBySeries = new Map(); - 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); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-playback-positions.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-playback-positions.ts new file mode 100644 index 000000000..c1d14cf82 --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-playback-positions.ts @@ -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; +} + +/** The dashboard's playback positions, indexed the ways its cards read them. */ +export interface DashboardPlaybackPositionMaps { + /** By `${playlistId}::${contentXtreamId}::${contentType}`. */ + readonly byContent: Map; + /** A series' newest episode row, by `${playlistId}::${seriesXtreamId}`. */ + readonly newestBySeries: Map; + /** Every episode row of a series: what comes next depends on all of them. */ + readonly episodesBySeries: Map; +} + +// 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 +): Promise { + 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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.spec.ts new file mode 100644 index 000000000..083b070d1 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.spec.ts @@ -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([]), + }; + 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); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.ts new file mode 100644 index 000000000..d076c700e --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-tmdb-rails.ts @@ -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 }; + readonly recommendationsService: { load(): Promise }; +} + +/** + * 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 +): 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()); + }); +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 70c28adfd..1eebb8e7d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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, }); }