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); }