From 05c4611403ab047185ca065793fcf2fab95a1cc4 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 22:48:44 +0200 Subject: [PATCH] Fix real data loss: two catalog queries were silently truncating at 300 Reported right after the previous PR merged: "the catalogs are broken, not all the data is there". Confirmed directly against the real Emby server, not guessed - this app's own "Musique" library has 1229 distinct artists and its "Films" library has 421 movies. getArtists gained a 300-item Limit in that same previous PR (meant defensively, alongside the render-side fix below); getLibraryItems already had one, apparently written before this - both were silently dropping the tail of the list with no error and no visible sign anything was missing, which is exactly what "some of the data is gone" looks like from the outside. Removed both. Confirmed empirically that omitting Limit entirely makes Emby return everything (TotalRecordCount matched Items.length exactly for both the 421-item and 1229-item cases, no server-side default cap kicking in) - there was never a reason for either app-side number beyond guarding the render path, and that guard already exists elsewhere: tiles.ts's renderTilesInChunks (the previous PR's fix for "the front freezes while it loads") inserts any size of list in small batches regardless of how large it is. While in there: renderTilesInChunks itself was scheduling its next chunk with requestAnimationFrame, which browsers deliberately pause for a backgrounded/hidden tab - completely ordinary to hit in practice (switching tabs while a 1229-item list is still loading, which now takes meaningfully longer than the 300-item case this was written and tested against) and would have stalled the render indefinitely, presenting as the exact same "data missing" symptom for a different reason. Switched to setTimeout(fn, 0), which keeps running (only throttled, never paused) in a background tab. Verified: full typecheck/test/build on both projects (122/122 server tests). The two real counts above (421, 1229) and the "no Limit means Emby returns everything" behavior were both confirmed with live, read-only requests against the real server - not assumed. Not verified: an actual browser render of either list end to end (no Emby login reachable from this sandbox), though the render mechanism itself was already verified separately in the previous PR and is unchanged here beyond the scheduling primitive. Co-Authored-By: Claude Sonnet 5 --- client/src/tiles.ts | 31 +++++++++++++++-------- server/src/services/CatalogService.ts | 36 +++++++++++++++++++-------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/client/src/tiles.ts b/client/src/tiles.ts index 878cc80..4d85f70 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -96,15 +96,26 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string) } /** - * Renders `items` into `container` a chunk at a time, across animation - * frames, instead of one `innerHTML` write for the whole list - a library - * grid can hand back up to 300 tiles at once (CatalogService.getLibraryItems/ - * getArtists), and building + inserting/laying out ~300 posters in a - * single synchronous task is exactly what a "the page freezes while it - * loads" report looks like on a slower device. Splitting the same HTML - * across a few chunks keeps 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. + * Renders `items` into `container` a chunk at a time instead of one + * `innerHTML` write for the whole list - a library grid can legitimately + * hand back over a thousand tiles now that CatalogService.getLibraryItems/ + * getArtists no longer cap themselves (confirmed against the real server: + * one library alone has 1229 artists - see their own comments), and + * building + inserting/laying out that many in a single synchronous task + * is exactly what a "the page freezes while it loads" report looks like + * on a slower device. Splitting the same HTML across a few chunks keeps + * 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 * 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; container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join('')); i += chunkSize; - if (i < items.length) requestAnimationFrame(renderNextChunk); + if (i < items.length) setTimeout(renderNextChunk, 0); } renderNextChunk(); } diff --git a/server/src/services/CatalogService.ts b/server/src/services/CatalogService.ts index c1691f4..9bb13fe 100644 --- a/server/src/services/CatalogService.ts +++ b/server/src/services/CatalogService.ts @@ -199,11 +199,13 @@ class CatalogServiceClass { // structure, same reasoning as Shows -> Seasons -> Episodes above. async getArtists(session: EmbySession, libraryId: string): Promise { - // Limit added for the same reason as getLibraryItems's own - unbounded - // on a large music library, this used to hand back every artist at - // once for the client to render in a single pass (see tiles.ts's - // renderTilesInChunks for the render-side half of that fix). - const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Limit: '300' }); + // No Limit, deliberately - see getLibraryItems's own comment below for + // why one very nearly shipped here and got pulled: confirmed directly + // against the real server that this library alone has 1229 artists, + // so a 300 cap would have silently dropped 75% of them, not just + // 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(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.'); 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 // never reads it. Traced here after "the catalog API is too slow, the // front freezes while it loads" - Emby happily serializes a full - // synopsis for all 300 items, and the client happily JSON.parses all - // of it, entirely to be thrown away unread. Contrast getSeriesEpisodes/ - // getPlaylistItemsRaw, which keep Overview because their own views - // (Series/Playlist) actually display it. + // synopsis for every item anyway, and the client happily JSON.parses + // all of it, entirely to be thrown away unread. Contrast + // getSeriesEpisodes/getPlaylistItemsRaw, which keep Overview because + // 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({ ParentId: libraryId, Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) SortBy: 'SortName', Fields: 'ProductionYear,AlbumId', - ImageTypeLimit: '1', - Limit: '300' + ImageTypeLimit: '1' }); const data = await this.fetchJson(`/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));