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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 08:48:05 +02:00
1 parent 5bd1812cba
commit e822156d92
4 files changed
+80 -25

No files matched your search

+4
View File
@@ -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.
@@ -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({})),
};
@@ -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<Record<string, PlaylistMeta | undefined>>(
{}
);
const playlistEntities = new BehaviorSubject<
Record<string, PlaylistMeta | undefined>
>({});
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'))
@@ -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(