From 76d3b1acfbc0f6f862d408ddf723511997b35008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Thu, 5 Mar 2026 12:02:26 +0100 Subject: [PATCH] remove path mappings, add subtitle summary endpoint, cache setup page, bump version Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 2 - package.json | 2 +- server/api/scan.ts | 4 +- server/api/setup.ts | 6 - server/api/subtitles.ts | 192 ++++++++++++++++++++++++++++++- server/db/index.ts | 27 +---- server/db/schema.ts | 2 +- src/features/paths/PathsPage.tsx | 2 +- src/features/setup/SetupPage.tsx | 28 +++-- src/routes/__root.tsx | 2 +- 10 files changed, 216 insertions(+), 51 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c2cdff9..121278c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,4 @@ services: - PORT=3000 # Additional audio languages to keep alongside original (comma-separated ISO 639-2 codes, order = priority) # - AUDIO_LANGUAGES=deu,spa - # Map Jellyfin library paths to container mount paths (comma-separated from=to pairs) - # - PATH_MAPPINGS=/tv/=/series/,/data/movies/=/movies/ restart: unless-stopped diff --git a/package.json b/package.json index cd461a5..3804f91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.03.05.6", + "version": "2026.03.05.7", "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 8a47bf9..83654ab 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { stream } from 'hono/streaming'; -import { getDb, getConfig, setConfig, getAllConfig, applyPathMappings } from '../db/index'; +import { getDb, getConfig, setConfig, getAllConfig } from '../db/index'; import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin'; import { getOriginalLanguage as radarrLang } from '../services/radarr'; import { getOriginalLanguage as sonarrLang } from '../services/sonarr'; @@ -217,7 +217,7 @@ async function runScan(limit: number | null = null): Promise { jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie', jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null, jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null, - jellyfinItem.ProductionYear ?? null, applyPathMappings(jellyfinItem.Path), jellyfinItem.Size ?? null, + jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null, jellyfinItem.Container ?? null, origLang, origLangSource, needsReview, imdbId, tmdbId, tvdbId ); diff --git a/server/api/setup.ts b/server/api/setup.ts index b9199a4..c4091e1 100644 --- a/server/api/setup.ts +++ b/server/api/setup.ts @@ -89,12 +89,6 @@ app.post('/audio-languages', async (c) => { return c.json({ ok: true }); }); -app.post('/path-mappings', async (c) => { - const body = await c.req.json<{ mappings: [string, string][] }>(); - setConfig('path_mappings', JSON.stringify(body.mappings ?? [])); - return c.json({ ok: true }); -}); - app.post('/clear-scan', (c) => { const db = getDb(); // Delete children first to avoid slow cascade deletes diff --git a/server/api/subtitles.ts b/server/api/subtitles.ts index d22c0ec..d5ea5f8 100644 --- a/server/api/subtitles.ts +++ b/server/api/subtitles.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { getDb, getAllConfig } from '../db/index'; +import { getDb, getConfig, getAllConfig } from '../db/index'; import { buildExtractOnlyCommand } from '../services/ffmpeg'; import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin'; import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types'; @@ -295,4 +295,194 @@ 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) => { + const db = getDb(); + const body = await c.req.json<{ categories: { language: string | null; variant: 'standard' | 'forced' | 'cc' }[] }>(); + + let deleted = 0; + for (const cat of body.categories) { + const isForced = cat.variant === 'forced' ? 1 : 0; + const isHI = cat.variant === 'cc' ? 1 : 0; + + let files: SubtitleFile[]; + if (cat.language === null) { + files = db.prepare(` + SELECT * FROM subtitle_files + WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ? + `).all(isForced, isHI) as SubtitleFile[]; + } else { + files = db.prepare(` + SELECT * FROM subtitle_files + WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ? + `).all(cat.language, isForced, isHI) as SubtitleFile[]; + } + + for (const file of files) { + try { unlinkSync(file.file_path); } catch { /* file may not exist */ } + db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(file.id); + deleted++; + } + + // Reset subs_extracted for affected items that now have no subtitle files + const affectedItems = new Set(files.map((f) => f.item_id)); + for (const itemId of affectedItems) { + const remaining = (db.prepare('SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?').get(itemId) as { n: number }).n; + if (remaining === 0) { + db.prepare('UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?').run(itemId); + } + } + } + + return c.json({ ok: true, deleted }); +}); + +// ─── Normalize titles ──────────────────────────────────────────────────────── + +app.post('/normalize-titles', (c) => { + const db = getDb(); + + // Get title groups per language + 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 }[]; + + // Find canonical (most common) title per language + const canonicalByLang = new Map(); + for (const r of titleRows) { + if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title); + } + + let normalized = 0; + for (const r of titleRows) { + const canonical = canonicalByLang.get(r.language); + if (r.title === canonical) continue; + + // Find all streams matching this language+title and set custom_title on their decisions + let streams: { id: number; item_id: number }[]; + if (r.language === null) { + streams = db.prepare(` + SELECT id, item_id FROM media_streams + WHERE type = 'Subtitle' AND language IS NULL AND title IS ? + `).all(r.title) as { id: number; item_id: number }[]; + } else { + streams = db.prepare(` + SELECT id, item_id FROM media_streams + WHERE type = 'Subtitle' AND language = ? AND title IS ? + `).all(r.language, r.title) as { id: number; item_id: number }[]; + } + + for (const stream of streams) { + // Ensure review_plan exists + let plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number } | undefined; + if (!plan) { + db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id); + plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number }; + } + + // Upsert stream_decision with custom_title + const existing = db.prepare('SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?').get(plan.id, stream.id); + if (existing) { + db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(canonical, plan.id, stream.id); + } else { + db.prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)").run(plan.id, stream.id, canonical); + } + normalized++; + } + } + + return c.json({ ok: true, normalized }); +}); + export default app; diff --git a/server/db/index.ts b/server/db/index.ts index 9d62afd..24152fd 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -23,7 +23,7 @@ const ENV_MAP: Record = { sonarr_enabled: 'SONARR_ENABLED', subtitle_languages: 'SUBTITLE_LANGUAGES', audio_languages: 'AUDIO_LANGUAGES', - path_mappings: 'PATH_MAPPINGS', + }; /** Read a config key from environment variables (returns null if not set). */ @@ -34,7 +34,6 @@ function envValue(key: string): string | null { if (!val) return null; if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0'; if (key === 'subtitle_languages' || key === 'audio_languages') return JSON.stringify(val.split(',').map((s) => s.trim())); - if (key === 'path_mappings') return JSON.stringify(val.split(',').map((pair) => { const [from, to] = pair.split('='); return [from?.trim(), to?.trim()]; }).filter(([f, t]) => f && t)); if (key.endsWith('_url')) return val.replace(/\/$/, ''); return val; } @@ -112,27 +111,3 @@ export function getAllConfig(): Record { return result; } -function getPathMappings(): [string, string][] { - const raw = getConfig('path_mappings'); - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(([f, t]: [string, string]) => f && t) : []; - } catch { return []; } -} - -/** Apply path_mappings config to translate a Jellyfin path to a local container path. */ -export function applyPathMappings(path: string): string { - for (const [from, to] of getPathMappings()) { - if (path.startsWith(from)) return to + path.slice(from.length); - } - return path; -} - -/** Apply all path_mappings as replaceAll on a command string (for baked-in paths). */ -export function applyPathMappingsToCommand(cmd: string): string { - for (const [from, to] of getPathMappings()) { - cmd = cmd.replaceAll(from, to); - } - return cmd; -} diff --git a/server/db/schema.ts b/server/db/schema.ts index e369dfe..23e26ea 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -126,6 +126,6 @@ export const DEFAULT_CONFIG: Record = { sonarr_enabled: '0', subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), audio_languages: '[]', - path_mappings: '[]', + scan_running: '0', }; diff --git a/src/features/paths/PathsPage.tsx b/src/features/paths/PathsPage.tsx index 40a2e06..a3438fc 100644 --- a/src/features/paths/PathsPage.tsx +++ b/src/features/paths/PathsPage.tsx @@ -67,7 +67,7 @@ export function PathsPage() { {paths.some((p) => !p.accessible) && (

