mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(stalker): coordinate playlist replacements with edit
This commit is contained in:
1 parent
f57650d562
commit
85531e940c
8 files changed
+288
-94
No files matched your search
@@ -1264,7 +1264,7 @@ engine` (restart required) or
|
||||
- Every resolved Edit commit is guarded by the source connection authority captured when Edit began. Electron checks it inside the per-playlist write queue; PWA performs the read, predicate, and cursor update in one IndexedDB readwrite transaction, so another tab cannot interleave a replacement. The one-time legacy mode-flag migration also scans and updates rows through one readwrite cursor transaction and never replays a pre-transaction snapshot. Delete/restore or replacement under the same playlist ID aborts both ordinary and post-navigation writes; the latter still merge concurrent title/EPG metadata when authority matches.
|
||||
- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
|
||||
- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `<base>/portal.php` → `<base>/server/load.php` → `<base>/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `<base>/portal.php`, while canonical-shaped unreachable addresses still abort. If bounded discovery returns while abandoned authentication remains on the wire, the refusal is shown immediately but Add and every form field stay disabled until its settlement promise resolves.
|
||||
- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays byte-identical even if the dialog hydrated before a concurrent discovery committed; it skips discovery. A persisted `portalUrl` keeps the row on the Stalker save path even if legacy Xtream fields remain. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery, PWA acquires an origin-wide per-playlist Web Lock and verifies the persisted source authority while holding it; a concurrent tab or stale dialog therefore fails before an identity-bearing remote request, and PWA fails closed if Web Locks are unavailable. Electron relies on its single-instance local owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled. That late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only connection/session fields into the current row, so newer title/EPG/metadata edits win; its returned row feeds the state-only update together with discovery's transient session patch, so NgRx replaces or clears its session fields while success UI is suppressed. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
|
||||
- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays byte-identical even if the dialog hydrated before a concurrent discovery committed; it skips discovery. A persisted `portalUrl` keeps the row on the Stalker save path even if legacy Xtream fields remain. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery, PWA acquires a shared playlist-authority barrier plus an exclusive origin-wide per-playlist Web Lock and verifies the persisted source authority while holding both. Add/delete, backup restore, and bulk replacement take the same row lock, while Delete All takes the barrier exclusively, so authority cannot change between preflight and the identity-bearing request. A concurrent Edit or stale dialog fails before remote discovery; a replacement waits for the current owner. PWA fails closed if Web Locks are unavailable, while Electron relies on its single-instance local owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled. That late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only connection/session fields into the current row, so newer title/EPG/metadata edits win; its returned row feeds the state-only update together with discovery's transient session patch, so NgRx replaces or clears its session fields while success UI is suppressed. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
|
||||
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair calls discovery, it verifies that the persisted row still owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or backup restore with the same playlist ID but different connection metadata retires the override and token only after the persisted row confirms ownership and only if no explicit Edit took ownership during that read, so a delayed stale request cannot remove valid runtime state or a token negotiated by the overlapping Edit. Each repair installs a session-level authentication fence synchronously, drains the existing token slot before probing, and keeps request routing ahead of effective-connection selection until repair finishes; an abandoned transport keeps both the repair and session fences until it actually settles. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
|
||||
- Explicit Edit advances the repair generation before installing its resolved session. Lazy repair captures that generation before any probe-history row read and rechecks it with the active Edit fence before reserving discovery. A repair that started earlier is therefore discarded even if it was restoring a `discarded` history record or had already verified its row, so it cannot probe alongside Edit or restore an older endpoint, mode or token afterwards.
|
||||
- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
|
||||
|
||||
@@ -226,14 +226,18 @@ Delete/restore or replacement under the same ID therefore aborts instead of
|
||||
receiving a portal/session write. A row identified by its persisted `portalUrl`
|
||||
stays on the Stalker save path even if legacy Xtream fields remain, so an
|
||||
unrelated Xtream write cannot strand the Edit reservation. Before discovery
|
||||
starts, PWA Edit acquires an origin-wide Web Lock keyed by playlist ID and,
|
||||
while holding it, verifies that the persisted row still has the source
|
||||
connection shown when Edit began. Another tab therefore fails before discovery
|
||||
instead of allowing two identity-bearing profile requests; a stale dialog also
|
||||
fails before it can touch the remote session. PWA fails closed when Web Locks
|
||||
are unavailable, while Electron relies on its single-instance local owner. The
|
||||
reservation blocks every new authentication (including a URL edit with the same
|
||||
normalized fingerprint) and repair, and drains any work already in flight.
|
||||
starts, PWA Edit acquires a shared playlist-authority barrier plus an exclusive
|
||||
origin-wide Web Lock keyed by playlist ID and, while holding both, verifies that
|
||||
the persisted row still has the source connection shown when Edit began.
|
||||
Add/delete, backup restore, and bulk replacement paths take the same row lock;
|
||||
Delete All takes the barrier exclusively. The checked authority therefore
|
||||
cannot be replaced between that preflight and the identity-bearing discovery
|
||||
request. Another tab fails before overlapping discovery, while a replacement
|
||||
waits for the existing Edit owner; a stale dialog also fails before it can touch
|
||||
the remote session. PWA fails closed when Web Locks are unavailable, while
|
||||
Electron relies on its single-instance local owner. The reservation blocks every
|
||||
new authentication (including a URL edit with the same normalized fingerprint)
|
||||
and repair, and drains any work already in flight.
|
||||
Ownership is rechecked after every asynchronous drain or authority rebase.
|
||||
Ordinary failure releases that reservation with the previous runtime untouched;
|
||||
if a bounded discovery result still has an abandoned authentication on the
|
||||
|
||||
@@ -1,47 +1 @@
|
||||
/** Holds one origin-wide edit reservation until persistence or cancellation. */
|
||||
export async function acquireCrossContextEditReservation(
|
||||
playlistId: string
|
||||
): Promise<() => void> {
|
||||
const locks = globalThis.navigator?.locks;
|
||||
if (!locks) {
|
||||
const isElectronRenderer =
|
||||
typeof window !== 'undefined' &&
|
||||
Boolean((window as Window & { electron?: unknown }).electron);
|
||||
if (typeof window === 'undefined' || isElectronRenderer) {
|
||||
return () => undefined;
|
||||
}
|
||||
throw new Error('Cross-context playlist edit locking is unavailable');
|
||||
}
|
||||
|
||||
let release: () => void = () => undefined;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const acquired = new Promise<boolean>((resolve, reject) => {
|
||||
const request = locks.request(
|
||||
`iptvnator:stalker-edit:${playlistId}`,
|
||||
{ ifAvailable: true, mode: 'exclusive' },
|
||||
async (lock) => {
|
||||
if (!lock) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
await held;
|
||||
}
|
||||
);
|
||||
void request.catch(reject);
|
||||
});
|
||||
|
||||
if (!(await acquired)) {
|
||||
throw new Error('Stalker playlist edit already in progress');
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
release();
|
||||
}
|
||||
};
|
||||
}
|
||||
export { acquirePlaylistAuthorityEditReservation as acquireCrossContextEditReservation } from '@iptvnator/services';
|
||||
@@ -234,9 +234,16 @@ describe('Stalker edited-session coordination', () => {
|
||||
const request = jest.fn(
|
||||
async (
|
||||
name: string,
|
||||
_options: LockOptions,
|
||||
options: LockOptions,
|
||||
callback: (lock: Lock | null) => Promise<void>
|
||||
) => {
|
||||
if (name === 'iptvnator:playlist-authority') {
|
||||
await callback({
|
||||
name,
|
||||
mode: 'shared',
|
||||
} as Lock);
|
||||
return;
|
||||
}
|
||||
if (held) {
|
||||
await callback(null);
|
||||
return;
|
||||
@@ -245,7 +252,7 @@ describe('Stalker edited-session coordination', () => {
|
||||
try {
|
||||
await callback({
|
||||
name,
|
||||
mode: 'exclusive',
|
||||
mode: options.mode ?? 'exclusive',
|
||||
} as Lock);
|
||||
} finally {
|
||||
held = false;
|
||||
@@ -275,7 +282,12 @@ describe('Stalker edited-session coordination', () => {
|
||||
/already in progress/i
|
||||
);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
`iptvnator:stalker-edit:${oldPlaylist._id}`,
|
||||
'iptvnator:playlist-authority',
|
||||
{ mode: 'shared' },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
`iptvnator:playlist-authority:${oldPlaylist._id}`,
|
||||
{ ifAvailable: true, mode: 'exclusive' },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './lib/playback-position.service';
|
||||
export * from './lib/playlist-delete-cleanup.token';
|
||||
export * from './lib/playlist-delete-action.service';
|
||||
export * from './lib/playlist-backup.service';
|
||||
export * from './lib/playlist-cross-context-lock';
|
||||
export * from './lib/playlist-refresh.service';
|
||||
export * from './lib/playlists.service';
|
||||
export * from './lib/portal-status.service';
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
const PLAYLIST_AUTHORITY_BARRIER = 'iptvnator:playlist-authority';
|
||||
|
||||
function getLockManager(): LockManager | undefined {
|
||||
return globalThis.navigator?.locks;
|
||||
}
|
||||
|
||||
function getPlaylistAuthorityLockName(playlistId: string): string {
|
||||
return `${PLAYLIST_AUTHORITY_BARRIER}:${playlistId}`;
|
||||
}
|
||||
|
||||
async function runWithPlaylistRowLocks<T>(
|
||||
locks: LockManager,
|
||||
playlistIds: readonly string[],
|
||||
operation: () => Promise<T>,
|
||||
index = 0
|
||||
): Promise<T> {
|
||||
if (index >= playlistIds.length) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
return locks.request(
|
||||
getPlaylistAuthorityLockName(playlistIds[index]),
|
||||
{ mode: 'exclusive' },
|
||||
() => runWithPlaylistRowLocks(locks, playlistIds, operation, index + 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates row replacement operations with long-running Stalker Edit
|
||||
* discovery in every browser tab from the same origin.
|
||||
*/
|
||||
export async function runWithPlaylistAuthorityMutation<T>(
|
||||
playlistIds: readonly string[],
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const locks = getLockManager();
|
||||
if (!locks) {
|
||||
return operation();
|
||||
}
|
||||
const orderedIds = [...new Set(playlistIds.filter(Boolean))].sort();
|
||||
|
||||
return locks.request(PLAYLIST_AUTHORITY_BARRIER, { mode: 'shared' }, () =>
|
||||
runWithPlaylistRowLocks(locks, orderedIds, operation)
|
||||
);
|
||||
}
|
||||
|
||||
/** Prevents a store-wide clear from crossing any row-scoped reservation. */
|
||||
export async function runWithPlaylistAuthorityReset<T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const locks = getLockManager();
|
||||
if (!locks) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
return locks.request(
|
||||
PLAYLIST_AUTHORITY_BARRIER,
|
||||
{ mode: 'exclusive' },
|
||||
operation
|
||||
);
|
||||
}
|
||||
|
||||
/** Holds one origin-wide edit reservation until persistence or cancellation. */
|
||||
export async function acquirePlaylistAuthorityEditReservation(
|
||||
playlistId: string
|
||||
): Promise<() => void> {
|
||||
const locks = getLockManager();
|
||||
if (!locks) {
|
||||
const isElectronRenderer =
|
||||
typeof window !== 'undefined' &&
|
||||
Boolean((window as Window & { electron?: unknown }).electron);
|
||||
if (typeof window === 'undefined' || isElectronRenderer) {
|
||||
return () => undefined;
|
||||
}
|
||||
throw new Error('Cross-context playlist edit locking is unavailable');
|
||||
}
|
||||
|
||||
let releaseHeldLock: () => void = () => undefined;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
releaseHeldLock = resolve;
|
||||
});
|
||||
const acquired = new Promise<boolean>((resolve, reject) => {
|
||||
const request = locks.request(
|
||||
PLAYLIST_AUTHORITY_BARRIER,
|
||||
{ mode: 'shared' },
|
||||
() =>
|
||||
locks.request(
|
||||
getPlaylistAuthorityLockName(playlistId),
|
||||
{ ifAvailable: true, mode: 'exclusive' },
|
||||
async (lock) => {
|
||||
if (!lock) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
await held;
|
||||
}
|
||||
)
|
||||
);
|
||||
void request.catch(reject);
|
||||
});
|
||||
|
||||
if (!(await acquired)) {
|
||||
throw new Error('Stalker playlist edit already in progress');
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (!released) {
|
||||
released = true;
|
||||
releaseHeldLock();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,14 @@ const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
|
||||
describe('PlaylistsService', () => {
|
||||
const testWindow = window as unknown as { electron?: unknown };
|
||||
const originalElectron = testWindow.electron;
|
||||
const originalLockManager = globalThis.navigator?.locks;
|
||||
|
||||
afterEach(() => {
|
||||
testWindow.electron = originalElectron;
|
||||
Object.defineProperty(globalThis.navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: originalLockManager,
|
||||
});
|
||||
localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG);
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
@@ -307,6 +312,56 @@ describe('PlaylistsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('coordinates browser row replacements with the cross-context edit reservation', async () => {
|
||||
const request = jest.fn(
|
||||
async <T>(
|
||||
name: string,
|
||||
options: LockOptions,
|
||||
callback: (lock: Lock | null) => Promise<T>
|
||||
) =>
|
||||
callback({
|
||||
name,
|
||||
mode: options.mode ?? 'exclusive',
|
||||
} as Lock)
|
||||
);
|
||||
Object.defineProperty(globalThis.navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request },
|
||||
});
|
||||
const playlist = {
|
||||
_id: 'playlist-replacement-lock',
|
||||
title: 'Replacement',
|
||||
count: 0,
|
||||
importDate: '2026-04-01T00:00:00.000Z',
|
||||
lastUsage: '2026-04-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
} as Playlist;
|
||||
const dbService = {
|
||||
add: jest.fn(() => of('generated-key')),
|
||||
delete: jest.fn(() => of(undefined)),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
const service = createService(dbService);
|
||||
|
||||
await firstValueFrom(service.addPlaylist(playlist));
|
||||
await firstValueFrom(service.deletePlaylist(playlist._id));
|
||||
|
||||
expect(
|
||||
request.mock.calls.map(([name, options]) => [name, options])
|
||||
).toEqual([
|
||||
['iptvnator:playlist-authority', { mode: 'shared' }],
|
||||
[
|
||||
`iptvnator:playlist-authority:${playlist._id}`,
|
||||
{ mode: 'exclusive' },
|
||||
],
|
||||
['iptvnator:playlist-authority', { mode: 'shared' }],
|
||||
[
|
||||
`iptvnator:playlist-authority:${playlist._id}`,
|
||||
{ mode: 'exclusive' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => {
|
||||
let storedPlaylists: Playlist[] = [
|
||||
{
|
||||
@@ -1195,6 +1250,37 @@ describe('PlaylistsService', () => {
|
||||
expect(dbService.clear).toHaveBeenCalledWith(DbStores.Playlists);
|
||||
});
|
||||
|
||||
it('takes the exclusive cross-context authority barrier before clearing playlists', async () => {
|
||||
const request = jest.fn(
|
||||
async <T>(
|
||||
name: string,
|
||||
options: LockOptions,
|
||||
callback: (lock: Lock | null) => Promise<T>
|
||||
) =>
|
||||
callback({
|
||||
name,
|
||||
mode: options.mode ?? 'exclusive',
|
||||
} as Lock)
|
||||
);
|
||||
Object.defineProperty(globalThis.navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request },
|
||||
});
|
||||
const dbService = {
|
||||
clear: jest.fn(() => of('cleared')),
|
||||
};
|
||||
testWindow.electron = undefined;
|
||||
const service = createService(dbService);
|
||||
|
||||
await firstValueFrom(service.removeAll());
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'iptvnator:playlist-authority',
|
||||
{ mode: 'exclusive' },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('adds many browser playlists through bulkAdd', async () => {
|
||||
const playlists = [
|
||||
{
|
||||
|
||||
@@ -34,6 +34,10 @@ import {
|
||||
normalizeStalkerDate,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token';
|
||||
import {
|
||||
runWithPlaylistAuthorityMutation,
|
||||
runWithPlaylistAuthorityReset,
|
||||
} from './playlist-cross-context-lock';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
@@ -504,13 +508,18 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
addPlaylist(playlist: Playlist) {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.upsertSqlitePlaylist(playlist);
|
||||
}
|
||||
return this.serializePlaylistWrite(playlist._id, () =>
|
||||
runWithPlaylistAuthorityMutation([playlist._id], async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return firstValueFrom(this.upsertSqlitePlaylist(playlist));
|
||||
}
|
||||
|
||||
return this.dbService
|
||||
.add(DbStores.Playlists, playlist)
|
||||
.pipe(map(() => playlist));
|
||||
await firstValueFrom(
|
||||
this.dbService.add(DbStores.Playlists, playlist)
|
||||
);
|
||||
return playlist;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getPlaylist(id: string) {
|
||||
@@ -527,20 +536,21 @@ export class PlaylistsService {
|
||||
// row back and resurrect the playlist.
|
||||
const delete$: Observable<unknown> = this.serializePlaylistWrite(
|
||||
playlistId,
|
||||
async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
await electron.dbDeletePlaylist(playlistId);
|
||||
() =>
|
||||
runWithPlaylistAuthorityMutation([playlistId], async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
await electron.dbDeletePlaylist(playlistId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
this.dbService.delete(DbStores.Playlists, playlistId)
|
||||
);
|
||||
}
|
||||
return firstValueFrom(
|
||||
this.dbService.delete(DbStores.Playlists, playlistId)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return delete$.pipe(
|
||||
@@ -1193,13 +1203,24 @@ export class PlaylistsService {
|
||||
addManyPlaylists(
|
||||
playlists: Playlist[]
|
||||
): Observable<AddManyPlaylistsResult> {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.upsertManySqlitePlaylists(playlists);
|
||||
}
|
||||
return defer(() =>
|
||||
runWithPlaylistAuthorityMutation(
|
||||
playlists.map((playlist) => playlist._id),
|
||||
async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return firstValueFrom(
|
||||
this.upsertManySqlitePlaylists(playlists)
|
||||
);
|
||||
}
|
||||
|
||||
return this.dbService.bulkAdd(
|
||||
DbStores.Playlists,
|
||||
playlists as unknown as Playlist[]
|
||||
return firstValueFrom(
|
||||
this.dbService.bulkAdd(
|
||||
DbStores.Playlists,
|
||||
playlists as unknown as Playlist[]
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1256,19 +1277,21 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
removeAll(): Observable<void> {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
await electron.dbDeleteAllPlaylists();
|
||||
return defer(() =>
|
||||
runWithPlaylistAuthorityReset(async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
await electron.dbDeleteAllPlaylists();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}).pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
return this.dbService
|
||||
.clear(DbStores.Playlists)
|
||||
.pipe(map(() => undefined));
|
||||
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private normalizePortalRecentIdentity(value: unknown): string {
|
||||
|
||||
Reference in new issue
Block a user