mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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 {
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user