From be2962f97dfc7b0a6334a5b0e7478f7af157708f Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 19:41:34 +0200 Subject: [PATCH] fix(server-storage): reorders on the stored favorites, URL-insensitive remembered server, prototype-free id maps - PlaylistEffects.setFavorites$ applies the drag's order through transformPlaylistFavorites (reorderFavorites): favorites added on another device stay, ones removed there stay out. - The remembered server matches the backend URL by origin and path without trailing slashes, so /api and /api/ start offline in server mode alike. - Maps keyed by playlist id are built without a prototype (idMap), so an id such as __proto__ is an ordinary key in the cache, conflict replays, re-reads and tags. Co-Authored-By: Claude Opus 5.5 --- .../server-storage-start.spec.ts | 13 ++++++++ .../server-storage/server-storage-start.ts | 17 +++++++++- docs/architecture/pwa-self-hosted.md | 5 +-- libs/m3u-state/src/lib/effects.ts | 23 ++++++++++++-- .../src/lib/favorites.effect.spec.ts | 15 +++++++++ .../server-storage/server-user-data-edit.ts | 14 +++------ .../server-user-data-entries.ts | 20 +++++++++--- .../server-user-data-storage.ts | 3 +- .../server-user-data-sync.spec.ts | 31 +++++++++++++++++++ .../server-storage/server-user-data-tags.ts | 3 +- 10 files changed, 123 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/server-storage/server-storage-start.spec.ts b/apps/web/src/app/server-storage/server-storage-start.spec.ts index bcaac3a40..025a47a69 100644 --- a/apps/web/src/app/server-storage/server-storage-start.spec.ts +++ b/apps/web/src/app/server-storage/server-storage-start.spec.ts @@ -32,6 +32,19 @@ describe('resolveServerStorageStart', () => { ).toEqual({ instance, startedOffline: true }); }); + it('matches the remembered server however its URL is written', () => { + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + + for (const spelling of ['/api/', ' /api', `${location.origin}/api//`]) { + expect( + resolveServerStorageStart({ kind: 'unreachable' }, spelling) + ).toEqual({ instance, startedOffline: true }); + } + expect( + resolveServerStorageStart({ kind: 'unreachable' }, '/apis') + ).toBeNull(); + }); + it('uses browser mode for an unknown or different server', () => { expect( resolveServerStorageStart({ kind: 'unreachable' }, '/api') diff --git a/apps/web/src/app/server-storage/server-storage-start.ts b/apps/web/src/app/server-storage/server-storage-start.ts index 4b0e8fdb8..5064c2df1 100644 --- a/apps/web/src/app/server-storage/server-storage-start.ts +++ b/apps/web/src/app/server-storage/server-storage-start.ts @@ -39,7 +39,8 @@ export function resolveServerStorageStart( return null; case 'unreachable': { const remembered = read(storage); - return remembered?.baseUrl === baseUrl + return remembered && + backendIdentity(remembered.baseUrl) === backendIdentity(baseUrl) ? { instance: remembered.instance, startedOffline: true } : null; } @@ -48,6 +49,20 @@ export function resolveServerStorageStart( } } +/** + * Origin and path of a backend URL, without trailing slashes: `/api` and + * `/api/` (or the same URL written absolute) name one server, as they do + * for the probe and the API client. + */ +function backendIdentity(url: string): string { + try { + const resolved = new URL(url.trim(), globalThis.location?.href); + return `${resolved.origin}${resolved.pathname.replace(/\/+$/, '')}`; + } catch { + return url.trim().replace(/\/+$/, ''); + } +} + function read( storage: Pick | null ): RememberedServer | null { diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index f6858302e..d631958fd 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -210,8 +210,9 @@ settings and guards read them in every build. refuses a document without an id. "Remove all" first lets queued playlist writes land, and writes issued meanwhile wait until it is done. An M3U favorite - toggle goes through `transformPlaylistFavorites`, adding or removing that - one channel on the favorites read at write time. After a write the store + toggle or reorder goes through `transformPlaylistFavorites`: a toggle adds + or removes that one channel, a reorder applies its order, on the + favorites read at write time. After a write the store keeps its copy only when `X-Replaced-ETag` names the version its payload came from. - **Xtream user data.** `USER_DATA_STORAGE` becomes `ServerUserDataStorage`: diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 2dc688530..efe97c0ce 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -122,10 +122,13 @@ export class PlaylistEffects { ofType(FavoritesActions.setFavorites), withLatestFrom(this.store.select(selectActivePlaylistId)), filter(([, playlistId]) => !!playlistId), - switchMap(([action, playlistId]) => - this.playlistsService.setFavorites( + // The new order, applied to the favorites as stored at write + // time: another device's additions stay, its removals hold. + mergeMap(([action, playlistId]) => + this.playlistsService.transformPlaylistFavorites( playlistId, - action.channelIds + (current) => + reorderFavorites(current, action.channelIds) ) ) ); @@ -594,3 +597,17 @@ export class PlaylistEffects { return normalizeEpgUrls(this.settingsStore.getSettings().epgUrl ?? []); } } + +/** + * `current` in the order of `order`: entries `order` no longer has stay out + * of its part, entries it never had (added elsewhere) follow it. + */ +export function reorderFavorites( + current: readonly T[], + order: readonly T[] +): T[] { + const present = new Set(current); + const ordered = order.filter((id) => present.has(id)); + const placed = new Set(ordered); + return [...ordered, ...current.filter((id) => !placed.has(id))]; +} diff --git a/libs/m3u-state/src/lib/favorites.effect.spec.ts b/libs/m3u-state/src/lib/favorites.effect.spec.ts index 4a1a67bb5..aa94b3962 100644 --- a/libs/m3u-state/src/lib/favorites.effect.spec.ts +++ b/libs/m3u-state/src/lib/favorites.effect.spec.ts @@ -64,6 +64,7 @@ describe('PlaylistEffects updateFavorites$', () => { () => new PlaylistEffects() ); effects.updateFavorites$.subscribe(); + effects.setFavorites$.subscribe(); } const channel = { url: 'http://a' } as Channel; @@ -90,4 +91,18 @@ describe('PlaylistEffects updateFavorites$', () => { expect(storedFavorites).toEqual(['http://b']); }); + + it('applies a reorder to the stored favorites', () => { + // Another device added 'http://new' and removed 'http://gone'. + storedFavorites = ['http://a', 'http://b', 'http://new']; + setup([]); + + actions$.next( + FavoritesActions.setFavorites({ + channelIds: ['http://b', 'http://gone', 'http://a'], + }) + ); + + expect(storedFavorites).toEqual(['http://b', 'http://a', 'http://new']); + }); }); diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.ts b/libs/services/src/lib/server-storage/server-user-data-edit.ts index 398fe26a8..96c0a16b2 100644 --- a/libs/services/src/lib/server-storage/server-user-data-edit.ts +++ b/libs/services/src/lib/server-storage/server-user-data-edit.ts @@ -4,7 +4,7 @@ import { type ServerUserStateResponse, } from '@iptvnator/shared/interfaces'; import { itemKey } from './server-migration-merge'; -import type { EditStep } from './server-user-data-entries'; +import { type EditStep, idMap } from './server-user-data-entries'; /** Edits made before the first read, applied once the server's value is known. */ export interface DeferredEdit { @@ -26,7 +26,7 @@ export function refreshedNamespace( /** What an early edit was made on; the server's entry by default. */ baseOf: (playlistId: string) => unknown = (playlistId) => server[playlistId] ): Record { - const value: Record = { ...server }; + const value = idMap(server); for (const playlistId of keepCached) { if (playlistId in cached) { value[playlistId] = cached[playlistId]; @@ -135,7 +135,7 @@ export function mergeEdits( read: Record, next: Record ): Record { - const merged = { ...current }; + const merged = idMap(current); for (const id of new Set([...Object.keys(read), ...Object.keys(next)])) { if (JSON.stringify(read[id]) !== JSON.stringify(next[id])) { const value = applyEdit(current[id], read[id], next[id]); @@ -151,18 +151,14 @@ export function mergeEdits( /** * A server read without the playlists deleted after it was sent (an id of - * `undefined` stands for every playlist). + * `undefined` stands for every playlist), its maps as `idMap`s. */ export function withoutDeleted( read: ServerUserStateResponse, deleted: readonly (string | undefined)[] ): ServerUserStateResponse { - if (deleted.length === 0) { - return read; - } const keep = (map: Record = {}): Record => { - // No prototype: a playlist id such as `__proto__` stays a key. - const kept: Record = Object.create(null); + const kept = idMap(); for (const [id, value] of Object.entries(map)) { if (!deleted.some((gone) => gone === undefined || gone === id)) { kept[id] = value; diff --git a/libs/services/src/lib/server-storage/server-user-data-entries.ts b/libs/services/src/lib/server-storage/server-user-data-entries.ts index edd85171f..dfad7526a 100644 --- a/libs/services/src/lib/server-storage/server-user-data-entries.ts +++ b/libs/services/src/lib/server-storage/server-user-data-entries.ts @@ -95,13 +95,25 @@ export function entryPath(entry: PendingEntry): string { return `/user-state/${entry.namespace}/${encodeURIComponent(entry.playlistId)}`; } +/** + * A map keyed by playlist id, without a prototype: an id such as + * `__proto__` is an ordinary key, both to set and to read when absent. + */ +export function idMap( + source: Readonly> = {} +): Record { + return Object.assign(Object.create(null) as Record, source); +} + export function parseMap(value: string): Record { try { const parsed: unknown = JSON.parse(value); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {}; + return idMap( + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + ); } catch { - return {}; + return idMap(); } } diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index d8e710e27..7afa73e34 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -8,6 +8,7 @@ import type { UserDataStorage } from '../user-data-storage'; import type { ServerApiClient } from './server-api-client'; import { addStep, + idMap, parseMap, splitEditKey, UNKNOWN_TAG, @@ -376,7 +377,7 @@ export class ServerUserDataStorage implements UserDataStorage { if (tags) { // A kept value is not what the server's tag describes: its // next write must conflict and be replayed on the server's. - const fresh = { ...tags[namespace] }; + const fresh = idMap(tags[namespace]); for (const playlistId of keepCached) { fresh[playlistId] = UNKNOWN_TAG; } diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts index 05c874071..521821207 100644 --- a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -1249,4 +1249,35 @@ describe('ServerUserDataStorage with other devices', () => { expect(favorites(phone)).toEqual({ p2: ['b'] }); }); + + it('keeps a playlist whose id is __proto__', async () => { + const server = fakeServer({ + 'xtream-favorites': JSON.parse('{"__proto__":["a"],"p2":["b"]}'), + }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + expect(Object.keys(favorites(phone))).toEqual(['__proto__', 'p2']); + + const map = favorites(phone); + Object.defineProperty(map, '__proto__', { + value: ['a', 'c'], + enumerable: true, + }); + phone.setItem('xtream-favorites', JSON.stringify(map)); + await phone.flush(); + + expect( + Object.getOwnPropertyDescriptor( + server.state['xtream-favorites'], + '__proto__' + )?.value + ).toEqual(['a', 'c']); + expect(JSON.parse(JSON.stringify(favorites(phone)))).toEqual( + JSON.parse('{"__proto__":["a","c"],"p2":["b"]}') + ); + }); }); diff --git a/libs/services/src/lib/server-storage/server-user-data-tags.ts b/libs/services/src/lib/server-storage/server-user-data-tags.ts index 7172eea77..880793a3b 100644 --- a/libs/services/src/lib/server-storage/server-user-data-tags.ts +++ b/libs/services/src/lib/server-storage/server-user-data-tags.ts @@ -1,4 +1,5 @@ import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import { idMap } from './server-user-data-entries'; /** The server version tag of each user-state entry, by namespace. */ export class UserStateTags { @@ -12,7 +13,7 @@ export class UserStateTags { namespace: ServerUserStateNamespace, tags: Readonly> ): void { - this.tags.set(namespace, { ...tags }); + this.tags.set(namespace, idMap(tags)); } /** `undefined` until the server's tags are known (or not offered). */