From e822156d9239177ea143d17bebddedfa5d0b067d Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 08:48:05 +0200 Subject: [PATCH] fix(m3u): wait for the playlist entity before hydrating hidden groups The startup inventory publishes playlists only after settings and EPG reconciliation, while a restored or deep-linked playlist route waits for the metadata read alone. The route session's full read could therefore finish first, and updateOne dropped the hydration for the missing entity for the whole session. The hydration now waits for the entity. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/m3u-playlist-module.md | 4 ++ ...orkspace-route-session.performance.spec.ts | 1 + ...3u-workspace-route-session.service.spec.ts | 51 ++++++++++++++++--- .../m3u-workspace-route-session.service.ts | 49 +++++++++++------- 4 files changed, 80 insertions(+), 25 deletions(-) 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(