diff --git a/src/api.ts b/src/api.ts index fb4a562..31e92bf 100644 --- a/src/api.ts +++ b/src/api.ts @@ -663,6 +663,7 @@ export interface ApiSettingsItem { auto_play_trailers: boolean; language: string; theme: string; + jellyfin_library_mappings?: string; // JSON string of LibraryMapping[] created_at?: string; updated_at?: string; } @@ -676,6 +677,7 @@ export interface CreateSettingsInput { auto_play_trailers?: boolean; language?: string; theme?: string; + jellyfin_library_mappings?: string; } export interface UpdateSettingsInput extends Partial {} @@ -691,6 +693,7 @@ export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings { autoPlayTrailers: apiItem.auto_play_trailers || false, language: apiItem.language || 'en', theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system', + jellyfinLibraryMappings: apiItem.jellyfin_library_mappings, createdAt: apiItem.created_at, updatedAt: apiItem.updated_at, }; @@ -706,6 +709,7 @@ export function convertSettingsToApi(settings: UserSettings): CreateSettingsInpu auto_play_trailers: settings.autoPlayTrailers, language: settings.language, theme: settings.theme, + jellyfin_library_mappings: settings.jellyfinLibraryMappings, }; } diff --git a/src/components/CastDetailView.tsx b/src/components/CastDetailView.tsx index e55533a..9aec549 100644 --- a/src/components/CastDetailView.tsx +++ b/src/components/CastDetailView.tsx @@ -290,6 +290,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP > in {item.title} + {item.category && ( + + {item.category} + + )} ))} @@ -350,6 +355,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP {item.role} + {item.category && ( + + {item.category} + + )} diff --git a/src/components/ImporterView.tsx b/src/components/ImporterView.tsx index 8a4d38b..646d5b8 100644 --- a/src/components/ImporterView.tsx +++ b/src/components/ImporterView.tsx @@ -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>([]); + const [libraryMappings, setLibraryMappings] = useState([]); + 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({ 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" /> +
+ + + {showLibraryMapping && libraryMappings.length > 0 && ( +
+ {libraryMappings.map(mapping => ( +
+
+ {mapping.libraryName} + +
+
+ Pfad-Segmente (kommagetrennt): + 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" + /> +
+
+ ))} +
+ )} +