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:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-25 19:24:42 +02:00
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,