mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(m3u): avoid persisting hydrated favorites
This commit is contained in:
1 parent
4e5132cbb5
commit
643160fbc2
8 files changed
+126
-26
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
+48
-2
@@ -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: [] })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user