fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies

- Moving M3U favorites keeps URL favorites and anything the server cannot
  resolve; only older channel ids are translated.
- Queued user-data edits are sent before the move writes combined values.
- A re-read that does not fit the cache keeps the old cache and baseline
  together.
- A response body cut off after its headers counts as offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 07:00:07 +02:00
1 parent 4afd4bcc33
commit 72c84a3eba
11 files changed
+141 -35

No files matched your search

@@ -99,6 +99,33 @@ describe('ServerApiClient', () => {
).resolves.toBe(false);
});
it('reports offline when a body is cut off after the headers', async () => {
const cut = {
...response(200),
json: async () => {
throw new TypeError('network error');
},
} as unknown as Response;
await expect(
client(jest.fn().mockResolvedValue(cut)).getJson('/user-state')
).rejects.toMatchObject({ status: 0, code: 'network' });
expect(status.reportOffline).toHaveBeenCalled();
// Malformed JSON is an answer, not an outage.
status.reportOffline.mockClear();
const malformed = {
...response(200),
json: async () => {
throw new SyntaxError('Unexpected token');
},
} as unknown as Response;
await expect(
client(jest.fn().mockResolvedValue(malformed)).getJson('/x')
).rejects.toBeInstanceOf(SyntaxError);
expect(status.reportOffline).not.toHaveBeenCalled();
});
it('clears the token and reports unauthorized on 401', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
const fetchFn = jest
@@ -107,7 +107,27 @@ export class ServerApiClient {
}
async getJson<T>(path: string): Promise<T> {
return (await this.request(path)).json() as Promise<T>;
return this.readJson<T>(await this.request(path));
}
/**
* A body cut off after the headers arrived is an outage too; malformed
* JSON is not.
*/
async readJson<T>(response: Response): Promise<T> {
try {
return (await response.json()) as T;
} catch (error) {
if (error instanceof SyntaxError) {
throw error;
}
this.options.status.reportOffline();
throw new ServerApiError(
0,
'network',
'The server is not reachable.'
);
}
}
sendJson(
@@ -119,10 +119,20 @@ describe('remapChannelReferences', () => {
it('names the server channel with the same stream URL', () => {
expect(
remapChannelReferences(['b2', 'b1', 'b3', 's1'], browser, server)
remapChannelReferences(['b2', 'b1', 's1'], browser, server)
).toEqual(['s2', 's1', 's1']);
});
it('keeps URL favorites and anything it cannot resolve', () => {
expect(
remapChannelReferences(
['http://s/1', 'http://elsewhere/9', 'b3'],
browser,
server
)
).toEqual(['http://s/1', 'http://elsewhere/9', 'b3']);
});
it('updates M3U history entries and passes portal items through', () => {
const portal = { id: 7, category_id: 'itv' };
expect(
@@ -77,10 +77,11 @@ interface ChannelRef {
}
/**
* M3U favorites and history name channels by id, and every import gives
* channels new random ids: a browser favorite is translated to the server
* channel with the same stream URL, and dropped when there is none.
* Portal favorites (objects) pass through.
* M3U favorites name channels by stream URL (older ones by channel id),
* and every import gives channels new random ids. A browser channel id is
* translated to the server channel with the same URL; URLs and anything
* the server cannot resolve are kept as they are, never dropped. M3U
* history entries get the server's channel id; portal items pass through.
*/
export function remapChannelReferences<T>(
references: readonly T[],
@@ -94,29 +95,23 @@ export function remapChannelReferences<T>(
const idByUrl = new Map<unknown, unknown>(
serverChannels.map((channel) => [channel.url, channel.id])
);
const result: T[] = [];
for (const reference of references) {
return references.map((reference) => {
if (typeof reference === 'string') {
const id = serverIds.has(reference)
? reference
: idByUrl.get(urlByBrowserId.get(reference));
if (typeof id === 'string') {
result.push(id as T);
if (serverIds.has(reference) || idByUrl.has(reference)) {
return reference;
}
} else if (
const id = idByUrl.get(urlByBrowserId.get(reference));
return (typeof id === 'string' ? id : reference) as T;
}
if (
isPlainObject(reference) &&
reference['source'] === 'm3u' &&
idByUrl.has(reference['url'])
typeof idByUrl.get(reference['url']) === 'string'
) {
result.push({
...reference,
id: idByUrl.get(reference['url']),
} as T);
} else {
result.push(reference);
return { ...reference, id: idByUrl.get(reference['url']) } as T;
}
}
return result;
return reference;
});
}
/**
@@ -23,7 +23,7 @@ describe('ServerMigrationService', () => {
dbUpsertAppPlaylist: jest.Mock;
};
api: { getJson: jest.Mock; sendJson: jest.Mock };
userData: { hydrate: jest.Mock };
userData: { flush: jest.Mock; hydrate: jest.Mock };
};
beforeEach(() => {
@@ -40,7 +40,10 @@ describe('ServerMigrationService', () => {
getJson: jest.fn().mockResolvedValue({ entries: {} }),
sendJson: jest.fn().mockResolvedValue(undefined),
},
userData: { hydrate: jest.fn().mockResolvedValue(undefined) },
userData: {
flush: jest.fn().mockResolvedValue(undefined),
hydrate: jest.fn().mockResolvedValue(undefined),
},
};
TestBed.configureTestingModule({
providers: [
@@ -104,6 +107,10 @@ describe('ServerMigrationService', () => {
{ value: ['live:1'] }
);
expect(session.api.sendJson).toHaveBeenCalledTimes(1);
// Queued edits were sent before the combined values were read.
expect(session.userData.flush.mock.invocationCallOrder[0]).toBeLessThan(
session.api.getJson.mock.invocationCallOrder[0]
);
expect(session.userData.hydrate).toHaveBeenCalled();
expect(migration.browserDataMoved()).toBe(true);
});
@@ -119,6 +119,8 @@ export class ServerMigrationService {
}
}
// Queued edits go out first, so none lands after the combined values.
await this.session.userData.flush();
failed += await this.copyXtreamUserData(targets);
await this.session.userData.hydrate();
if (failed === 0) {
@@ -32,6 +32,7 @@ describe('ServerPlaylistDocumentStore', () => {
const api = {
request: jest.fn(),
getJson: jest.fn(),
readJson: jest.fn((response: Response) => response.json()),
sendJson: jest.fn(),
};
return { api, store: new ServerPlaylistDocumentStore(api as never) };
@@ -13,7 +13,10 @@ interface CachedDocument {
readonly document: Playlist;
}
type ServerApi = Pick<ServerApiClient, 'request' | 'getJson' | 'sendJson'>;
type ServerApi = Pick<
ServerApiClient,
'request' | 'getJson' | 'readJson' | 'sendJson'
>;
/**
* `PlaylistDocumentStore` over the server storage API. Full documents are
@@ -56,7 +59,7 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
if (response.status === 304 && cached) {
return this.serve(cached.document, cached.etag);
}
const document = (await response.json()) as Playlist;
const document = await this.api.readJson<Playlist>(response);
const etag = this.remember(playlistId, response, document);
return this.serve(document, etag);
}
@@ -373,4 +373,37 @@ describe('ServerUserDataStorage', () => {
await storage.flush();
expect(api.sendJson).not.toHaveBeenCalled();
});
it('keeps cache and baseline together when the cache is full', async () => {
const { api, local, storage } = setup();
await storage.hydrate();
// The server now has a second playlist the cache cannot take.
api.getJson.mockResolvedValueOnce({
entries: {
'xtream-favorites': { p1: ['live:1'], p2: ['live:2'] },
},
});
const setItem = local.setItem;
local.setItem = (key: string, value: string) => {
if (key === 'iptvnator-server:xtream-favorites') {
throw new DOMException('full', 'QuotaExceededError');
}
setItem(key, value);
};
await storage.hydrate();
local.setItem = setItem;
storage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['live:1', 'live:3'] })
);
await storage.flush();
expect(api.request).not.toHaveBeenCalled();
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['live:1', 'live:3'] }
);
});
});
@@ -128,11 +128,17 @@ export class ServerUserDataStorage implements UserDataStorage {
delete value[playlistId];
}
}
try {
this.local.setItem(
SERVER_CACHE_PREFIX + namespace,
JSON.stringify(value)
);
} catch {
// Storage full: keep the old cache and the baseline it was
// diffed against together, or an edit would read as deletes.
continue;
}
this.synced.set(namespace, value);
this.local.setItem(
SERVER_CACHE_PREFIX + namespace,
JSON.stringify(value)
);
}
for (const [key, edit] of this.edits) {
if (