fix(m3u): avoid persisting hydrated favorites

This commit is contained in:
4gray committed 2026-07-25 22:14:43 +02:00
1 parent 4e5132cbb5
commit 643160fbc2
8 files changed
+126 -26

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: perf
area: m3u
---
Importing large M3U playlists is faster because IPTVnator no longer rewrites
the entire playlist when loading saved favorites.
+1 -1
View File
@@ -430,7 +430,7 @@ State management via NgRx (`libs/m3u-state/`):
- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive
- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
- `FavoritesActions`: updateFavorites, setFavorites
- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites
See `docs/architecture/m3u-playlist-module.md` for complete documentation.
+8 -2
View File
@@ -152,7 +152,7 @@ used by the groups view to remember which group titles the user has hidden.
| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD |
| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation |
| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state |
| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management |
| **FavoritesActions** | `updateFavorites`, `setFavorites`, `hydrateFavorites` | Favorites management |
| **FilterActions** | `setSelectedFilters` | Playlist type filtering |
### Key Selectors
@@ -250,6 +250,10 @@ channel-list-container/
- `M3uWorkspaceRouteSession` owns route-driven channel loading for the player/sidebar routes: `all` and `groups`.
- The route session sets `channelsLoading` before `getPlaylist()` resolves and clears it when `ChannelActions.setChannels` lands.
- The route session dispatches reducer-only `FavoritesActions.hydrateFavorites`
after that persisted read. Hydration must not use the persistence-bearing
`setFavorites` action: doing so reads and rewrites the complete M3U payload
again just to store favorites that already came from SQLite.
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
@@ -897,4 +901,6 @@ Routes live in `libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts
1. Dispatch `FavoritesActions.updateFavorites` for toggle
2. Dispatch `FavoritesActions.setFavorites` for reordering
3. Effects automatically persist to database
3. Dispatch `FavoritesActions.hydrateFavorites` only when copying values that
were already read from persistence into NgRx
4. Effects persist the two user-mutation actions; hydration is reducer-only
+1
View File
@@ -71,6 +71,7 @@ export const FavoritesActions = createActionGroup({
events: {
'Update Favorites': props<{ channel: Channel }>(),
'Set Favorites': props<{ channelIds: string[] }>(),
'Hydrate Favorites': props<{ channelIds: string[] }>(),
},
});
@@ -0,0 +1,36 @@
import { createReducer } from '@ngrx/store';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { FavoritesActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { initialState } from '../state';
import { favoritesReducers } from './favorites.reducers';
const reducer = createReducer(initialState, ...favoritesReducers);
describe('favoritesReducers', () => {
it('hydrates persisted favorites into state without using the persistence action', () => {
const playlist = {
_id: 'playlist-1',
title: 'Synthetic playlist',
favorites: ['stale-channel'],
} as PlaylistMeta;
const state = {
...initialState,
playlists: playlistsAdapter.addOne(playlist, {
...initialState.playlists,
selectedId: playlist._id,
}),
};
const nextState = reducer(
state,
FavoritesActions.hydrateFavorites({
channelIds: ['persisted-channel'],
})
);
expect(nextState.playlists.entities[playlist._id]?.favorites).toEqual([
'persisted-channel',
]);
});
});
@@ -32,26 +32,30 @@ export const favoritesReducers = [
},
};
}),
on(FavoritesActions.setFavorites, (state, action): PlaylistState => {
const selectedId = state.playlists.selectedId;
const playlist = state.playlists.entities[selectedId];
if (!selectedId || !playlist) {
return state;
}
on(
FavoritesActions.setFavorites,
FavoritesActions.hydrateFavorites,
(state, action): PlaylistState => {
const selectedId = state.playlists.selectedId;
const playlist = state.playlists.entities[selectedId];
if (!selectedId || !playlist) {
return state;
}
const { channelIds } = action;
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[selectedId]: {
...playlist,
favorites: channelIds,
const { channelIds } = action;
return {
...state,
playlists: {
...state.playlists,
entities: {
...state.playlists.entities,
[selectedId]: {
...playlist,
favorites: channelIds,
},
},
},
},
};
}),
};
}
),
];
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { of, Subject } from 'rxjs';
import { of, Subject, throwError } from 'rxjs';
import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from '@iptvnator/services';
@@ -180,6 +180,52 @@ describe('M3uWorkspaceRouteSession', () => {
);
});
it('hydrates persisted favorites without dispatching the persistence action', async () => {
playlistsService.getPlaylist.mockReturnValue(
of({
favorites: [PRIMARY_CHANNEL.url],
playlist: {
items: [PRIMARY_CHANNEL],
},
} as Playlist)
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(store.dispatch).toHaveBeenCalledWith(
FavoritesActions.hydrateFavorites({
channelIds: [PRIMARY_CHANNEL.url],
})
);
expect(
store.dispatch.mock.calls.some(
([action]) => action.type === FavoritesActions.setFavorites.type
)
).toBe(false);
});
it('clears in-memory favorites without dispatching persistence when playlist loading fails', async () => {
playlistsService.getPlaylist.mockReturnValue(
throwError(() => new Error('Playlist loading failed'))
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(store.dispatch).toHaveBeenCalledWith(
ChannelActions.setChannels({ channels: [] })
);
expect(store.dispatch).toHaveBeenCalledWith(
FavoritesActions.hydrateFavorites({ channelIds: [] })
);
expect(
store.dispatch.mock.calls.some(
([action]) => action.type === FavoritesActions.setFavorites.type
)
).toBe(false);
});
it('ignores stale playlist responses after a newer route request wins', async () => {
const firstResponse = new Subject<Playlist>();
const secondResponse = new Subject<Playlist>();
@@ -225,7 +271,7 @@ describe('M3uWorkspaceRouteSession', () => {
[ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })],
]);
expect(store.dispatch).toHaveBeenCalledWith(
FavoritesActions.setFavorites({
FavoritesActions.hydrateFavorites({
channelIds: [NEXT_CHANNEL.url],
})
);
@@ -117,7 +117,7 @@ export class M3uWorkspaceRouteSession {
(favorite): favorite is string => typeof favorite === 'string'
);
this.store.dispatch(
FavoritesActions.setFavorites({
FavoritesActions.hydrateFavorites({
channelIds: favorites,
})
);
@@ -128,7 +128,7 @@ export class M3uWorkspaceRouteSession {
this.store.dispatch(ChannelActions.setChannels({ channels: [] }));
this.store.dispatch(
FavoritesActions.setFavorites({ channelIds: [] })
FavoritesActions.hydrateFavorites({ channelIds: [] })
);
}
}