From 797aeae307087a7a46d6e7a0a0770eae0c47a239 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 15:19:13 +0200 Subject: [PATCH] fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move The Stalker metadata migration wrote server rows back through the unconditional bulk upsert, from the list read: a device that edited a Stalker playlist in between (or a second browser migrating the same copied database) lost its change. Server rows are now read again one by one and written as conditional mutations on that version; a conflict leaves the flag unset, so the next start retries. Moving a document's history kept the server's copy of an item both held, even when the browser had viewed it later, and skipped the write when the length did not change. The combined history is now newest first by added_at, one entry per item, and any content change is written. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 6 +- .../src/lib/playlists.service.spec.ts | 121 +++++++++++------- libs/services/src/lib/playlists.service.ts | 15 ++- .../server-storage/server-migration-merge.ts | 17 ++- .../server-migration.service.spec.ts | 49 +++++++ .../server-migration.service.ts | 25 +++- 6 files changed, 170 insertions(+), 63 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index b8d08ee9e..276ae0735 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -357,13 +357,15 @@ settings and guards read them in every build. playlists this browser kept in IndexedDB (`planServerMigration`), Stalker rows from before the portal-mode flag normalized first (server rows get the same Stalker metadata migration once per server, as a desktop - database copied there can predate it). A + database copied there can predate it: each row read again and written as + a conditional mutation, a conflict retried at the next start). A playlist the server already has, by id or by source (Xtream server and user, Stalker portal and MAC, M3U URL), merges into the server's copy; the others upload with their ids. A merged playlist gains the browser document's favorites and history (M3U favorites name channels by stream URL; older ones by a channel id that is random per import and is - translated through the URL; Stalker items by id, category and series + translated through the URL; recently viewed newest first by `added_at`, + one per item with its newest view; Stalker items by id, category and series flag, since one provider category can hold a movie and a series of one id; nothing is dropped), and the legacy Xtream maps follow under the server id; each copy's bare legacy Xtream ids are typed from its own diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index d5eb14db4..78c9723e4 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -578,58 +578,83 @@ describe('PlaylistsService', () => { ); }); - it('migrates legacy Stalker portal flags on the server too', async () => { + describe('legacy Stalker portal flags on the server', () => { // A desktop database copied to the server, from before the flag. - let storedPlaylists: Playlist[] = [ - { - _id: 'stalker-1', - title: 'Copied Stalker', - count: 0, - importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), - lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(), - autoRefresh: false, - macAddress: '00:1A:79:AA:BB:CC', - portalUrl: 'http://example.com/stalker_portal/c/', - } as Playlist, - ]; - const appState = new Map(); - const server = { - dbGetAppPlaylists: jest.fn(async () => storedPlaylists), - dbGetAppState: jest.fn( - async (key: string) => appState.get(key) ?? null - ), - dbSetAppState: jest.fn(async (key: string, value: string) => { - appState.set(key, value); - }), - dbUpsertAppPlaylist: jest.fn(), - dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => { - storedPlaylists = playlists; - }), - dbRecoverLegacyPlaylists: jest.fn(), - }; - testWindow.electron = undefined; - const service = createService(); - Object.assign(service as object, { - storageBackend: { - kind: () => 'server', - documentStore: () => server, - }, + const copied = { + _id: 'stalker-1', + title: 'Copied Stalker', + count: 0, + importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(), + autoRefresh: false, + macAddress: '00:1A:79:AA:BB:CC', + portalUrl: 'http://example.com/stalker_portal/c/', + } as Playlist; + + function serverService( + fresh: Playlist, + upsert: jest.Mock = jest.fn(async () => ({ success: true })) + ) { + const appState = new Map(); + const server = { + dbGetAppPlaylists: jest.fn(async () => [copied]), + // Another device may have changed it since the list read. + dbGetAppPlaylist: jest.fn(async () => fresh), + dbGetAppState: jest.fn( + async (key: string) => appState.get(key) ?? null + ), + dbSetAppState: jest.fn(async (key: string, value: string) => { + appState.set(key, value); + }), + dbUpsertAppPlaylist: upsert, + dbUpsertAppPlaylists: jest.fn(), + dbRecoverLegacyPlaylists: jest.fn(), + }; + testWindow.electron = undefined; + const service = createService(); + Object.assign(service as object, { + storageBackend: { + kind: () => 'server', + documentStore: () => server, + }, + }); + return { service, server, appState }; + } + + it('are written per row on a fresh read, as conditional mutations', async () => { + const edited = { ...copied, title: 'Renamed elsewhere' }; + const { service, server, appState } = serverService(edited); + + await firstValueFrom(service.getAllPlaylists()); + + // Never the stale list copy, never an unconditional bulk write. + expect(server.dbUpsertAppPlaylists).not.toHaveBeenCalled(); + expect(server.dbUpsertAppPlaylist).toHaveBeenCalledWith({ + ...edited, + isFullStalkerPortal: true, + }); + expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe( + '1' + ); + // The desktop-only steps stay with the desktop. + expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled(); }); - await expect( - firstValueFrom(service.getAllPlaylists()) - ).resolves.toEqual([ - expect.objectContaining({ - _id: 'stalker-1', - isFullStalkerPortal: true, - }), - ]); + it('stay unflagged for the next start after a conflict', async () => { + const { service, appState } = serverService( + copied, + jest.fn(async () => { + throw new PlaylistWriteConflict('stalker-1'); + }) + ); + jest.spyOn(console, 'error').mockImplementation(() => undefined); - expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe( - '1' - ); - // The desktop-only steps stay with the desktop. - expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled(); + await firstValueFrom(service.getAllPlaylists()); + + expect( + appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) + ).toBeUndefined(); + }); }); it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => { diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 1cd0821e4..2f8943982 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -304,7 +304,20 @@ export class PlaylistsService { const updates = this.collectStalkerMetadataMigrationUpdates(storedPlaylists); - if (updates.length > 0) { + if (this.storageBackend.kind() === 'server') { + // Shared with other devices: each row is read again and + // written on that version, never over a newer copy. A + // conflict leaves the flag unset, so the next start retries. + for (const { _id } of updates) { + const fresh = await store.dbGetAppPlaylist(_id); + const [update] = fresh + ? this.collectStalkerMetadataMigrationUpdates([fresh]) + : []; + if (update) { + await store.dbUpsertAppPlaylist(update); + } + } + } else if (updates.length > 0) { await store.dbUpsertAppPlaylists(updates); } diff --git a/libs/services/src/lib/server-storage/server-migration-merge.ts b/libs/services/src/lib/server-storage/server-migration-merge.ts index de893cb62..ece71880b 100644 --- a/libs/services/src/lib/server-storage/server-migration-merge.ts +++ b/libs/services/src/lib/server-storage/server-migration-merge.ts @@ -151,12 +151,19 @@ export function combineRecentViews(values: readonly unknown[]): unknown { ); } -/** Items with a `viewedAt` (recently viewed), newest first; stable. */ -export function newestViewsFirst(items: readonly T[]): T[] { +/** + * Recently viewed items newest first, by `viewedAt` (Xtream maps) or + * `added_at` (playlist documents: an ISO date or epoch milliseconds); + * stable, undated items after dated ones. + */ +export function newestViewsFirst( + items: readonly T[], + field: 'viewedAt' | 'added_at' = 'viewedAt' +): T[] { const viewedAt = (item: unknown) => { - const at = isPlainObject(item) - ? Date.parse(String(item['viewedAt'])) - : NaN; + const value = isPlainObject(item) ? item[field] : undefined; + const at = + typeof value === 'number' ? value : Date.parse(String(value)); return Number.isNaN(at) ? 0 : at; }; return [...items].sort((a, b) => viewedAt(b) - viewedAt(a)); diff --git a/libs/services/src/lib/server-storage/server-migration.service.spec.ts b/libs/services/src/lib/server-storage/server-migration.service.spec.ts index 14ec2b339..e960e902b 100644 --- a/libs/services/src/lib/server-storage/server-migration.service.spec.ts +++ b/libs/services/src/lib/server-storage/server-migration.service.spec.ts @@ -300,6 +300,55 @@ describe('ServerMigrationService', () => { }); }); + it("keeps a document's newest view of an item both copies hold", async () => { + const view = (day: number, extra = {}) => ({ + source: 'm3u', + id: 'b1', + url: 'http://s/1', + title: 'One', + category_id: 'live', + added_at: `2026-10-0${day}T20:00:00.000Z`, + ...extra, + }); + TestBed.overrideProvider(NgxIndexedDBService, { + useValue: { + getAll: () => + of([ + { + ...browserPlaylists[0], + recentlyViewed: [ + view(3, { poster_url: 'new.png' }), + ], + playlist: { + items: [{ id: 'b1', url: 'http://s/1' }], + }, + }, + ]), + }, + }); + session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([ + { _id: 'server-m', title: 'Family', url: browserPlaylists[0].url }, + ]); + const stored = { + _id: 'server-m', + title: 'Family', + url: browserPlaylists[0].url, + favorites: [], + // The same channel, watched there before this browser's view. + recentlyViewed: [view(1, { id: 's1' })], + playlist: { items: [{ id: 's1', url: 'http://s/1' }] }, + }; + session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored); + + await TestBed.inject(ServerMigrationService).moveBrowserData(); + + // Not skipped for an unchanged length: the newer view replaces it. + expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({ + ...stored, + recentlyViewed: [view(3, { id: 's1', poster_url: 'new.png' })], + }); + }); + it('keeps the move on offer when a playlist could not be uploaded', async () => { session.documentStore.dbUpsertAppPlaylist .mockRejectedValueOnce(new Error('413')) diff --git a/libs/services/src/lib/server-storage/server-migration.service.ts b/libs/services/src/lib/server-storage/server-migration.service.ts index 2afca4930..d7c7ac5d7 100644 --- a/libs/services/src/lib/server-storage/server-migration.service.ts +++ b/libs/services/src/lib/server-storage/server-migration.service.ts @@ -12,6 +12,7 @@ import { import { combineRecentViews, combineUserData, + newestViewsFirst, remapChannelReferences, retargetUserData, unionItems, @@ -178,18 +179,28 @@ export class ServerMigrationService { serverChannels ) ), + // Newest first, one per item: its newest view (the server's on + // a tie), as the app keeps the list. recentlyViewed: unionItems( - stored.recentlyViewed ?? [], - remapChannelReferences( - recentlyViewed, - browserChannels, - serverChannels + newestViewsFirst( + [ + ...(stored.recentlyViewed ?? []), + ...remapChannelReferences( + recentlyViewed, + browserChannels, + serverChannels + ), + ], + 'added_at' ) ), }; + // Also when only a newer view of an item it has changed. if ( - next.favorites.length !== (stored.favorites ?? []).length || - next.recentlyViewed.length !== (stored.recentlyViewed ?? []).length + JSON.stringify(next.favorites) !== + JSON.stringify(stored.favorites ?? []) || + JSON.stringify(next.recentlyViewed) !== + JSON.stringify(stored.recentlyViewed ?? []) ) { await this.session.documentStore.dbUpsertAppPlaylist(next); }