Paths marked "Not mounted" are not reachable from the container. - Add a Docker volume mount for the missing path, or configure a path mapping in Settings to translate the Jellyfin path to a local path. + Mount each path into the Docker container exactly as Jellyfin reports it.

)} diff --git a/src/features/setup/SetupPage.tsx b/src/features/setup/SetupPage.tsx index 69bdc6d..d82b077 100644 --- a/src/features/setup/SetupPage.tsx +++ b/src/features/setup/SetupPage.tsx @@ -7,6 +7,8 @@ import { LANG_NAMES } from '~/shared/lib/lang'; interface SetupData { config: Record; envLocked: string[]; } +let setupCache: SetupData | null = null; + const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ code, label })); // ─── Locked input ───────────────────────────────────────────────────────────── @@ -169,7 +171,8 @@ function ConnSection({ // ─── Setup page ─────────────────────────────────────────────────────────────── export function SetupPage() { - const [data, setData] = useState(null); + const [data, setData] = useState(setupCache); + const [loading, setLoading] = useState(setupCache === null); const [clearStatus, setClearStatus] = useState(''); const [subLangs, setSubLangs] = useState([]); const [subSaved, setSubSaved] = useState(''); @@ -177,17 +180,22 @@ export function SetupPage() { const [audSaved, setAudSaved] = useState(''); const [langsLoaded, setLangsLoaded] = useState(false); - const load = () => api.get('/api/setup').then((d) => { - setData(d); - if (!langsLoaded) { - setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]')); - setAudLangs(JSON.parse(d.config.audio_languages ?? '[]')); - setLangsLoaded(true); - } - }); + const load = () => { + if (!setupCache) setLoading(true); + api.get('/api/setup').then((d) => { + setupCache = d; + setData(d); + if (!langsLoaded) { + setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]')); + setAudLangs(JSON.parse(d.config.audio_languages ?? '[]')); + setLangsLoaded(true); + } + }).finally(() => setLoading(false)); + }; useEffect(() => { load(); }, []); - if (!data) return
Loading…
; + if (loading && !data) return
Loading…
; + if (!data) return
Failed to load settings.
; const { config: cfg, envLocked: envLockedArr } = data; const locked = new Set(envLockedArr); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index fc27f27..3821bc0 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -52,7 +52,7 @@ function RootLayout() { Scan Paths Audio - Subs + Subtitles Execute