diff --git a/package.json b/package.json index 3804f91..ea071c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.03.05.7", + "version": "2026.03.05.8", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/subtitles.ts b/server/api/subtitles.ts index d5ea5f8..d5dce95 100644 --- a/server/api/subtitles.ts +++ b/server/api/subtitles.ts @@ -154,6 +154,93 @@ app.get('/series/:seriesKey/episodes', (c) => { return c.json({ seasons }); }); +// ─── Summary ───────────────────────────────────────────────────────────────── + +interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number } + +function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' { + if (row.is_forced) return 'forced'; + if (row.is_hearing_impaired) return 'cc'; + return 'standard'; +} + +function catKey(lang: string | null, variant: string) { return `${lang ?? '__null__'}|${variant}`; } + +app.get('/summary', (c) => { + const db = getDb(); + + // Embedded count — items with subtitle streams where subs_extracted = 0 + const embeddedCount = (db.prepare(` + SELECT COUNT(DISTINCT mi.id) as n FROM media_items mi + JOIN media_streams ms ON ms.item_id = mi.id AND ms.type = 'Subtitle' + LEFT JOIN review_plans rp ON rp.item_id = mi.id + WHERE COALESCE(rp.subs_extracted, 0) = 0 + `).get() as { n: number }).n; + + // Stream counts by (language, variant) + const streamRows = db.prepare(` + SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt + FROM media_streams WHERE type = 'Subtitle' + GROUP BY language, is_forced, is_hearing_impaired + `).all() as CategoryRow[]; + + // File counts by (language, variant) + const fileRows = db.prepare(` + SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt + FROM subtitle_files + GROUP BY language, is_forced, is_hearing_impaired + `).all() as CategoryRow[]; + + // Merge into categories + const catMap = new Map(); + for (const r of streamRows) { + const v = variantOf(r); + const k = catKey(r.language, v); + catMap.set(k, { language: r.language, variant: v, streamCount: r.cnt, fileCount: 0 }); + } + for (const r of fileRows) { + const v = variantOf(r); + const k = catKey(r.language, v); + const existing = catMap.get(k); + if (existing) { existing.fileCount = r.cnt; } + else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); } + } + const categories = Array.from(catMap.values()).sort((a, b) => { + const la = a.language ?? 'zzz'; + const lb = b.language ?? 'zzz'; + if (la !== lb) return la.localeCompare(lb); + return a.variant.localeCompare(b.variant); + }); + + // Title grouping + const titleRows = db.prepare(` + SELECT language, title, COUNT(*) as cnt + FROM media_streams WHERE type = 'Subtitle' + GROUP BY language, title + ORDER BY language, cnt DESC + `).all() as { language: string | null; title: string | null; cnt: number }[]; + + // Determine canonical title per language (most common) + const canonicalByLang = new Map(); + for (const r of titleRows) { + if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title); + } + + const titles = titleRows.map((r) => ({ + language: r.language, + title: r.title, + count: r.cnt, + isCanonical: canonicalByLang.get(r.language) === r.title, + })); + + // Keep languages from config + const raw = getConfig('subtitle_languages'); + let keepLanguages: string[] = []; + try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ } + + return c.json({ embeddedCount, categories, titles, keepLanguages }); +}); + // ─── Detail ────────────────────────────────────────────────────────────────── app.get('/:id', (c) => { @@ -295,93 +382,6 @@ app.post('/:id/rescan', async (c) => { return c.json(detail); }); -// ─── Summary ───────────────────────────────────────────────────────────────── - -interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number } - -function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' { - if (row.is_forced) return 'forced'; - if (row.is_hearing_impaired) return 'cc'; - return 'standard'; -} - -function catKey(lang: string | null, variant: string) { return `${lang ?? '__null__'}|${variant}`; } - -app.get('/summary', (c) => { - const db = getDb(); - - // Embedded count — items with subtitle streams where subs_extracted = 0 - const embeddedCount = (db.prepare(` - SELECT COUNT(DISTINCT mi.id) as n FROM media_items mi - JOIN media_streams ms ON ms.item_id = mi.id AND ms.type = 'Subtitle' - LEFT JOIN review_plans rp ON rp.item_id = mi.id - WHERE COALESCE(rp.subs_extracted, 0) = 0 - `).get() as { n: number }).n; - - // Stream counts by (language, variant) - const streamRows = db.prepare(` - SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt - FROM media_streams WHERE type = 'Subtitle' - GROUP BY language, is_forced, is_hearing_impaired - `).all() as CategoryRow[]; - - // File counts by (language, variant) - const fileRows = db.prepare(` - SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt - FROM subtitle_files - GROUP BY language, is_forced, is_hearing_impaired - `).all() as CategoryRow[]; - - // Merge into categories - const catMap = new Map(); - for (const r of streamRows) { - const v = variantOf(r); - const k = catKey(r.language, v); - catMap.set(k, { language: r.language, variant: v, streamCount: r.cnt, fileCount: 0 }); - } - for (const r of fileRows) { - const v = variantOf(r); - const k = catKey(r.language, v); - const existing = catMap.get(k); - if (existing) { existing.fileCount = r.cnt; } - else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); } - } - const categories = Array.from(catMap.values()).sort((a, b) => { - const la = a.language ?? 'zzz'; - const lb = b.language ?? 'zzz'; - if (la !== lb) return la.localeCompare(lb); - return a.variant.localeCompare(b.variant); - }); - - // Title grouping - const titleRows = db.prepare(` - SELECT language, title, COUNT(*) as cnt - FROM media_streams WHERE type = 'Subtitle' - GROUP BY language, title - ORDER BY language, cnt DESC - `).all() as { language: string | null; title: string | null; cnt: number }[]; - - // Determine canonical title per language (most common) - const canonicalByLang = new Map(); - for (const r of titleRows) { - if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title); - } - - const titles = titleRows.map((r) => ({ - language: r.language, - title: r.title, - count: r.cnt, - isCanonical: canonicalByLang.get(r.language) === r.title, - })); - - // Keep languages from config - const raw = getConfig('subtitle_languages'); - let keepLanguages: string[] = []; - try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ } - - return c.json({ embeddedCount, categories, titles, keepLanguages }); -}); - // ─── Batch delete subtitle files ───────────────────────────────────────────── app.post('/batch-delete', async (c) => {