diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index d8bc102ea..a291772e8 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -638,6 +638,10 @@ channel-list-container/ restart. `M3uWorkspaceRouteSession` copies it from the full playlist read into the store (`updatePlaylistMeta` with `persist: false`) before publishing the channels, unless the store value changed while that read was queued (#1894). + A restored or deep-linked route can finish that read before the startup + inventory, which waits for settings and EPG reconciliation, publishes the + playlist; the hydration then waits for the entity, since `updateOne` drops an + update for a missing one. - The groups route keeps the manage action reachable even when every group is hidden by separating "playlist has no groups" from "no visible/search-matching groups" empty states. diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts index 5984de01f..3fe831e86 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts @@ -33,6 +33,7 @@ describe('M3uWorkspaceRouteSession performance phases', () => { const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); const store = { dispatch: jest.fn(), + select: jest.fn(() => of({})), selectSignal: jest.fn(() => signal({})), }; diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts index 9a340703b..154c90443 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -1,7 +1,6 @@ -import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { of, Subject, throwError } from 'rxjs'; +import { BehaviorSubject, of, Subject, throwError } from 'rxjs'; import { ChannelActions, FavoritesActions, @@ -81,12 +80,13 @@ describe('M3uWorkspaceRouteSession', () => { const playlistsService = { getPlaylist: jest.fn(), }; - const playlistEntities = signal>( - {} - ); + const playlistEntities = new BehaviorSubject< + Record + >({}); const store = { dispatch: jest.fn(), - selectSignal: jest.fn(() => playlistEntities.asReadonly()), + select: jest.fn(() => playlistEntities.asObservable()), + selectSignal: jest.fn(() => () => playlistEntities.value), }; const electronApi = { setUserAgent: jest.fn().mockResolvedValue(true), @@ -108,7 +108,7 @@ describe('M3uWorkspaceRouteSession', () => { electronApi.setUserAgent.mockClear(); playlistsService.getPlaylist.mockReset(); store.dispatch.mockClear(); - playlistEntities.set({ + playlistEntities.next({ [PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta, }); @@ -273,7 +273,7 @@ describe('M3uWorkspaceRouteSession', () => { // The dialog's save lands in the store before the queued read returns // the row as it was before that save. - playlistEntities.set({ + playlistEntities.next({ [PLAYLIST_ID]: { _id: PLAYLIST_ID, hiddenGroupTitles: ['News'], @@ -297,6 +297,41 @@ describe('M3uWorkspaceRouteSession', () => { ).toBe(false); }); + it('waits for the startup inventory when a restored route reads the playlist first', async () => { + playlistEntities.next({}); + playlistsService.getPlaylist.mockReturnValue( + of({ + hiddenGroupTitles: ['Sports'], + playlist: { items: [PRIMARY_CHANNEL] }, + } as Playlist) + ); + const hydration = PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: PLAYLIST_ID, + hiddenGroupTitles: ['Sports'], + } as PlaylistMeta, + persist: false, + }); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + // An update for an entity that is not in the store yet is dropped. + expect(store.dispatch).not.toHaveBeenCalledWith(hydration); + + playlistEntities.next({ + [PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta, + }); + + expect(store.dispatch).toHaveBeenCalledWith(hydration); + expect( + store.dispatch.mock.calls.filter( + ([action]) => + action.type === PlaylistActions.updatePlaylistMeta.type + ) + ).toHaveLength(1); + }); + it('clears in-memory favorites without dispatching persistence when playlist loading fails', async () => { playlistsService.getPlaylist.mockReturnValue( throwError(() => new Error('Playlist loading failed')) diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index a8ada978c..334050945 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -18,7 +18,7 @@ import { measureRendererPerformancePhase, RENDERER_PERFORMANCE_PHASE, } from '@iptvnator/shared/logging'; -import { filter, firstValueFrom } from 'rxjs'; +import { filter, firstValueFrom, map, Subscription, take } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; @@ -39,6 +39,7 @@ export class M3uWorkspaceRouteSession { private currentPlaylistId: string | null = null; private currentSection: string | null = null; private loadRequestId = 0; + private pendingHiddenGroupsHydration: Subscription | null = null; constructor() { this.router.events @@ -166,28 +167,42 @@ export class M3uWorkspaceRouteSession { * leave out the hidden groups stored in the payload (#1894). Published * before the channels so the groups rail never shows them; a save made * while the read was queued is newer and stays. + * + * A restored or deep-linked route can finish this read before the startup + * inventory publishes the playlist (that waits for settings and EPG + * reconciliation), and `updateOne` drops an update for a missing entity, + * so the hydration waits for it. */ private hydrateHiddenGroupTitles( playlistId: string, storedHiddenGroupTitles: string[] | undefined, hiddenGroupTitlesBeforeRead: string[] | undefined ): void { - if ( - this.playlistEntities()[playlistId]?.hiddenGroupTitles !== - hiddenGroupTitlesBeforeRead - ) { - return; - } - - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { - _id: playlistId, - hiddenGroupTitles: storedHiddenGroupTitles ?? [], - } as PlaylistMeta, - persist: false, - }) - ); + this.pendingHiddenGroupsHydration?.unsubscribe(); + this.pendingHiddenGroupsHydration = this.store + .select(selectPlaylistEntities) + .pipe( + map((entities) => entities[playlistId]), + filter((playlist) => playlist !== undefined), + take(1), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((playlist) => { + if ( + playlist.hiddenGroupTitles !== hiddenGroupTitlesBeforeRead + ) { + return; + } + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlistId, + hiddenGroupTitles: storedHiddenGroupTitles ?? [], + } as PlaylistMeta, + persist: false, + }) + ); + }); } private isLoadedSection(