add path mappings to translate jellyfin library paths to container mount paths
All checks were successful
Build and Push Docker Image / build (push) Successful in 20s

jellyfin may use different internal paths (e.g. /tv/) than container mounts
(/series/). path_mappings config (or PATH_MAPPINGS env var) translates at scan
time. configurable via setup ui or env var format: /tv/=/series/,/data/=/movies/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 16:57:22 +01:00
parent d5f4afd26b
commit ef785de955
6 changed files with 75 additions and 2 deletions

View File

@@ -10,4 +10,6 @@ services:
environment:
- DATA_DIR=/data/
- PORT=3000
# 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 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
import { getDb, getConfig, setConfig, getAllConfig, applyPathMappings } 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';
@@ -224,7 +224,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, jellyfinItem.Path, jellyfinItem.Size ?? null,
jellyfinItem.ProductionYear ?? null, applyPathMappings(jellyfinItem.Path), jellyfinItem.Size ?? null,
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
imdbId, tmdbId, tvdbId
);

View File

@@ -83,6 +83,12 @@ app.post('/subtitle-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();
db.prepare('DELETE FROM media_items').run();

View File

@@ -22,6 +22,7 @@ const ENV_MAP: Record<string, string> = {
sonarr_api_key: 'SONARR_API_KEY',
sonarr_enabled: 'SONARR_ENABLED',
subtitle_languages: 'SUBTITLE_LANGUAGES',
path_mappings: 'PATH_MAPPINGS',
};
/** Read a config key from environment variables (returns null if not set). */
@@ -32,6 +33,7 @@ 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') 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;
}
@@ -108,3 +110,13 @@ export function getAllConfig(): Record<string, string> {
if (isEnvConfigured()) result.setup_complete = '1';
return result;
}
/** Apply path_mappings config to translate a Jellyfin path to a local container path. */
export function applyPathMappings(path: string): string {
const raw = getConfig('path_mappings') ?? '[]';
const mappings = JSON.parse(raw) as [string, string][];
for (const [from, to] of mappings) {
if (path.startsWith(from)) return to + path.slice(from.length);
}
return path;
}

View File

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

View File

@@ -127,6 +127,17 @@ export function SetupPage() {
const saveSonarr = (url: string, apiKey: string) =>
api.post('/api/setup/sonarr', { url, api_key: apiKey });
const pathMappings: [string, string][] = JSON.parse(cfg.path_mappings ?? '[]');
const [mappings, setMappings] = useState<[string, string][]>(pathMappings.length > 0 ? pathMappings : [['', '']]);
const [mappingSaved, setMappingSaved] = useState('');
const saveMappings = async () => {
const valid = mappings.filter(([f, t]) => f.trim() && t.trim());
await api.post('/api/setup/path-mappings', { mappings: valid });
setMappingSaved('Saved.');
setTimeout(() => setMappingSaved(''), 2000);
};
const saveSubtitleLangs = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
@@ -172,6 +183,47 @@ export function SetupPage() {
onSave={saveSonarr}
/>
{/* Path mappings */}
<SectionCard
title={
<span className="flex items-center gap-2">
Path Mappings
<EnvBadge envVar="PATH_MAPPINGS" locked={locked.has('path_mappings')} />
</span>
}
subtitle="Translate Jellyfin library paths to container mount paths. E.g. Jellyfin uses /tv/ but the container mounts at /series/."
>
{mappings.map(([from, to], i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<Input
value={from}
onChange={(e) => { const m = [...mappings]; m[i] = [e.target.value, m[i][1]]; setMappings(m); }}
placeholder="/tv/"
className="max-w-[12rem] text-sm"
disabled={locked.has('path_mappings')}
/>
<span className="text-gray-400"></span>
<Input
value={to}
onChange={(e) => { const m = [...mappings]; m[i] = [m[i][0], e.target.value]; setMappings(m); }}
placeholder="/series/"
className="max-w-[12rem] text-sm"
disabled={locked.has('path_mappings')}
/>
{mappings.length > 1 && (
<button type="button" onClick={() => setMappings(mappings.filter((_, j) => j !== i))} className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer"></button>
)}
</div>
))}
<div className="flex items-center gap-2 mt-2">
<button type="button" onClick={() => setMappings([...mappings, ['', '']])} className="text-blue-600 text-sm border-0 bg-transparent cursor-pointer hover:underline" disabled={locked.has('path_mappings')}>+ Add mapping</button>
</div>
<div className="flex items-center gap-2 mt-3">
<Button onClick={saveMappings} disabled={locked.has('path_mappings')}>Save</Button>
{mappingSaved && <span className="text-green-700 text-sm">{mappingSaved}</span>}
</div>
</SectionCard>
{/* Subtitle languages */}
<SectionCard
title={