Fix real data loss: two catalog queries were silently truncating at 300 #2

Merged
kyuno merged 2 commits from fix/catalog-data-loss into master 2026-09-15 22:52:10 +02:00
2 changed files with 46 additions and 21 deletions
Showing only changes of commit 05c4611403 - Show all commits

View file

@ -96,15 +96,26 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
} }
/** /**
* Renders `items` into `container` a chunk at a time, across animation * Renders `items` into `container` a chunk at a time instead of one
* frames, instead of one `innerHTML` write for the whole list - a library * `innerHTML` write for the whole list - a library grid can legitimately
* grid can hand back up to 300 tiles at once (CatalogService.getLibraryItems/ * hand back over a thousand tiles now that CatalogService.getLibraryItems/
* getArtists), and building + inserting/laying out ~300 posters in a * getArtists no longer cap themselves (confirmed against the real server:
* single synchronous task is exactly what a "the page freezes while it * one library alone has 1229 artists - see their own comments), and
* loads" report looks like on a slower device. Splitting the same HTML * building + inserting/laying out that many in a single synchronous task
* across a few chunks keeps every individual task well under a frame's * is exactly what a "the page freezes while it loads" report looks like
* budget, so the tab stays responsive (and the loading spinner keeps * on a slower device. Splitting the same HTML across a few chunks keeps
* animating) while it works through the rest. * every individual task well under a frame's budget, so the tab stays
* responsive (and the loading spinner keeps animating) while it works
* through the rest.
*
* Scheduled with `setTimeout(fn, 0)`, not `requestAnimationFrame` - rAF
* callbacks are deliberately paused by the browser for a backgrounded/
* hidden tab, which is a completely ordinary thing to do while a large
* library is still loading (switching tabs, minimizing the window) and
* would otherwise stall the render indefinitely, looking exactly like
* "some of the data is missing" rather than "still loading". setTimeout
* keeps running in a background tab (only throttled, never paused), so
* this always finishes on its own.
* *
* Uses `insertAdjacentHTML`, not repeated `innerHTML +=` (which would * Uses `insertAdjacentHTML`, not repeated `innerHTML +=` (which would
* re-parse and re-render everything already inserted on every chunk). * re-parse and re-render everything already inserted on every chunk).
@ -120,7 +131,7 @@ export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], ch
if (!chunk.length) return; if (!chunk.length) return;
container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join('')); container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join(''));
i += chunkSize; i += chunkSize;
if (i < items.length) requestAnimationFrame(renderNextChunk); if (i < items.length) setTimeout(renderNextChunk, 0);
} }
renderNextChunk(); renderNextChunk();
} }

View file

@ -199,11 +199,13 @@ class CatalogServiceClass {
// structure, same reasoning as Shows -> Seasons -> Episodes above. // structure, same reasoning as Shows -> Seasons -> Episodes above.
async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> { async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
// Limit added for the same reason as getLibraryItems's own - unbounded // No Limit, deliberately - see getLibraryItems's own comment below for
// on a large music library, this used to hand back every artist at // why one very nearly shipped here and got pulled: confirmed directly
// once for the client to render in a single pass (see tiles.ts's // against the real server that this library alone has 1229 artists,
// renderTilesInChunks for the render-side half of that fix). // so a 300 cap would have silently dropped 75% of them, not just
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Limit: '300' }); // trimmed an edge case. Rendering that many is tiles.ts's
// renderTilesInChunks's job, not this query's.
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.'); const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId)); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
} }
@ -238,17 +240,29 @@ class CatalogServiceClass {
// renders tileHtml's poster/name/year (views/library, tiles.ts), which // renders tileHtml's poster/name/year (views/library, tiles.ts), which
// never reads it. Traced here after "the catalog API is too slow, the // never reads it. Traced here after "the catalog API is too slow, the
// front freezes while it loads" - Emby happily serializes a full // front freezes while it loads" - Emby happily serializes a full
// synopsis for all 300 items, and the client happily JSON.parses all // synopsis for every item anyway, and the client happily JSON.parses
// of it, entirely to be thrown away unread. Contrast getSeriesEpisodes/ // all of it, entirely to be thrown away unread. Contrast
// getPlaylistItemsRaw, which keep Overview because their own views // getSeriesEpisodes/getPlaylistItemsRaw, which keep Overview because
// (Series/Playlist) actually display it. // their own views (Series/Playlist) actually display it.
//
// No Limit - there used to be one here (300), presumably meant as the
// same defensive cap getArtists briefly grew (see its own comment).
// Confirmed directly against the real server that this app's own
// "Films" library alone has 421 items - past that cap, so it was
// quietly serving an incomplete grid with no error and no hint
// anything was missing, reported as "the catalog is broken, not all
// the data is there". Emby returns everything when Limit is omitted
// (confirmed: TotalRecordCount matched Items.length exactly with no
// Limit param, for both this library and the 1229-artist one above) -
// rendering that many tiles without freezing the tab is
// renderTilesInChunks's job (tiles.ts), not something this query
// should be truncating data to work around.
const params = new URLSearchParams({ const params = new URLSearchParams({
ParentId: libraryId, ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName', SortBy: 'SortName',
Fields: 'ProductionYear,AlbumId', Fields: 'ProductionYear,AlbumId',
ImageTypeLimit: '1', ImageTypeLimit: '1'
Limit: '300'
}); });
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.'); const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.');
return (data.Items || []).map((i) => this.toItemDto(i, session.serverId)); return (data.Items || []).map((i) => this.toItemDto(i, session.serverId));