From 0c595a787ec8e0c74ed6ba200acce25b672255f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 15 Apr 2026 19:42:23 +0200 Subject: [PATCH] =?UTF-8?q?library:=20batch=20audio-codec=20lookup=20?= =?UTF-8?q?=E2=80=94=20per-row=20subquery=20was=20O(page=C3=97streams)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scalar subquery I added in 7d30e6c ran one aggregate scan of media_streams per row. On a real library (33k items / 212k streams) a single page took 500+ seconds synchronously, blocking the event loop and timing out every other request — Library AND Pipeline both stopped loading. Swap it for a single batched `GROUP_CONCAT ... WHERE item_id IN (?...)` query over the current page's ids (max 25), then merge back into rows. v2026.04.15.10 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- server/api/scan.ts | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c09a96b..f7d3292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.15.9", + "version": "2026.04.15.10", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/scan.ts b/server/api/scan.ts index c58f152..cb89b74 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -163,11 +163,7 @@ app.get("/items", (c) => { ` SELECT id, jellyfin_id, name, type, series_name, season_number, episode_number, scan_status, original_language, orig_lang_source, container, file_size, file_path, - last_scanned_at, ingest_source, - (SELECT GROUP_CONCAT(DISTINCT LOWER(codec)) - FROM media_streams - WHERE item_id = media_items.id AND type = 'Audio' AND codec IS NOT NULL - ) AS audio_codecs + last_scanned_at, ingest_source FROM media_items ${where.sql} ORDER BY COALESCE(last_scanned_at, created_at) DESC, id DESC @@ -192,6 +188,24 @@ app.get("/items", (c) => { ingest_source: string | null; audio_codecs: string | null; }>; + + // Audio codecs per item, batched into one query for the current page. + // A per-row scalar subquery over media_streams was O(page × streams) + // and could block the event loop for minutes on large libraries. + if (rows.length > 0) { + const placeholders = rows.map(() => "?").join(","); + const codecRows = db + .prepare( + `SELECT item_id, GROUP_CONCAT(DISTINCT LOWER(codec)) AS codecs + FROM media_streams + WHERE item_id IN (${placeholders}) AND type = 'Audio' AND codec IS NOT NULL + GROUP BY item_id`, + ) + .all(...rows.map((r) => r.id)) as { item_id: number; codecs: string | null }[]; + const byItem = new Map(codecRows.map((r) => [r.item_id, r.codecs])); + for (const row of rows) row.audio_codecs = byItem.get(row.id) ?? null; + } + const total = (db.prepare(`SELECT COUNT(*) as n FROM media_items ${where.sql}`).get(...where.args) as { n: number }).n; return c.json({ rows, total, hasMore: query.offset + rows.length < total, query }); });