fix(stalker): migrate pwa flags transactionally

This commit is contained in:
4gray committed 2026-08-09 18:59:46 +02:00
1 parent 7dbf175b8c
commit 71c99929d3
4 files changed
+53 -28

No files matched your search

+1 -1
View File
@@ -1261,7 +1261,7 @@ engine` (restart required) or
**Stalker Portal Mode and Endpoint Discovery**:
- 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. 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.
- 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 it reserves the playlist ID; a second Edit cannot replace that 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.
+3
View File
@@ -219,6 +219,9 @@ require the storage-current row to retain the source connection authority
captured when Edit began. Electron performs the check inside its 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 likewise scans and updates rows through
one readwrite cursor transaction; it never replays a snapshot collected before
another tab's delete/restore or replacement.
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
+23 -12
View File
@@ -1,4 +1,4 @@
import { firstValueFrom, of } from 'rxjs';
import { EMPTY, firstValueFrom, of } from 'rxjs';
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
@@ -26,10 +26,11 @@ describe('PlaylistsService', () => {
dbService: {
clear: jest.fn(() => of(undefined)),
delete: jest.fn(() => of(undefined)),
// IndexedDB operations always run the Stalker metadata migration first,
// and that migration reads all playlists before the requested operation.
// IndexedDB operations run the Stalker metadata migration first;
// the default cursor represents an empty playlist store.
getAll: jest.fn(() => of([])),
getByID: jest.fn(() => of(undefined)),
openCursor: jest.fn(() => EMPTY),
update: jest.fn(() => of(undefined)),
...overrides,
},
@@ -395,7 +396,7 @@ describe('PlaylistsService', () => {
);
});
it('migrates legacy Stalker portal flags in IndexedDB before returning full playlists', async () => {
it('migrates legacy Stalker portal flags in one IndexedDB cursor transaction', async () => {
let storedPlaylists: Playlist[] = [
{
_id: 'stalker-2',
@@ -408,14 +409,19 @@ describe('PlaylistsService', () => {
portalUrl: 'http://example.com/portal/c/',
} as Playlist,
];
const cursorUpdate = jest.fn((playlist: Playlist) => {
storedPlaylists = [playlist];
});
const cursorContinue = jest.fn();
const dbService = {
getAll: jest.fn(() => of(storedPlaylists)),
update: jest.fn((_storeName: string, playlist: Playlist) => {
storedPlaylists = storedPlaylists.map((current) =>
current._id === playlist._id ? playlist : current
);
return of(playlist);
}),
openCursor: jest.fn(() =>
of({
value: storedPlaylists[0],
update: cursorUpdate,
continue: cursorContinue,
})
),
};
testWindow.electron = undefined;
@@ -428,13 +434,18 @@ describe('PlaylistsService', () => {
}),
]);
expect(dbService.update).toHaveBeenCalledWith(
DbStores.Playlists,
expect(dbService.openCursor).toHaveBeenCalledWith({
storeName: DbStores.Playlists,
mode: 'readwrite',
});
expect(cursorUpdate).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'stalker-2',
isFullStalkerPortal: false,
})
);
expect(cursorContinue).toHaveBeenCalledTimes(1);
expect(dbService.getAll).toHaveBeenCalledTimes(1);
expect(
localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)
).toBe('1');
+26 -15
View File
@@ -346,21 +346,32 @@ export class PlaylistsService {
return;
}
const storedPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
const updates =
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
if (updates.length > 0) {
await firstValueFrom(
combineLatest(
updates.map((playlist) =>
this.dbService.update(DbStores.Playlists, playlist)
)
)
);
}
await new Promise<void>((resolve, reject) => {
this.dbService
.openCursor<Playlist>({
storeName: DbStores.Playlists,
mode: DBMode.readwrite,
})
.subscribe({
next: (cursor) => {
try {
const migratedPlaylist =
this.withExplicitLegacyStalkerPortalFlag(
cursor.value
);
if (migratedPlaylist !== cursor.value) {
cursor.update(migratedPlaylist);
}
cursor.continue();
} catch (error) {
cursor.request.transaction?.abort();
reject(error);
}
},
error: reject,
complete: resolve,
});
});
this.writeIndexedDbMigrationFlag(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG