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

@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { firstValueFrom, from, of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
Channel,
@@ -49,8 +49,7 @@ describe('UnifiedFavoritesDataService', () => {
let playlistsService: {
addPortalFavorite: jest.Mock;
getPlaylistById: jest.Mock;
setFavorites: jest.Mock;
setPortalFavorites: jest.Mock;
transformPlaylistFavorites: jest.Mock;
};
const m3uChannels: Channel[] = [
@@ -134,8 +133,31 @@ describe('UnifiedFavoritesDataService', () => {
playlistsService = {
addPortalFavorite: jest.fn().mockReturnValue(of({})),
getPlaylistById: jest.fn(),
setFavorites: jest.fn().mockReturnValue(of({})),
setPortalFavorites: jest.fn().mockReturnValue(of({})),
// Faithful default: read the playlist through the configured
// getPlaylistById mock and apply the transform to its favorites,
// like the real queued PlaylistsService implementation.
transformPlaylistFavorites: jest.fn(
(
playlistId: string,
transform: (current: unknown[]) => unknown[]
) =>
from(
(async () => {
const playlist = (await firstValueFrom(
playlistsService.getPlaylistById(playlistId) ??
of(undefined)
)) as Partial<Playlist> | undefined;
const current = Array.isArray(playlist?.favorites)
? playlist.favorites
: [];
return {
...playlist,
_id: playlistId,
favorites: transform(current),
};
})()
)
),
};
databaseService = {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
@@ -408,7 +430,7 @@ describe('UnifiedFavoritesDataService', () => {
]);
});
it('persists M3U playlist reorders through setFavorites', async () => {
it('persists M3U playlist reorders through an atomic favorites transform', async () => {
const reorderedItems = [
{
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
@@ -438,13 +460,27 @@ describe('UnifiedFavoritesDataService', () => {
portalType: 'm3u',
});
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
expect(
playlistsService.transformPlaylistFavorites
).toHaveBeenCalledWith('m3u-1', expect.any(Function));
const [, transform] =
playlistsService.transformPlaylistFavorites.mock.calls[0];
// The reordered ids come first; favorites added concurrently (not part
// of the drag list) are preserved at the end instead of being dropped.
expect(
transform([
'https://example.com/1.m3u8',
'https://example.com/2.m3u8',
'https://example.com/concurrent.m3u8',
])
).toEqual([
'https://example.com/2.m3u8',
'https://example.com/1.m3u8',
'https://example.com/concurrent.m3u8',
]);
});
it('adds M3U favorites through setFavorites without duplicating existing entries', async () => {
it('adds M3U favorites through an atomic transform without duplicating existing entries', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
@@ -463,10 +499,9 @@ describe('UnifiedFavoritesDataService', () => {
channelId: 'new-channel',
} satisfies UnifiedCollectionItem);
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
'https://example.com/existing.m3u8',
'https://example.com/new.m3u8',
]);
expect(
playlistsService.transformPlaylistFavorites
).toHaveBeenCalledWith('m3u-1', expect.any(Function));
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: '[Playlists] Update Playlist Meta',
@@ -490,7 +525,69 @@ describe('UnifiedFavoritesDataService', () => {
streamUrl: 'https://example.com/existing.m3u8',
} satisfies UnifiedCollectionItem);
expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1);
// The duplicate add still runs atomically, but its transform leaves
// the favorites unchanged.
const [, duplicateTransform] =
playlistsService.transformPlaylistFavorites.mock.calls[1];
expect(
duplicateTransform(['https://example.com/existing.m3u8'])
).toEqual(['https://example.com/existing.m3u8']);
});
it('does not lose a rapid favorite toggle through the public M3U add flow', async () => {
const backingStore = {
favorites: ['https://example.com/existing.m3u8'],
};
let queue: Promise<unknown> = Promise.resolve();
playlistsService.transformPlaylistFavorites.mockImplementation(
(
playlistId: string,
transform: (current: string[]) => string[]
) => {
// Emulate the real queued read-transform-write semantics.
const run = queue.then(async () => {
await Promise.resolve();
backingStore.favorites = transform(backingStore.favorites);
return {
_id: playlistId,
favorites: backingStore.favorites,
};
});
queue = run.then(
() => undefined,
() => undefined
);
return from(run);
}
);
const baseItem = {
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
} as const;
await Promise.all([
service.addFavorite({
...baseItem,
uid: 'm3u::m3u-1::https://example.com/a.m3u8',
name: 'Channel A',
streamUrl: 'https://example.com/a.m3u8',
} satisfies UnifiedCollectionItem),
service.addFavorite({
...baseItem,
uid: 'm3u::m3u-1::https://example.com/b.m3u8',
name: 'Channel B',
streamUrl: 'https://example.com/b.m3u8',
} satisfies UnifiedCollectionItem),
]);
expect(backingStore.favorites).toEqual([
'https://example.com/existing.m3u8',
'https://example.com/a.m3u8',
'https://example.com/b.m3u8',
]);
});
it('adds Xtream favorites after resolving the content id', async () => {
@@ -605,7 +702,7 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('persists Stalker playlist reorders through setPortalFavorites', async () => {
it('persists Stalker playlist reorders through an atomic favorites transform', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
@@ -642,10 +739,15 @@ describe('UnifiedFavoritesDataService', () => {
}
);
expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith(
'stalker-1',
[stalkerFavorites[1], stalkerFavorites[0]]
);
expect(
playlistsService.transformPlaylistFavorites
).toHaveBeenCalledWith('stalker-1', expect.any(Function));
const [, transform] =
playlistsService.transformPlaylistFavorites.mock.calls[0];
expect(transform(stalkerFavorites)).toEqual([
stalkerFavorites[1],
stalkerFavorites[0],
]);
});
it('clears M3U favorites once per playlist with the remaining favorites preserved', async () => {
@@ -683,10 +785,21 @@ describe('UnifiedFavoritesDataService', () => {
},
] satisfies UnifiedCollectionItem[]);
expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1);
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
'https://example.com/3.m3u8',
]);
expect(
playlistsService.transformPlaylistFavorites
).toHaveBeenCalledTimes(1);
expect(
playlistsService.transformPlaylistFavorites
).toHaveBeenCalledWith('m3u-1', expect.any(Function));
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: '[Playlists] Update Playlist Meta',
playlist: expect.objectContaining({
_id: 'm3u-1',
favorites: ['https://example.com/3.m3u8'],
}),
})
);
});
it('clears Stalker favorites once per playlist with the remaining favorites preserved', async () => {
@@ -727,11 +840,15 @@ describe('UnifiedFavoritesDataService', () => {
},
] satisfies UnifiedCollectionItem[]);
expect(playlistsService.setPortalFavorites).toHaveBeenCalledTimes(1);
expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith(
'stalker-1',
[remainingFavorite]
);
expect(
playlistsService.transformPlaylistFavorites
).toHaveBeenCalledTimes(1);
const [transformedPlaylistId, transform] =
playlistsService.transformPlaylistFavorites.mock.calls[0];
expect(transformedPlaylistId).toBe('stalker-1');
expect(transform([...stalkerFavorites, remainingFavorite])).toEqual([
remainingFavorite,
]);
});
it('clears Xtream favorites through the bulk removal path', async () => {
@@ -82,17 +82,13 @@ export class UnifiedFavoritesDataService {
async removeFavorite(item: UnifiedCollectionItem): Promise<void> {
switch (item.sourceType) {
case 'm3u': {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
await this.transformM3uFavorites(item.playlistId, (current) =>
current.filter(
(favoriteId) =>
favoriteId !== item.streamUrl &&
favoriteId !== item.channelId
)
);
const filtered = (
(playlist.favorites as string[]) ?? []
).filter(
(favoriteId) =>
favoriteId !== item.streamUrl &&
favoriteId !== item.channelId
);
await this.setM3uFavorites(item.playlistId, filtered);
break;
}
case 'xtream': {
@@ -132,23 +128,9 @@ export class UnifiedFavoritesDataService {
return;
}
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
)) as Playlist | undefined;
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string => typeof favorite === 'string'
)
: [];
if (currentFavorites.includes(favoriteId)) {
return;
}
await this.setM3uFavorites(item.playlistId, [
...currentFavorites,
favoriteId,
]);
await this.transformM3uFavorites(item.playlistId, (current) =>
current.includes(favoriteId) ? current : [...current, favoriteId]
);
}
private async addXtreamFavorite(
@@ -249,12 +231,14 @@ export class UnifiedFavoritesDataService {
options.playlistId &&
options.portalType === 'm3u'
) {
await this.setM3uFavorites(
options.playlistId,
items
.map((item) => item.streamUrl ?? item.channelId ?? '')
.filter((value) => value.length > 0)
);
const orderedIds = items
.map((item) => item.streamUrl ?? item.channelId ?? '')
.filter((value) => value.length > 0);
const orderedIdSet = new Set(orderedIds);
await this.transformM3uFavorites(options.playlistId, (current) => [
...orderedIds,
...current.filter((favorite) => !orderedIdSet.has(favorite)),
]);
return;
}
@@ -263,32 +247,43 @@ export class UnifiedFavoritesDataService {
options.playlistId &&
options.portalType === 'stalker'
) {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(options.playlistId)
)) as Playlist | undefined;
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(isStalkerItem)
: [];
const favoritesById = new Map(
currentFavorites.map((favorite) => [
this.getStalkerFavoriteId(favorite),
favorite,
])
);
const reorderedFavorites = items
.map(
(item) =>
favoritesById.get(this.getStalkerFavoriteId(item)) ??
null
)
.filter(
(favorite): favorite is StalkerPortalItem =>
favorite !== null
);
await firstValueFrom(
this.playlistsService.setPortalFavorites(
this.playlistsService.transformPlaylistFavorites(
options.playlistId,
reorderedFavorites
(current) => {
const currentFavorites = current.filter(isStalkerItem);
const favoritesById = new Map(
currentFavorites.map((favorite) => [
this.getStalkerFavoriteId(favorite),
favorite,
])
);
const reorderedFavorites = items
.map(
(item) =>
favoritesById.get(
this.getStalkerFavoriteId(item)
) ?? null
)
.filter(
(favorite): favorite is StalkerPortalItem =>
favorite !== null
);
const reorderedIds = new Set(
reorderedFavorites.map((favorite) =>
this.getStalkerFavoriteId(favorite)
)
);
return [
...reorderedFavorites,
...currentFavorites.filter(
(favorite) =>
!reorderedIds.has(
this.getStalkerFavoriteId(favorite)
)
),
];
}
)
);
return;
@@ -334,9 +329,6 @@ export class UnifiedFavoritesDataService {
await Promise.all(
Array.from(groupedItems.entries()).map(
async ([playlistId, playlistItems]) => {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
const targetIds = new Set<string>();
playlistItems.forEach((item) => {
[item.streamUrl, item.channelId].forEach((value) => {
@@ -346,17 +338,12 @@ export class UnifiedFavoritesDataService {
}
});
});
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string'
)
: [];
const nextFavorites = currentFavorites.filter(
(favorite) => !targetIds.has(favorite.trim())
);
await this.setM3uFavorites(playlistId, nextFavorites);
await this.transformM3uFavorites(playlistId, (current) =>
current.filter(
(favorite) => !targetIds.has(favorite.trim())
)
);
}
)
);
@@ -409,26 +396,26 @@ export class UnifiedFavoritesDataService {
await Promise.all(
Array.from(groupedItems.entries()).map(
async ([playlistId, playlistItems]) => {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
const targetIds = new Set(
playlistItems.map((item) =>
this.getStalkerFavoriteId(item)
)
);
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(isStalkerItem)
: [];
const nextFavorites = currentFavorites.filter(
(favorite) =>
!targetIds.has(this.getStalkerFavoriteId(favorite))
);
await firstValueFrom(
this.playlistsService.setPortalFavorites(
this.playlistsService.transformPlaylistFavorites(
playlistId,
nextFavorites
(current) =>
current
.filter(isStalkerItem)
.filter(
(favorite) =>
!targetIds.has(
this.getStalkerFavoriteId(
favorite
)
)
)
)
);
}
@@ -758,18 +745,27 @@ export class UnifiedFavoritesDataService {
}
}
private async setM3uFavorites(
private async transformM3uFavorites(
playlistId: string,
favorites: string[]
transform: (currentFavorites: string[]) => string[]
): Promise<void> {
await firstValueFrom(
this.playlistsService.setFavorites(playlistId, favorites)
const updatedPlaylist = await firstValueFrom(
this.playlistsService.transformPlaylistFavorites(
playlistId,
(current) =>
transform(
current.filter(
(favorite): favorite is string =>
typeof favorite === 'string'
)
)
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
favorites,
favorites: updatedPlaylist.favorites,
} as PlaylistMeta,
})
);
+434 -3
View File
@@ -55,6 +55,7 @@ describe('PlaylistsService', () => {
},
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
playlistWriteQueues: new Map(),
playlistDeleteCleanups: [],
});
@@ -274,7 +275,9 @@ describe('PlaylistsService', () => {
await expect(
firstValueFrom(service.getM3uFavoriteChannels('playlist-1'))
).resolves.toBeNull();
expect(electron.dbGetAppPlaylistFavoriteChannels).not.toHaveBeenCalled();
expect(
electron.dbGetAppPlaylistFavoriteChannels
).not.toHaveBeenCalled();
expect(electron.dbGetAppPlaylist).not.toHaveBeenCalled();
expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled();
});
@@ -477,6 +480,10 @@ describe('PlaylistsService', () => {
it('updates many browser playlists with refresh metadata', async () => {
jest.spyOn(Date, 'now').mockReturnValue(1770000000000);
// Auto-refresh snapshots always come from playlists that had
// autoRefresh enabled; the batch write preserves that flag from the
// current row (or the snapshot when the row is missing) instead of
// force-enabling it.
const playlists = [
{
_id: 'playlist-a',
@@ -484,7 +491,7 @@ describe('PlaylistsService', () => {
count: 1,
importDate: '2026-04-01T00:00:00.000Z',
lastUsage: '2026-04-01T00:00:00.000Z',
autoRefresh: false,
autoRefresh: true,
},
{
_id: 'playlist-b',
@@ -492,7 +499,7 @@ describe('PlaylistsService', () => {
count: 2,
importDate: '2026-04-01T00:00:00.000Z',
lastUsage: '2026-04-01T00:00:00.000Z',
autoRefresh: false,
autoRefresh: true,
},
] as Playlist[];
const dbService = {
@@ -1240,4 +1247,428 @@ describe('PlaylistsService', () => {
})
);
});
describe('per-playlist write serialization', () => {
function createBasePlaylist(id: string): Playlist {
return {
_id: id,
title: 'Race Playlist',
count: 0,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-01T00:00:00.000Z',
autoRefresh: false,
favorites: [{ stream_id: 1, title: 'Existing Favorite' }],
recentlyViewed: [],
} as Playlist;
}
function createStatefulElectronStore(initialPlaylist: Playlist) {
const store = { current: initialPlaylist };
const electron = {
dbGetAppPlaylist: jest.fn(async () => {
// Yield a microtask so overlapping reads interleave the
// same way real async IPC reads do.
await Promise.resolve();
return store.current;
}),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) =>
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
? '1'
: null
),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(async (playlist: Playlist) => {
store.current = playlist;
}),
dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => {
const target = playlists.find(
(playlist) => playlist._id === store.current._id
);
if (target) {
store.current = target;
}
}),
};
return { store, electron };
}
it('keeps both changes when a favorite add overlaps a recently-viewed add (SQLite)', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-race-cross-field')
);
testWindow.electron = electron;
const service = createService();
await Promise.all([
firstValueFrom(
service.addPortalFavorite('portal-race-cross-field', {
stream_id: 2,
title: 'New Favorite',
} as never)
),
firstValueFrom(
service.addPlaylistRecentlyViewed(
'portal-race-cross-field',
{
source: 'm3u',
id: 'https://example.com/recent.m3u8',
url: 'https://example.com/recent.m3u8',
title: 'Recent Channel',
category_id: 'live',
added_at: '2026-07-25T10:00:00.000Z',
} as never
)
),
]);
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 2 }),
]);
expect(store.current.recentlyViewed).toEqual([
expect.objectContaining({
url: 'https://example.com/recent.m3u8',
}),
]);
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2);
});
it('keeps both favorites when two rapid favorite adds overlap (SQLite)', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-race-two-favorites')
);
testWindow.electron = electron;
const service = createService();
await Promise.all([
firstValueFrom(
service.addPortalFavorite('portal-race-two-favorites', {
stream_id: 2,
title: 'Second Favorite',
} as never)
),
firstValueFrom(
service.addPortalFavorite('portal-race-two-favorites', {
stream_id: 3,
title: 'Third Favorite',
} as never)
),
]);
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 2 }),
expect.objectContaining({ stream_id: 3 }),
]);
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2);
});
it('keeps both changes when mutations overlap in the IndexedDB path', async () => {
const store = {
current: createBasePlaylist('portal-race-indexeddb'),
};
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(store.current)),
update: jest.fn((_storeName: string, playlist: Playlist) => {
store.current = playlist;
return of(playlist);
}),
};
testWindow.electron = undefined;
const service = createService(dbService);
await Promise.all([
firstValueFrom(
service.addPortalFavorite('portal-race-indexeddb', {
stream_id: 2,
title: 'New Favorite',
} as never)
),
firstValueFrom(
service.addPlaylistRecentlyViewed('portal-race-indexeddb', {
source: 'm3u',
id: 'https://example.com/recent.m3u8',
url: 'https://example.com/recent.m3u8',
title: 'Recent Channel',
category_id: 'live',
added_at: '2026-07-25T10:00:00.000Z',
} as never)
),
]);
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 2 }),
]);
expect(store.current.recentlyViewed).toEqual([
expect.objectContaining({
url: 'https://example.com/recent.m3u8',
}),
]);
expect(dbService.update).toHaveBeenCalledTimes(2);
});
it('continues the per-playlist queue after a failed mutation', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-race-error')
);
electron.dbGetAppPlaylist.mockRejectedValueOnce(
new Error('read failed')
);
testWindow.electron = electron;
const service = createService();
const failing = firstValueFrom(
service.addPortalFavorite('portal-race-error', {
stream_id: 2,
title: 'Dropped Favorite',
} as never)
);
const following = firstValueFrom(
service.addPortalFavorite('portal-race-error', {
stream_id: 3,
title: 'Surviving Favorite',
} as never)
);
await expect(failing).rejects.toThrow('read failed');
await following;
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 3 }),
]);
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1);
});
it('applies overlapping favorites transforms atomically', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-transform-race')
);
testWindow.electron = electron;
const service = createService();
await Promise.all([
firstValueFrom(
service.transformPlaylistFavorites(
'portal-transform-race',
(current) => [
...current,
{ stream_id: 2, title: 'Second' } as never,
]
)
),
firstValueFrom(
service.transformPlaylistFavorites(
'portal-transform-race',
(current) => [
...current,
{ stream_id: 3, title: 'Third' } as never,
]
)
),
]);
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 2 }),
expect.objectContaining({ stream_id: 3 }),
]);
});
it('keeps a queued favorite add when an auto-refresh batch write overlaps', async () => {
const { store, electron } = createStatefulElectronStore({
...createBasePlaylist('portal-refresh-race'),
autoRefresh: true,
} as Playlist);
testWindow.electron = electron;
const service = createService();
const staleRefreshSnapshot = {
...createBasePlaylist('portal-refresh-race'),
autoRefresh: true,
title: 'Refreshed Title',
count: 42,
recentlyViewed: [],
} as Playlist;
await Promise.all([
firstValueFrom(
service.addPortalFavorite('portal-refresh-race', {
stream_id: 2,
title: 'New Favorite',
} as never)
),
firstValueFrom(
service.updateManyPlaylists([staleRefreshSnapshot])
),
]);
expect(store.current.title).toBe('Refreshed Title');
expect(store.current.autoRefresh).toBe(true);
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 2 }),
]);
});
it('does not re-enable auto-refresh disabled while a batch refresh is in flight', async () => {
const { store, electron } = createStatefulElectronStore({
...createBasePlaylist('portal-autorefresh-race'),
autoRefresh: true,
} as Playlist);
testWindow.electron = electron;
const service = createService();
const staleRefreshSnapshot = {
...createBasePlaylist('portal-autorefresh-race'),
autoRefresh: true,
title: 'Refreshed Title',
} as Playlist;
await Promise.all([
firstValueFrom(
service.updatePlaylistMeta({
_id: 'portal-autorefresh-race',
autoRefresh: false,
} as PlaylistMeta)
),
firstValueFrom(
service.updateManyPlaylists([staleRefreshSnapshot])
),
]);
expect(store.current.autoRefresh).toBe(false);
expect(store.current.title).toBe('Refreshed Title');
});
it('keeps queued metadata changes when an auto-refresh batch write overlaps', async () => {
const { store, electron } = createStatefulElectronStore({
...createBasePlaylist('portal-meta-race'),
hiddenGroupTitles: ['News'],
manualEpgUrls: ['https://example.com/manual.xml'],
} as Playlist);
testWindow.electron = electron;
const service = createService();
const staleRefreshSnapshot = {
...createBasePlaylist('portal-meta-race'),
title: 'Refreshed Title',
playlist: { items: [{ id: 'channel-1' }] },
} as Playlist;
await Promise.all([
firstValueFrom(
service.updatePlaylistMeta({
_id: 'portal-meta-race',
hiddenGroupTitles: ['News', 'Movies'],
} as PlaylistMeta)
),
firstValueFrom(
service.updateManyPlaylists([staleRefreshSnapshot])
),
]);
expect(store.current.hiddenGroupTitles).toEqual(['News', 'Movies']);
expect(store.current.manualEpgUrls).toEqual([
'https://example.com/manual.xml',
]);
expect(store.current.title).toBe('Refreshed Title');
expect(store.current.count).toBe(1);
});
it('keeps a queued favorite add when a position update overlaps', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-position-race')
);
testWindow.electron = electron;
const service = createService();
await Promise.all([
firstValueFrom(
service.addPortalFavorite('portal-position-race', {
stream_id: 2,
title: 'New Favorite',
} as never)
),
firstValueFrom(
service.updatePlaylistPositions([
{
id: 'portal-position-race',
changes: { position: 7 },
},
])
),
]);
expect(store.current.position).toBe(7);
expect(store.current.favorites).toEqual([
expect.objectContaining({ stream_id: 1 }),
expect.objectContaining({ stream_id: 2 }),
]);
});
it('does not serialize mutations across different playlists', async () => {
const playlists = new Map<string, Playlist>([
['portal-a', createBasePlaylist('portal-a')],
['portal-b', createBasePlaylist('portal-b')],
]);
let resolvePortalARead: (() => void) | undefined;
const portalARead = new Promise<void>((resolve) => {
resolvePortalARead = resolve;
});
const electron = {
dbGetAppPlaylist: jest.fn(async (playlistId: string) => {
if (playlistId === 'portal-a') {
await portalARead;
}
return playlists.get(playlistId);
}),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) =>
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
? '1'
: null
),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(async (playlist: Playlist) => {
playlists.set(playlist._id, playlist);
}),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
const blockedPortalA = firstValueFrom(
service.addPortalFavorite('portal-a', {
stream_id: 2,
title: 'Portal A Favorite',
} as never)
);
// Portal B completes although portal A's read is still blocked.
await firstValueFrom(
service.addPortalFavorite('portal-b', {
stream_id: 5,
title: 'Portal B Favorite',
} as never)
);
expect(playlists.get('portal-b')?.favorites).toHaveLength(2);
resolvePortalARead?.();
await blockedPortalA;
expect(playlists.get('portal-a')?.favorites).toHaveLength(2);
});
});
});
+396 -375
View File
@@ -10,6 +10,7 @@ import {
import { NgxIndexedDBService } from 'ngx-indexed-db';
import {
combineLatest,
defer,
firstValueFrom,
from,
map,
@@ -95,6 +96,7 @@ export class PlaylistsService {
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
private get electronApi(): PlaylistStorageElectronApi | null {
if (typeof window === 'undefined') {
@@ -384,6 +386,41 @@ export class PlaylistsService {
});
}
// Every playlist mutation reads the row, patches it in memory, and writes
// the whole row back. Overlapping mutations on the same playlist would be
// last-write-wins, so all of them are chained per playlist id here.
private serializePlaylistWrite<T>(
playlistId: string,
operation: () => Promise<T>
): Observable<T> {
return defer(() => {
const previous =
this.playlistWriteQueues.get(playlistId) ?? Promise.resolve();
const next = previous.then(() => operation());
const tail = next.then(
() => undefined,
() => undefined
);
this.playlistWriteQueues.set(playlistId, tail);
void tail.then(() => {
if (this.playlistWriteQueues.get(playlistId) === tail) {
this.playlistWriteQueues.delete(playlistId);
}
});
return next;
});
}
private persistPlaylistMutation(nextPlaylist: Playlist) {
if (this.isElectronStorageAvailable) {
return firstValueFrom(this.upsertSqlitePlaylist(nextPlaylist));
}
return firstValueFrom(
this.dbService.update(DbStores.Playlists, nextPlaylist)
);
}
getAllPlaylists() {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
@@ -467,51 +504,69 @@ export class PlaylistsService {
}
}
/**
* Canonical refresh merge shared by the single-playlist update flow and
* the auto-refresh batch. The refreshed payload only contributes
* refresh-owned data (parsed content, count, EPG detection); user-owned
* state on the freshly read row — favorites, recently viewed, ordering,
* hidden groups, curated EPG sources — must survive the refresh write.
*/
private mergeRefreshedPlaylist(
currentPlaylist: Playlist | undefined,
updatedPlaylist: Playlist,
playlistId: string
): Playlist {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
currentPlaylist?.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? currentPlaylist?.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ?? currentPlaylist?.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
currentPlaylist?.disabledEpgUrls,
});
return {
...currentPlaylist,
...updatedPlaylist,
_id: playlistId,
count:
updatedPlaylist.playlist?.items?.length ??
currentPlaylist?.count ??
updatedPlaylist.count,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
...(currentPlaylist
? {
favorites: currentPlaylist.favorites,
recentlyViewed: currentPlaylist.recentlyViewed,
position: currentPlaylist.position,
}
: {}),
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist?.autoRefresh ?? updatedPlaylist.autoRefresh,
};
}
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
return this.getPlaylistById(playlistId).pipe(
switchMap((currentPlaylist: Playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
currentPlaylist.detectedEpgUrls,
enabledEpgUrls:
updatedPlaylist.epgUrls ?? currentPlaylist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
currentPlaylist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
currentPlaylist.disabledEpgUrls,
});
const mergedPlaylist: Playlist = {
...currentPlaylist,
...updatedPlaylist,
_id: playlistId,
count:
updatedPlaylist.playlist?.items?.length ??
currentPlaylist.count,
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
favorites: currentPlaylist.favorites,
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist.autoRefresh ??
updatedPlaylist.autoRefresh,
};
return this.serializePlaylistWrite(playlistId, async () => {
const currentPlaylist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
const mergedPlaylist = this.mergeRefreshedPlaylist(
currentPlaylist,
updatedPlaylist,
playlistId
);
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(mergedPlaylist);
}
return this.dbService.update(
DbStores.Playlists,
mergedPlaylist
);
})
);
return this.persistPlaylistMutation(mergedPlaylist);
});
}
getPlaylistById(id: string) {
@@ -535,138 +590,160 @@ export class PlaylistsService {
}
updatePlaylistMeta(updatedPlaylist: PlaylistMeta) {
return this.getPlaylistById(updatedPlaylist._id).pipe(
switchMap((playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
playlist.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
playlist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
playlist.disabledEpgUrls,
});
const nextPlaylist: Playlist = {
...playlist,
...(updatedPlaylist.title != null
? { title: updatedPlaylist.title }
: {}),
...(updatedPlaylist.autoRefresh != null
? { autoRefresh: updatedPlaylist.autoRefresh }
: {}),
...(updatedPlaylist.userAgent != null
? { userAgent: updatedPlaylist.userAgent }
: {}),
...(updatedPlaylist.referrer !== undefined
? { referrer: updatedPlaylist.referrer }
: {}),
...(updatedPlaylist.origin !== undefined
? { origin: updatedPlaylist.origin }
: {}),
...(updatedPlaylist.serverUrl != null
? { serverUrl: updatedPlaylist.serverUrl }
: {}),
...(updatedPlaylist.portalUrl != null
? { portalUrl: updatedPlaylist.portalUrl }
: {}),
...(updatedPlaylist.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal:
updatedPlaylist.isFullStalkerPortal,
}
: {}),
...(updatedPlaylist.macAddress != null
? { macAddress: updatedPlaylist.macAddress }
: {}),
...(updatedPlaylist.username != null
? { username: updatedPlaylist.username }
: {}),
...(updatedPlaylist.password != null
? { password: updatedPlaylist.password }
: {}),
...(updatedPlaylist.favorites != null
? { favorites: updatedPlaylist.favorites }
: {}),
...(updatedPlaylist.recentlyViewed != null
? { recentlyViewed: updatedPlaylist.recentlyViewed }
: {}),
...(updatedPlaylist.hiddenGroupTitles != null
? {
hiddenGroupTitles:
updatedPlaylist.hiddenGroupTitles,
}
: {}),
...(updatedPlaylist.detectedEpgUrls !== undefined
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(updatedPlaylist.manualEpgUrls !== undefined
? { manualEpgUrls: epgSourceState.manualEpgUrls }
: {}),
...(updatedPlaylist.disabledEpgUrls !== undefined
? { disabledEpgUrls: epgSourceState.disabledEpgUrls }
: {}),
...(updatedPlaylist.epgUrls !== undefined ||
updatedPlaylist.detectedEpgUrls !== undefined ||
updatedPlaylist.manualEpgUrls !== undefined ||
updatedPlaylist.disabledEpgUrls !== undefined
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
...(updatedPlaylist.stalkerSerialNumber !== undefined
? {
stalkerSerialNumber:
updatedPlaylist.stalkerSerialNumber,
}
: {}),
...(updatedPlaylist.stalkerDeviceId1 !== undefined
? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 }
: {}),
...(updatedPlaylist.stalkerDeviceId2 !== undefined
? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 }
: {}),
...(updatedPlaylist.stalkerSignature1 !== undefined
? {
stalkerSignature1:
updatedPlaylist.stalkerSignature1,
}
: {}),
...(updatedPlaylist.stalkerSignature2 !== undefined
? {
stalkerSignature2:
updatedPlaylist.stalkerSignature2,
}
: {}),
};
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(updatedPlaylist._id)
);
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ?? playlist.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ?? playlist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ?? playlist.disabledEpgUrls,
});
const nextPlaylist: Playlist = {
...playlist,
...(updatedPlaylist.title != null
? { title: updatedPlaylist.title }
: {}),
...(updatedPlaylist.autoRefresh != null
? { autoRefresh: updatedPlaylist.autoRefresh }
: {}),
...(updatedPlaylist.userAgent != null
? { userAgent: updatedPlaylist.userAgent }
: {}),
...(updatedPlaylist.referrer !== undefined
? { referrer: updatedPlaylist.referrer }
: {}),
...(updatedPlaylist.origin !== undefined
? { origin: updatedPlaylist.origin }
: {}),
...(updatedPlaylist.serverUrl != null
? { serverUrl: updatedPlaylist.serverUrl }
: {}),
...(updatedPlaylist.portalUrl != null
? { portalUrl: updatedPlaylist.portalUrl }
: {}),
...(updatedPlaylist.isFullStalkerPortal !== undefined
? {
isFullStalkerPortal:
updatedPlaylist.isFullStalkerPortal,
}
: {}),
...(updatedPlaylist.macAddress != null
? { macAddress: updatedPlaylist.macAddress }
: {}),
...(updatedPlaylist.username != null
? { username: updatedPlaylist.username }
: {}),
...(updatedPlaylist.password != null
? { password: updatedPlaylist.password }
: {}),
...(updatedPlaylist.favorites != null
? { favorites: updatedPlaylist.favorites }
: {}),
...(updatedPlaylist.recentlyViewed != null
? { recentlyViewed: updatedPlaylist.recentlyViewed }
: {}),
...(updatedPlaylist.hiddenGroupTitles != null
? {
hiddenGroupTitles: updatedPlaylist.hiddenGroupTitles,
}
: {}),
...(updatedPlaylist.detectedEpgUrls !== undefined
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(updatedPlaylist.manualEpgUrls !== undefined
? { manualEpgUrls: epgSourceState.manualEpgUrls }
: {}),
...(updatedPlaylist.disabledEpgUrls !== undefined
? { disabledEpgUrls: epgSourceState.disabledEpgUrls }
: {}),
...(updatedPlaylist.epgUrls !== undefined ||
updatedPlaylist.detectedEpgUrls !== undefined ||
updatedPlaylist.manualEpgUrls !== undefined ||
updatedPlaylist.disabledEpgUrls !== undefined
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
...(updatedPlaylist.stalkerSerialNumber !== undefined
? {
stalkerSerialNumber:
updatedPlaylist.stalkerSerialNumber,
}
: {}),
...(updatedPlaylist.stalkerDeviceId1 !== undefined
? { stalkerDeviceId1: updatedPlaylist.stalkerDeviceId1 }
: {}),
...(updatedPlaylist.stalkerDeviceId2 !== undefined
? { stalkerDeviceId2: updatedPlaylist.stalkerDeviceId2 }
: {}),
...(updatedPlaylist.stalkerSignature1 !== undefined
? {
stalkerSignature1: updatedPlaylist.stalkerSignature1,
}
: {}),
...(updatedPlaylist.stalkerSignature2 !== undefined
? {
stalkerSignature2: updatedPlaylist.stalkerSignature2,
}
: {}),
};
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation(nextPlaylist);
});
}
updateFavorites(id: string, favorites: string[]) {
return this.getPlaylistById(id).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
favorites,
};
return this.serializePlaylistWrite(id, async () => {
const playlist = await firstValueFrom(this.getPlaylistById(id));
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.persistPlaylistMutation({
...playlist,
favorites,
});
});
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
/**
* Applies an atomic favorites update: the current favorites are read
* inside the per-playlist write queue, so overlapping calls cannot work
* from stale snapshots. Callers should pass a pure transform instead of
* precomputing the next favorites array from an earlier read.
*/
transformPlaylistFavorites(
playlistId: string,
transform: (
currentFavorites: NonNullable<Playlist['favorites']>
) => NonNullable<Playlist['favorites']>
): Observable<Playlist> {
if (!playlistId) {
throw new Error('Playlist ID is required');
}
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (!playlist) {
throw new Error(`Playlist not found: ${playlistId}`);
}
const currentFavorites = Array.isArray(playlist.favorites)
? playlist.favorites
: [];
const nextPlaylist: Playlist = {
...playlist,
favorites: transform(currentFavorites),
};
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
});
}
updateManyPlaylists(playlists: Playlist[]) {
@@ -674,23 +751,29 @@ export class PlaylistsService {
return of([]);
}
if (this.isElectronStorageAvailable) {
const updatedPlaylists = playlists.map((playlist) => ({
...playlist,
updateDate: Date.now(),
autoRefresh: true,
}));
return this.upsertManySqlitePlaylists(updatedPlaylists);
}
// Auto-refresh payloads are snapshots taken before the refresh ran,
// so each row goes through the same per-playlist queue and canonical
// refresh merge as the single-playlist update flow: a batch write
// cannot clobber a concurrent collection or metadata mutation.
return combineLatest(
playlists.map((playlist) => {
return this.dbService.update(DbStores.Playlists, {
...playlist,
updateDate: Date.now(),
autoRefresh: true,
});
})
playlists.map((playlist) =>
this.serializePlaylistWrite(playlist._id, async () => {
const current = await firstValueFrom(
this.getPlaylistById(playlist._id)
);
// The merge takes autoRefresh from the current row first,
// so disabling auto-refresh while a refresh is in flight
// is not reverted by the completing batch write.
const nextPlaylist = this.mergeRefreshedPlaylist(
current,
playlist,
playlist._id
);
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
})
)
);
}
@@ -767,20 +850,14 @@ export class PlaylistsService {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nextPlaylist: Playlist = {
...portal,
favorites: [...(portal.favorites ?? []), item],
};
return this.serializePlaylistWrite(portalId, async () => {
const portal = await firstValueFrom(this.getPlaylistById(portalId));
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...portal,
favorites: [...(portal.favorites ?? []), item],
});
});
}
setPortalFavorites(portalId: string, favorites: StalkerPortalItem[]) {
@@ -788,54 +865,42 @@ export class PlaylistsService {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nextPlaylist: Playlist = {
...portal,
favorites,
};
return this.serializePlaylistWrite(portalId, async () => {
const portal = await firstValueFrom(this.getPlaylistById(portalId));
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...portal,
favorites,
});
});
}
removeFromPortalFavorites(portalId: string, favoriteId: number | string) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nextPlaylist: Playlist = {
...portal,
favorites: portal.favorites?.filter((i) => {
const expectedId = String(favoriteId);
const favorite = i as PortalFavoriteItem;
const streamId = String(favorite.stream_id ?? '');
const seriesId = String(favorite.series_id ?? '');
const movieId = String(favorite.movie_id ?? '');
const itemId = String(favorite.id ?? '');
return this.serializePlaylistWrite(portalId, async () => {
const portal = await firstValueFrom(this.getPlaylistById(portalId));
return (
streamId !== expectedId &&
seriesId !== expectedId &&
movieId !== expectedId &&
itemId !== expectedId
);
}),
};
return this.persistPlaylistMutation({
...portal,
favorites: portal.favorites?.filter((i) => {
const expectedId = String(favoriteId);
const favorite = i as PortalFavoriteItem;
const streamId = String(favorite.stream_id ?? '');
const seriesId = String(favorite.series_id ?? '');
const movieId = String(favorite.movie_id ?? '');
const itemId = String(favorite.id ?? '');
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return (
streamId !== expectedId &&
seriesId !== expectedId &&
movieId !== expectedId &&
itemId !== expectedId
);
}),
});
});
}
updatePlaylistPositions(
@@ -848,46 +913,25 @@ export class PlaylistsService {
return of([]);
}
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? ((await electron.dbGetAppPlaylists()) as Playlist[])
: [];
const positionsById = new Map(
positionUpdates.map((item) => [
item.id,
item.changes.position,
])
);
const updatedPlaylists = playlists
.filter((playlist) => positionsById.has(playlist._id))
.map((playlist) => ({
...playlist,
position: positionsById.get(playlist._id),
}));
if (electron) {
await electron.dbUpsertAppPlaylists(updatedPlaylists);
}
return updatedPlaylists;
});
}
return combineLatest(
positionUpdates.map((item) => {
return this.dbService
.getByID<Playlist>(DbStores.Playlists, item.id)
.pipe(
switchMap((playlist: Playlist) =>
this.dbService.update(DbStores.Playlists, {
...playlist,
position: item.changes.position,
})
)
positionUpdates.map((item) =>
this.serializePlaylistWrite(item.id, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(item.id)
);
})
if (!playlist) {
return null;
}
const nextPlaylist: Playlist = {
...playlist,
position: item.changes.position,
};
await this.persistPlaylistMutation(nextPlaylist);
return nextPlaylist;
})
)
);
}
@@ -953,20 +997,16 @@ export class PlaylistsService {
}
setFavorites(playlistId: string, favorites: string[]) {
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
favorites,
};
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...playlist,
favorites,
});
});
}
getRawPlaylistById(id: string) {
@@ -1087,40 +1127,36 @@ export class PlaylistsService {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nowIso = new Date().toISOString();
const recentItems = Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
const existingIndex = recentItems.findIndex((recentItem) =>
this.matchesPlaylistRecentIdentity(
recentItem,
this.getPlaylistRecentIdentity(item)
)
);
const existingItem =
existingIndex >= 0 ? recentItems[existingIndex] : null;
const nextItem: PlaylistRecentlyViewedItem = {
...(existingItem ?? {}),
...item,
added_at: nowIso,
};
const remainingItems = recentItems.filter(
(_, index) => index !== existingIndex
);
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: [nextItem, ...remainingItems],
};
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
const nowIso = new Date().toISOString();
const recentItems = Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
const existingIndex = recentItems.findIndex((recentItem) =>
this.matchesPlaylistRecentIdentity(
recentItem,
this.getPlaylistRecentIdentity(item)
)
);
const existingItem =
existingIndex >= 0 ? recentItems[existingIndex] : null;
const nextItem: PlaylistRecentlyViewedItem = {
...(existingItem ?? {}),
...item,
added_at: nowIso,
};
const remainingItems = recentItems.filter(
(_, index) => index !== existingIndex
);
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: [nextItem, ...remainingItems],
});
});
}
removeFromPlaylistRecentlyViewed(
@@ -1131,25 +1167,21 @@ export class PlaylistsService {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!this.matchesPlaylistRecentIdentity(item, identity)
),
};
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!this.matchesPlaylistRecentIdentity(item, identity)
),
});
});
}
removeFromPlaylistRecentlyViewedBatch(
@@ -1164,30 +1196,23 @@ export class PlaylistsService {
return this.getPlaylistById(playlistId);
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!identities.some((identity) =>
this.matchesPlaylistRecentIdentity(
item,
identity
)
)
),
};
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: (
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
)?.filter(
(item) =>
!identities.some((identity) =>
this.matchesPlaylistRecentIdentity(item, identity)
)
),
});
});
}
clearPlaylistRecentlyViewed(playlistId: string) {
@@ -1195,20 +1220,16 @@ export class PlaylistsService {
throw new Error('Playlist ID is required');
}
return this.getPlaylistById(playlistId).pipe(
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
recentlyViewed: [],
};
return this.serializePlaylistWrite(playlistId, async () => {
const playlist = await firstValueFrom(
this.getPlaylistById(playlistId)
);
if (this.isElectronStorageAvailable) {
return this.upsertSqlitePlaylist(nextPlaylist);
}
return this.dbService.update(DbStores.Playlists, nextPlaylist);
})
);
return this.persistPlaylistMutation({
...playlist,
recentlyViewed: [],
});
});
}
getPortalRecentlyViewed(portalId: string) {
@@ -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,
@@ -86,17 +86,11 @@ export class GlobalFavoritesService {
async removeFavorite(channel: UnifiedFavoriteChannel): Promise<void> {
switch (channel.sourceType) {
case 'm3u': {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(channel.playlistId)
);
const currentFavs = (playlist.favorites as string[]) ?? [];
const filtered = currentFavs.filter(
(f) => f !== channel.streamUrl
);
await firstValueFrom(
this.playlistsService.setFavorites(
this.playlistsService.transformPlaylistFavorites(
channel.playlistId,
filtered
(current) =>
current.filter((f) => f !== channel.streamUrl)
)
);
break;
@@ -134,9 +128,7 @@ export class GlobalFavoritesService {
// Persist xtream positions to DB
const xtreamUpdates = channels
.filter(
(
ch
): ch is UnifiedFavoriteChannel & { contentId: number } =>
(ch): ch is UnifiedFavoriteChannel & { contentId: number } =>
ch.sourceType === 'xtream' && ch.contentId != null
)
.map((ch, index) => ({