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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 19:41:34 +02:00
1 parent b4eedb0d8d
commit be2962f97d
10 files changed
+123 -21

No files matched your search

@@ -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')
@@ -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<Storage, 'getItem'> | null
): RememberedServer | null {
+3 -2
View File
@@ -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`:
+20 -3
View File
@@ -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<T>(
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))];
}
@@ -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']);
});
});
@@ -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<string, unknown> {
const value: Record<string, unknown> = { ...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<string, unknown>,
next: Record<string, unknown>
): Record<string, unknown> {
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 = <T>(map: Record<string, T> = {}): Record<string, T> => {
// No prototype: a playlist id such as `__proto__` stays a key.
const kept: Record<string, T> = Object.create(null);
const kept = idMap<T>();
for (const [id, value] of Object.entries(map)) {
if (!deleted.some((gone) => gone === undefined || gone === id)) {
kept[id] = value;
@@ -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<T>(
source: Readonly<Record<string, T>> = {}
): Record<string, T> {
return Object.assign(Object.create(null) as Record<string, T>, source);
}
export function parseMap(value: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
return idMap(
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
);
} catch {
return {};
return idMap();
}
}
@@ -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;
}
@@ -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"]}')
);
});
});
@@ -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<Record<string, string>>
): void {
this.tags.set(namespace, { ...tags });
this.tags.set(namespace, idMap(tags));
}
/** `undefined` until the server's tags are known (or not offered). */