Add Jellyfin library mapping support

Add support for Jellyfin library-to-category mappings used during import. Key changes:

- UI: ImporterView now lets users fetch Jellyfin libraries, configure per-library category (TV/Anime/Movies/Music/skip) and optional path segments, and persists mappings to API settings and localStorage.
- API/types: Add jellyfin_library_mappings to ApiSettingsItem/CreateSettingsInput and UserSettings (JSON string of LibraryMapping[]), and wire conversion helpers in src/api.ts and src/types.ts.
- Jellyfin importer: Introduce LibraryMapping type, fetchJellyfinLibraries, helper functions to resolve library from ParentId or Path (extractLibraryFromPath, findLibraryForItem), and update item conversion (movies/series/albums) to apply mappings and skip items marked 'skip'. Import flow now fetches libraries to build id->name map and passes mappings through to converters.

This enables category-aware imports and allows skipping libraries during Jellyfin imports.
This commit is contained in:
Lars Behrends
2026-04-12 02:08:31 +02:00
parent dff599e5af
commit 9c7e5a2b19
5 changed files with 510 additions and 15 deletions

View File

@@ -6,7 +6,8 @@ import { cn } from '@/lib/utils';
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions } from '@/lib/jellyfinImporter';
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
import { fetchSettings, updateSettings } from '@/api';
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
@@ -31,8 +32,63 @@ export default function ImporterView() {
importSeries: true,
importMusic: true,
importCast: true,
limit: undefined
limit: undefined,
libraryMappings: []
});
const [jellyfinLibraries, setJellyfinLibraries] = useState<Array<{ Id: string; Name: string; CollectionType: string }>>([]);
const [libraryMappings, setLibraryMappings] = useState<LibraryMapping[]>([]);
const [showLibraryMapping, setShowLibraryMapping] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Load library mappings from API on mount
useEffect(() => {
const loadMappings = async () => {
try {
const settings = await fetchSettings();
if (settings?.jellyfinLibraryMappings) {
const mappings = JSON.parse(settings.jellyfinLibraryMappings);
setLibraryMappings(mappings);
setShowLibraryMapping(true);
}
} catch (error) {
console.error('Failed to load library mappings from API:', error);
// Fallback to localStorage
const savedMappings = localStorage.getItem('jellyfinLibraryMappings');
if (savedMappings) {
try {
setLibraryMappings(JSON.parse(savedMappings));
setShowLibraryMapping(true);
} catch (error) {
console.error('Failed to parse saved library mappings:', error);
}
}
}
setIsInitialLoad(false);
};
loadMappings();
}, []);
// Save library mappings to API and localStorage when they change
useEffect(() => {
if (libraryMappings.length > 0 && !isInitialLoad) {
// Save to localStorage as fallback
localStorage.setItem('jellyfinLibraryMappings', JSON.stringify(libraryMappings));
// Save to API
const saveMappings = async () => {
try {
const settings = await fetchSettings();
if (settings) {
settings.jellyfinLibraryMappings = JSON.stringify(libraryMappings);
await updateSettings(settings);
}
} catch (error) {
console.error('Failed to save library mappings to API:', error);
}
};
saveMappings();
}
}, [libraryMappings, isInitialLoad]);
const [progress, setProgress] = useState<ImportProgress>({
current: 0,
total: 0,
@@ -161,9 +217,15 @@ export default function ImporterView() {
});
setImportLog([]);
// Update options with current library mappings
const optionsWithMappings = {
...jellyfinOptions,
libraryMappings: libraryMappings
};
const result = await importFromJellyfin(
jellyfinConfig,
jellyfinOptions,
optionsWithMappings,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
@@ -197,6 +259,66 @@ export default function ImporterView() {
setProgress(result);
};
const handleFetchJellyfinLibraries = async () => {
try {
const libraries = await fetchJellyfinLibraries(jellyfinConfig);
setJellyfinLibraries(libraries);
// Merge existing mappings with new libraries
const newMappings: LibraryMapping[] = libraries.map(lib => {
// Check if mapping already exists
const existing = libraryMappings.find(m => m.libraryName === lib.Name);
if (existing) {
return existing;
}
// Create new mapping with default category
let defaultCategory: 'TV Series' | 'Anime' | 'Movies' | 'Music' = 'TV Series';
if (lib.CollectionType === 'movies') {
defaultCategory = 'Movies';
} else if (lib.CollectionType === 'music') {
defaultCategory = 'Music';
} else if (lib.CollectionType === 'tvshows') {
defaultCategory = 'TV Series';
}
return {
libraryName: lib.Name,
category: defaultCategory
};
});
setLibraryMappings(newMappings);
setShowLibraryMapping(true);
addLog(`Fetched ${libraries.length} libraries from Jellyfin`);
} catch (error) {
addLog(`Failed to fetch libraries: ${error}`);
}
};
const handleLibraryMappingChange = (libraryName: string, category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip') => {
setLibraryMappings(prev => {
const existing = prev.find(m => m.libraryName === libraryName);
if (existing) {
return prev.map(m => m.libraryName === libraryName ? { ...m, category } : m);
} else {
return [...prev, { libraryName, category }];
}
});
};
const handleLibraryPathSegmentsChange = (libraryName: string, value: string) => {
const segments = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
setLibraryMappings(prev => {
const existing = prev.find(m => m.libraryName === libraryName);
if (existing) {
return prev.map(m => m.libraryName === libraryName ? { ...m, pathSegments: segments } : m);
} else {
return [...prev, { libraryName, category: 'TV Series', pathSegments: segments }];
}
});
};
const resetImport = () => {
setProgress({
current: 0,
@@ -606,6 +728,52 @@ export default function ImporterView() {
placeholder="e.g. 10"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Library Category Mapping</label>
<Button
onClick={handleFetchJellyfinLibraries}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
variant="outline"
className="w-full mb-3 font-bold border-border"
>
<RefreshCw size={16} className="mr-2" />
Fetch Libraries
</Button>
{showLibraryMapping && libraryMappings.length > 0 && (
<div className="space-y-2 max-h-48 overflow-y-auto">
{libraryMappings.map(mapping => (
<div key={mapping.libraryName} className="space-y-1 p-2 border border-border rounded-lg">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground flex-1 truncate">{mapping.libraryName}</span>
<select
value={mapping.category}
onChange={(e) => handleLibraryMappingChange(mapping.libraryName, e.target.value as any)}
disabled={progress.stage !== 'idle'}
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
>
<option value="TV Series">TV Series</option>
<option value="Anime">Anime</option>
<option value="Movies">Movies</option>
<option value="Music">Music</option>
<option value="skip">Nicht importieren</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Pfad-Segmente (kommagetrennt):</span>
<input
type="text"
value={mapping.pathSegments?.join(', ') || ''}
onChange={(e) => handleLibraryPathSegmentsChange(mapping.libraryName, e.target.value)}
disabled={progress.stage !== 'idle'}
placeholder="z.B. Serien, Animes"
className="text-xs px-2 py-1 border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed flex-1"
/>
</div>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleJellyfinImport}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}