mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(playlists): serialize per-playlist collection writes to prevent lost updates (#1255)
* fix(playlists): serialize per-playlist collection writes to prevent lost updates All per-playlist mutations (portal favorites, recently viewed, playlist meta/favorites updates) used an uncoordinated read -> patch -> replace-whole-row pattern, so two overlapping mutations on the same playlist were last-write-wins and silently dropped each other's changes (flagged by Greptile on PR #1253). Chain every read-modify-write through a per-playlist promise queue (Map<playlistId, Promise>) inside defer(), covering both the SQLite upsert and IndexedDB update paths while keeping the Observable-based public API, laziness, and emitted values unchanged. A failed mutation does not wedge the queue, and different playlists are not serialized against each other. Regression coverage: overlapping favorite+recently-viewed adds, two rapid favorite adds, IndexedDB-path overlap (all three fail on the old code), plus queue-continues-after-error and cross-playlist independence guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): close serialization gaps flagged by review bots Greptile P1 (writers bypassing the queue): route updateManyPlaylists and updatePlaylistPositions through the same per-playlist write queue. Auto-refresh batch writes now re-read the stored row inside the queue and preserve user-owned fields (favorites, recently viewed, position) instead of writing a pre-refresh snapshot over them; position updates re-read and patch inside the queue on both storage paths. Codex P1 (callers precompute stale snapshots): add an atomic PlaylistsService.transformPlaylistFavorites(playlistId, transform) that applies the favorites transform to the freshly-read row inside the queue, and convert every read-then-set call site to it: UnifiedFavoritesDataService M3U add/remove/clear/reorder and Stalker reorder/clear, GlobalFavoritesService M3U removal, DashboardDataService M3U removal. Reorders now keep concurrently added favorites (appended after the dragged order) instead of dropping them. New coverage: overlapping favorites transforms, auto-refresh batch write vs queued favorite add, position update vs queued favorite add, and a public addFavorite race through UnifiedFavoritesDataService; existing specs updated to the transform-based contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): share the canonical refresh merge with the auto-refresh batch The batch path previously spread the stale refresh snapshot over the freshly read row and pinned only favorites/recently-viewed/position, so a queued metadata mutation (hiddenGroupTitles, curated EPG sources) finishing before the refresh write could be reverted. Extract updatePlaylist's merge into mergeRefreshedPlaylist() and use it for both the single-playlist update flow and updateManyPlaylists: refresh-owned data (parsed content, count, EPG detection) comes from the payload, user-owned state comes from the current row, and manual/disabled EPG configuration is resolved through resolvePlaylistEpgSourceState instead of being overwritten. Regression test: queued hiddenGroupTitles meta update overlapping an auto-refresh batch write keeps both the metadata change and the refreshed content, including preserved manualEpgUrls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playlists): let the current row decide autoRefresh during batch refresh The batch path force-set autoRefresh: true after the merge, so disabling auto-refresh while a refresh was in flight was reverted by the completing write. Drop the override — mergeRefreshedPlaylist already prefers the current row's autoRefresh over the snapshot — and add a count fallback to the snapshot value for rows without a stored copy. Regression test: disabling auto-refresh concurrently with the batch write keeps autoRefresh false while still applying the refreshed content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
270350c2e1
commit
6bdd6fd8a8
7 files changed
+1092
-537
No files matched your search
@@ -148,7 +148,7 @@ describe('DashboardDataService', () => {
|
||||
const playlistsServiceMock = {
|
||||
getM3uFavoriteChannels: jest.fn().mockReturnValue(of(null)),
|
||||
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
|
||||
setFavorites: jest.fn().mockReturnValue(of(undefined)),
|
||||
transformPlaylistFavorites: jest.fn().mockReturnValue(of(playlistMock)),
|
||||
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(
|
||||
of({
|
||||
...playlistMock,
|
||||
@@ -193,8 +193,10 @@ describe('DashboardDataService', () => {
|
||||
playlistsServiceMock.getM3uFavoriteChannels.mockReturnValue(of(null));
|
||||
playlistsServiceMock.getPlaylistById.mockClear();
|
||||
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
|
||||
playlistsServiceMock.setFavorites.mockClear();
|
||||
playlistsServiceMock.setFavorites.mockReturnValue(of(undefined));
|
||||
playlistsServiceMock.transformPlaylistFavorites.mockClear();
|
||||
playlistsServiceMock.transformPlaylistFavorites.mockReturnValue(
|
||||
of(playlistMock)
|
||||
);
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear();
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue(
|
||||
of({
|
||||
@@ -751,10 +753,14 @@ describe('DashboardDataService', () => {
|
||||
|
||||
await service.removeGlobalFavorite(m3uItem!);
|
||||
|
||||
expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
['https://example.com/stream-2.m3u8']
|
||||
);
|
||||
expect(
|
||||
playlistsServiceMock.transformPlaylistFavorites
|
||||
).toHaveBeenCalledWith('m3u-1', expect.any(Function));
|
||||
const [, transform] =
|
||||
playlistsServiceMock.transformPlaylistFavorites.mock.calls[0];
|
||||
expect(
|
||||
transform(['channel-1', 'https://example.com/stream-2.m3u8'])
|
||||
).toEqual(['https://example.com/stream-2.m3u8']);
|
||||
});
|
||||
|
||||
it('removes PWA Xtream favorites through the active data source', async () => {
|
||||
@@ -1288,9 +1294,7 @@ describe('DashboardDataService', () => {
|
||||
|
||||
// Crucially, the movie favorite is excluded — the rail source must
|
||||
// never leak VOD posters into the channel layout.
|
||||
expect(liveOnly.map((it) => it.title)).not.toContain(
|
||||
'Favorite Movie'
|
||||
);
|
||||
expect(liveOnly.map((it) => it.title)).not.toContain('Favorite Movie');
|
||||
expect(
|
||||
service.globalFavoriteLiveItems().every((it) => it.type === 'live')
|
||||
).toBe(true);
|
||||
|
||||
@@ -1065,23 +1065,15 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlist_id)
|
||||
);
|
||||
const currentFavorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string'
|
||||
)
|
||||
: [];
|
||||
const filteredFavorites = currentFavorites.filter(
|
||||
(favorite) => favorite !== String(item.id)
|
||||
);
|
||||
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(
|
||||
this.playlistsService.transformPlaylistFavorites(
|
||||
item.playlist_id,
|
||||
filteredFavorites
|
||||
(current) =>
|
||||
current.filter(
|
||||
(favorite) =>
|
||||
typeof favorite !== 'string' ||
|
||||
favorite !== String(item.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
await this.reloadGlobalFavorites();
|
||||
@@ -1221,14 +1213,16 @@ export class DashboardDataService {
|
||||
const fallbackTimestamp =
|
||||
this.getM3uFavoriteTimestamp(playlistMeta) ??
|
||||
new Date(0).toISOString();
|
||||
const items = resolvedChannels.slice().map((favorite) =>
|
||||
this.createM3uFavoriteItem(
|
||||
playlistMeta,
|
||||
favorite.favoriteId,
|
||||
favorite.channel,
|
||||
fallbackTimestamp
|
||||
)
|
||||
);
|
||||
const items = resolvedChannels
|
||||
.slice()
|
||||
.map((favorite) =>
|
||||
this.createM3uFavoriteItem(
|
||||
playlistMeta,
|
||||
favorite.favoriteId,
|
||||
favorite.channel,
|
||||
fallbackTimestamp
|
||||
)
|
||||
);
|
||||
|
||||
this.m3uFavoritesCache.set(playlistMeta._id, {
|
||||
fingerprint,
|
||||
|
||||
Reference in new issue
Block a user