diff --git a/.changes/m3u-favorites-hydration.md b/.changes/m3u-favorites-hydration.md new file mode 100644 index 000000000..97d9deb64 --- /dev/null +++ b/.changes/m3u-favorites-hydration.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a2e3b4021..9b5b422fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 08c5dd84e..a376abbd3 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index d2ea2baee..9fcdaf2ca 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -71,6 +71,7 @@ export const FavoritesActions = createActionGroup({ events: { 'Update Favorites': props<{ channel: Channel }>(), 'Set Favorites': props<{ channelIds: string[] }>(), + 'Hydrate Favorites': props<{ channelIds: string[] }>(), }, }); diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts new file mode 100644 index 000000000..25d781018 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.spec.ts @@ -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', + ]); + }); +}); diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts index 4e83e0947..12bb4a383 100644 --- a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts @@ -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, + }, }, }, - }, - }; - }), + }; + } + ), ]; 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 cee66647b..4581781d4 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,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(); const secondResponse = new Subject(); @@ -225,7 +271,7 @@ describe('M3uWorkspaceRouteSession', () => { [ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })], ]); expect(store.dispatch).toHaveBeenCalledWith( - FavoritesActions.setFavorites({ + FavoritesActions.hydrateFavorites({ channelIds: [NEXT_CHANNEL.url], }) ); 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 09d8ed28b..0db0dcd87 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 @@ -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: [] }) ); } }