diff --git a/docker-compose.yml b/docker-compose.yml index 60ef479..f26ccc5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/server/api/scan.ts b/server/api/scan.ts index 500a75c..dc4d9d7 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 } 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 { 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 ); diff --git a/server/api/setup.ts b/server/api/setup.ts index 0da9380..ee228d5 100644 --- a/server/api/setup.ts +++ b/server/api/setup.ts @@ -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(); diff --git a/server/db/index.ts b/server/db/index.ts index b638319..80f71e5 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -22,6 +22,7 @@ const ENV_MAP: Record = { 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 { 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; +} diff --git a/server/db/schema.ts b/server/db/schema.ts index 83058dd..524fa60 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -125,5 +125,6 @@ export const DEFAULT_CONFIG: Record = { sonarr_api_key: '', sonarr_enabled: '0', subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']), + path_mappings: '[]', scan_running: '0', }; diff --git a/src/features/setup/SetupPage.tsx b/src/features/setup/SetupPage.tsx index 5a39527..d645280 100644 --- a/src/features/setup/SetupPage.tsx +++ b/src/features/setup/SetupPage.tsx @@ -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) => { e.preventDefault(); const fd = new FormData(e.currentTarget); @@ -172,6 +183,47 @@ export function SetupPage() { onSave={saveSonarr} /> + {/* Path mappings */} + + Path Mappings + + + } + 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) => ( +
+ { 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')} + /> + + { 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 && ( + + )} +
+ ))} +
+ +
+
+ + {mappingSaved && {mappingSaved}} +
+
+ {/* Subtitle languages */}