remove path mappings, add subtitle summary endpoint, cache setup page, bump version
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:02:26 +01:00
parent 99274d3ae8
commit 76d3b1acfb
10 changed files with 216 additions and 51 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<void> {
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
);

View File

@@ -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

View File

@@ -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<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
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<string | null, string | null>();
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<string | null, string | null>();
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;

View File

@@ -23,7 +23,7 @@ const ENV_MAP: Record<string, string> = {
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<string, string> {
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;
}

View File

@@ -126,6 +126,6 @@ export const DEFAULT_CONFIG: Record<string, string> = {
sonarr_enabled: '0',
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
audio_languages: '[]',
path_mappings: '[]',
scan_running: '0',
};

View File

@@ -67,7 +67,7 @@ export function PathsPage() {
{paths.some((p) => !p.accessible) && (
<p className="mt-4 text-xs text-gray-500">
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.
</p>
)}
</>

View File

@@ -7,6 +7,8 @@ import { LANG_NAMES } from '~/shared/lib/lang';
interface SetupData { config: Record<string, string>; 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<SetupData | null>(null);
const [data, setData] = useState<SetupData | null>(setupCache);
const [loading, setLoading] = useState(setupCache === null);
const [clearStatus, setClearStatus] = useState('');
const [subLangs, setSubLangs] = useState<string[]>([]);
const [subSaved, setSubSaved] = useState('');
@@ -177,17 +180,22 @@ export function SetupPage() {
const [audSaved, setAudSaved] = useState('');
const [langsLoaded, setLangsLoaded] = useState(false);
const load = () => api.get<SetupData>('/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<SetupData>('/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 <div className="text-gray-400 py-8 text-center">Loading</div>;
if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load settings.</div>;
const { config: cfg, envLocked: envLockedArr } = data;
const locked = new Set(envLockedArr);

View File

@@ -52,7 +52,7 @@ function RootLayout() {
<NavLink to="/scan">Scan</NavLink>
<NavLink to="/paths">Paths</NavLink>
<NavLink to="/review/audio">Audio</NavLink>
<NavLink to="/review/subtitles">Subs</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>
<NavLink to="/execute">Execute</NavLink>
</div>
<div className="flex-1" />