mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
+144
-27
@@ -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 () => {
|
||||
|
||||
+83
-87
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in new issue
Block a user