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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user