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:
@@ -663,6 +663,7 @@ export interface ApiSettingsItem {
|
|||||||
auto_play_trailers: boolean;
|
auto_play_trailers: boolean;
|
||||||
language: string;
|
language: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
jellyfin_library_mappings?: string; // JSON string of LibraryMapping[]
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -676,6 +677,7 @@ export interface CreateSettingsInput {
|
|||||||
auto_play_trailers?: boolean;
|
auto_play_trailers?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
jellyfin_library_mappings?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||||
@@ -691,6 +693,7 @@ export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
|||||||
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||||
language: apiItem.language || 'en',
|
language: apiItem.language || 'en',
|
||||||
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||||
|
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
|
||||||
createdAt: apiItem.created_at,
|
createdAt: apiItem.created_at,
|
||||||
updatedAt: apiItem.updated_at,
|
updatedAt: apiItem.updated_at,
|
||||||
};
|
};
|
||||||
@@ -706,6 +709,7 @@ export function convertSettingsToApi(settings: UserSettings): CreateSettingsInpu
|
|||||||
auto_play_trailers: settings.autoPlayTrailers,
|
auto_play_trailers: settings.autoPlayTrailers,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
theme: settings.theme,
|
theme: settings.theme,
|
||||||
|
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
>
|
>
|
||||||
in {item.title}
|
in {item.title}
|
||||||
</button>
|
</button>
|
||||||
|
{item.category && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-bold mt-2 bg-muted text-muted-foreground border-none">
|
||||||
|
{item.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -350,6 +355,11 @@ export default function CastDetailView({ person, relatedMedia }: CastDetailViewP
|
|||||||
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border">
|
<Badge variant="outline" className="text-[10px] font-bold py-0 h-5 border-border">
|
||||||
{item.role}
|
{item.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{item.category && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-bold py-0 h-5 bg-muted text-muted-foreground border-none">
|
||||||
|
{item.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { cn } from '@/lib/utils';
|
|||||||
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
||||||
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||||
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
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';
|
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
@@ -31,8 +32,63 @@ export default function ImporterView() {
|
|||||||
importSeries: true,
|
importSeries: true,
|
||||||
importMusic: true,
|
importMusic: true,
|
||||||
importCast: 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>({
|
const [progress, setProgress] = useState<ImportProgress>({
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -161,9 +217,15 @@ export default function ImporterView() {
|
|||||||
});
|
});
|
||||||
setImportLog([]);
|
setImportLog([]);
|
||||||
|
|
||||||
|
// Update options with current library mappings
|
||||||
|
const optionsWithMappings = {
|
||||||
|
...jellyfinOptions,
|
||||||
|
libraryMappings: libraryMappings
|
||||||
|
};
|
||||||
|
|
||||||
const result = await importFromJellyfin(
|
const result = await importFromJellyfin(
|
||||||
jellyfinConfig,
|
jellyfinConfig,
|
||||||
jellyfinOptions,
|
optionsWithMappings,
|
||||||
addLog,
|
addLog,
|
||||||
(progressUpdate) => {
|
(progressUpdate) => {
|
||||||
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||||
@@ -197,6 +259,66 @@ export default function ImporterView() {
|
|||||||
setProgress(result);
|
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 = () => {
|
const resetImport = () => {
|
||||||
setProgress({
|
setProgress({
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -606,6 +728,52 @@ export default function ImporterView() {
|
|||||||
placeholder="e.g. 10"
|
placeholder="e.g. 10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Button
|
||||||
onClick={handleJellyfinImport}
|
onClick={handleJellyfinImport}
|
||||||
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
|
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ export interface JellyfinConfig {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LibraryMapping {
|
||||||
|
libraryName: string;
|
||||||
|
category: 'TV Series' | 'Anime' | 'Movies' | 'Music' | 'skip';
|
||||||
|
pathSegments?: string[]; // Additional path segments that map to this library
|
||||||
|
}
|
||||||
|
|
||||||
export interface JellyfinImportOptions {
|
export interface JellyfinImportOptions {
|
||||||
importMovies?: boolean;
|
importMovies?: boolean;
|
||||||
importSeries?: boolean;
|
importSeries?: boolean;
|
||||||
importMusic?: boolean;
|
importMusic?: boolean;
|
||||||
importCast?: boolean;
|
importCast?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
libraryMappings?: LibraryMapping[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportProgress {
|
export interface ImportProgress {
|
||||||
@@ -72,6 +79,9 @@ export interface JellyfinItem {
|
|||||||
LocationType?: string;
|
LocationType?: string;
|
||||||
DateCreated?: string;
|
DateCreated?: string;
|
||||||
DateLastMediaAdded?: string;
|
DateLastMediaAdded?: string;
|
||||||
|
CollectionType?: string;
|
||||||
|
ParentId?: string;
|
||||||
|
Path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JellyfinItemsResponse {
|
export interface JellyfinItemsResponse {
|
||||||
@@ -151,6 +161,53 @@ function getYear(dateString?: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to extract library name from path
|
||||||
|
function extractLibraryFromPath(path: string, libraryIdToName: Map<string, string>, libraryMappings?: LibraryMapping[]): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
|
||||||
|
// Split path by / or \
|
||||||
|
const segments = path.split(/[\/\\]/);
|
||||||
|
|
||||||
|
// Build a reverse mapping from path segments to library names
|
||||||
|
const pathSegmentToLibrary = new Map<string, string>();
|
||||||
|
|
||||||
|
// Add library names
|
||||||
|
for (const [_, libraryName] of libraryIdToName) {
|
||||||
|
pathSegmentToLibrary.set(libraryName.toLowerCase(), libraryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom path segments from mappings
|
||||||
|
if (libraryMappings) {
|
||||||
|
for (const mapping of libraryMappings) {
|
||||||
|
if (mapping.pathSegments) {
|
||||||
|
for (const segment of mapping.pathSegments) {
|
||||||
|
pathSegmentToLibrary.set(segment.toLowerCase(), mapping.libraryName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first non-empty segment after /media/ or similar
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
|
||||||
|
// Skip empty segments and common root directories
|
||||||
|
if (!segment || segment === 'media') continue;
|
||||||
|
|
||||||
|
// Check if this segment matches any path segment (case-insensitive)
|
||||||
|
const libraryName = pathSegmentToLibrary.get(segment.toLowerCase());
|
||||||
|
if (libraryName) {
|
||||||
|
return libraryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the first meaningful segment, return it as potential library name
|
||||||
|
// This handles cases like "/media/Serien/..." when library is named "Shows"
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to fetch with authentication
|
// Helper function to fetch with authentication
|
||||||
async function fetchWithAuth(url: string, apiKey: string, options: RequestInit = {}): Promise<Response> {
|
async function fetchWithAuth(url: string, apiKey: string, options: RequestInit = {}): Promise<Response> {
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -161,6 +218,79 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit =
|
|||||||
return fetch(url, { ...options, headers });
|
return fetch(url, { ...options, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch libraries from Jellyfin
|
||||||
|
export async function fetchJellyfinLibraries(config: JellyfinConfig): Promise<Array<{ Id: string; Name: string; CollectionType: string }>> {
|
||||||
|
const userId = await getJellyfinUserId(config);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Users/${userId}/Items?${params.toString()}`, config.apiKey);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch libraries from Jellyfin: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: JellyfinItemsResponse = await response.json();
|
||||||
|
|
||||||
|
// Filter for parent items (libraries) and get their collection type
|
||||||
|
const libraries = (data.Items || []).filter(item => item.Type === 'CollectionFolder').map(item => ({
|
||||||
|
Id: item.Id,
|
||||||
|
Name: item.Name,
|
||||||
|
CollectionType: item.CollectionType || 'Unknown'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return libraries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch item details with parent information
|
||||||
|
async function fetchJellyfinItemDetails(config: JellyfinConfig, itemId: string): Promise<JellyfinItem> {
|
||||||
|
const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Items/${itemId}`, config.apiKey);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch item details from Jellyfin: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively find the library for an item by following ParentId
|
||||||
|
async function findLibraryForItem(
|
||||||
|
config: JellyfinConfig,
|
||||||
|
itemId: string,
|
||||||
|
libraryIdToName: Map<string, string>,
|
||||||
|
maxDepth: number = 10
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (maxDepth <= 0) return null;
|
||||||
|
|
||||||
|
// Check if this is already a library
|
||||||
|
if (libraryIdToName.has(itemId)) {
|
||||||
|
return libraryIdToName.get(itemId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = await getJellyfinUserId(config);
|
||||||
|
const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Users/${userId}/Items/${itemId}`, config.apiKey);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`Failed to fetch item details for ${itemId}: ${response.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await response.json();
|
||||||
|
|
||||||
|
// If item has no parent, it might be the library itself
|
||||||
|
if (!item.ParentId) {
|
||||||
|
return libraryIdToName.get(itemId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively check parent
|
||||||
|
return await findLibraryForItem(config, item.ParentId, libraryIdToName, maxDepth - 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch item details for ${itemId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch items from Jellyfin
|
// Fetch items from Jellyfin
|
||||||
async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limit?: number): Promise<JellyfinItem[]> {
|
async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limit?: number): Promise<JellyfinItem[]> {
|
||||||
const userId = await getJellyfinUserId(config);
|
const userId = await getJellyfinUserId(config);
|
||||||
@@ -168,7 +298,7 @@ async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limi
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
'IncludeItemTypes': itemType,
|
'IncludeItemTypes': itemType,
|
||||||
'Recursive': 'true',
|
'Recursive': 'true',
|
||||||
'Fields': 'People,Genres,Tags,Studios,ProductionYear,CommunityRating,Overview,PremiereDate,RunTimeTicks,DateCreated,DateLastMediaAdded',
|
'Fields': 'People,Genres,Tags,Studios,ProductionYear,CommunityRating,Overview,PremiereDate,RunTimeTicks,DateCreated,DateLastMediaAdded,ParentId,Path',
|
||||||
'SortBy': 'SortName',
|
'SortBy': 'SortName',
|
||||||
'SortOrder': 'Ascending'
|
'SortOrder': 'Ascending'
|
||||||
});
|
});
|
||||||
@@ -189,6 +319,52 @@ async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limi
|
|||||||
return data.Items || [];
|
return data.Items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch items with ancestors to get library information
|
||||||
|
async function fetchJellyfinItemsWithAncestors(config: JellyfinConfig, itemType: string, libraryIdToName: Map<string, string>, limit?: number): Promise<Array<{ item: JellyfinItem; libraryName: string | null }>> {
|
||||||
|
const userId = await getJellyfinUserId(config);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'IncludeItemTypes': itemType,
|
||||||
|
'Recursive': 'true',
|
||||||
|
'Fields': 'People,Genres,Tags,Studios,ProductionYear,CommunityRating,Overview,PremiereDate,RunTimeTicks,DateCreated,DateLastMediaAdded,ParentId,Path',
|
||||||
|
'SortBy': 'SortName',
|
||||||
|
'SortOrder': 'Ascending'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
params.append('Limit', limit.toString());
|
||||||
|
} else {
|
||||||
|
params.append('Limit', '10000');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Users/${userId}/Items?${params.toString()}`, config.apiKey);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${itemType} from Jellyfin: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: JellyfinItemsResponse = await response.json();
|
||||||
|
const items = data.Items || [];
|
||||||
|
|
||||||
|
// For each item, try to find the library by following the parent chain
|
||||||
|
const results = await Promise.all(items.map(async (item) => {
|
||||||
|
let libraryName: string | null = null;
|
||||||
|
|
||||||
|
if (item.ParentId) {
|
||||||
|
libraryName = libraryIdToName.get(item.ParentId) || null;
|
||||||
|
|
||||||
|
// If not found, try to find recursively
|
||||||
|
if (!libraryName) {
|
||||||
|
libraryName = await findLibraryForItem(config, item.ParentId, libraryIdToName, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item, libraryName };
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch people (cast) from Jellyfin
|
// Fetch people (cast) from Jellyfin
|
||||||
async function fetchJellyfinPeople(config: JellyfinConfig, limit?: number): Promise<JellyfinPerson[]> {
|
async function fetchJellyfinPeople(config: JellyfinConfig, limit?: number): Promise<JellyfinPerson[]> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -258,7 +434,41 @@ async function fetchJellyfinSeriesEpisodes(config: JellyfinConfig, seriesId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert Jellyfin movie to API media format
|
// Convert Jellyfin movie to API media format
|
||||||
function convertJellyfinMovieToMedia(item: JellyfinItem, config: JellyfinConfig): any {
|
async function convertJellyfinMovieToMedia(
|
||||||
|
item: JellyfinItem,
|
||||||
|
config: JellyfinConfig,
|
||||||
|
libraryIdToName: Map<string, string>,
|
||||||
|
libraryMappings?: LibraryMapping[]
|
||||||
|
): Promise<any | null> {
|
||||||
|
// Determine category based on library mapping
|
||||||
|
let category = 'Movies';
|
||||||
|
let libraryName: string | null = null;
|
||||||
|
|
||||||
|
if (libraryMappings && libraryMappings.length > 0) {
|
||||||
|
// Try to find library by ParentId first
|
||||||
|
if (item.ParentId) {
|
||||||
|
libraryName = libraryIdToName.get(item.ParentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found by ParentId, try to extract from path
|
||||||
|
if (!libraryName && item.Path) {
|
||||||
|
libraryName = extractLibraryFromPath(item.Path, libraryIdToName, libraryMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryName) {
|
||||||
|
const mapping = libraryMappings.find(m => m.libraryName === libraryName);
|
||||||
|
|
||||||
|
// Check if library should be skipped
|
||||||
|
if (mapping && mapping.category === 'skip') {
|
||||||
|
return null; // Skip this item
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping && mapping.category !== 'skip') {
|
||||||
|
category = mapping.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const poster = item.ImageTags?.Primary
|
const poster = item.ImageTags?.Primary
|
||||||
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
|
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
|
||||||
: null;
|
: null;
|
||||||
@@ -289,8 +499,8 @@ function convertJellyfinMovieToMedia(item: JellyfinItem, config: JellyfinConfig)
|
|||||||
banner: banner || backdrop,
|
banner: banner || backdrop,
|
||||||
description: item.Overview || null,
|
description: item.Overview || null,
|
||||||
rating: item.CommunityRating || null,
|
rating: item.CommunityRating || null,
|
||||||
category: 'Movies',
|
category: category,
|
||||||
type: 'Movie',
|
type: category === 'Anime' ? 'Anime' : 'Movie',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
aspectRatio: '2/3',
|
aspectRatio: '2/3',
|
||||||
runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null,
|
runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null,
|
||||||
@@ -306,7 +516,41 @@ function convertJellyfinMovieToMedia(item: JellyfinItem, config: JellyfinConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert Jellyfin series to API media format
|
// Convert Jellyfin series to API media format
|
||||||
async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: JellyfinConfig): Promise<any> {
|
async function convertJellyfinSeriesToMedia(
|
||||||
|
item: JellyfinItem,
|
||||||
|
config: JellyfinConfig,
|
||||||
|
libraryIdToName: Map<string, string>,
|
||||||
|
libraryMappings?: LibraryMapping[]
|
||||||
|
): Promise<any | null> {
|
||||||
|
// Determine category based on library mapping
|
||||||
|
let category = 'TV Series';
|
||||||
|
let libraryName: string | null = null;
|
||||||
|
|
||||||
|
if (libraryMappings && libraryMappings.length > 0) {
|
||||||
|
// Try to find library by ParentId first
|
||||||
|
if (item.ParentId) {
|
||||||
|
libraryName = libraryIdToName.get(item.ParentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found by ParentId, try to extract from path
|
||||||
|
if (!libraryName && item.Path) {
|
||||||
|
libraryName = extractLibraryFromPath(item.Path, libraryIdToName, libraryMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryName) {
|
||||||
|
const mapping = libraryMappings.find(m => m.libraryName === libraryName);
|
||||||
|
|
||||||
|
// Check if library should be skipped
|
||||||
|
if (mapping && mapping.category === 'skip') {
|
||||||
|
return null; // Skip this item
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping && mapping.category !== 'skip') {
|
||||||
|
category = mapping.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const poster = item.ImageTags?.Primary
|
const poster = item.ImageTags?.Primary
|
||||||
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
|
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
|
||||||
: null;
|
: null;
|
||||||
@@ -346,6 +590,9 @@ async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: Jellyfin
|
|||||||
console.warn(`Failed to fetch episodes for series ${item.Name}:`, error);
|
console.warn(`Failed to fetch episodes for series ${item.Name}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set type based on category
|
||||||
|
const type = category === 'Anime' ? 'Anime' : 'TV';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: item.Name,
|
title: item.Name,
|
||||||
sortingName: item.SortName || null,
|
sortingName: item.SortName || null,
|
||||||
@@ -354,8 +601,8 @@ async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: Jellyfin
|
|||||||
banner: banner || backdrop,
|
banner: banner || backdrop,
|
||||||
description: item.Overview || null,
|
description: item.Overview || null,
|
||||||
rating: item.CommunityRating || null,
|
rating: item.CommunityRating || null,
|
||||||
category: 'TV Series',
|
category: category,
|
||||||
type: 'TV',
|
type: type,
|
||||||
status: 'ongoing',
|
status: 'ongoing',
|
||||||
aspectRatio: '2/3',
|
aspectRatio: '2/3',
|
||||||
runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null,
|
runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null,
|
||||||
@@ -372,7 +619,41 @@ async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: Jellyfin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert Jellyfin music album to API media format
|
// Convert Jellyfin music album to API media format
|
||||||
async function convertJellyfinAlbumToMedia(item: JellyfinItem, config: JellyfinConfig): Promise<any> {
|
async function convertJellyfinAlbumToMedia(
|
||||||
|
item: JellyfinItem,
|
||||||
|
config: JellyfinConfig,
|
||||||
|
libraryIdToName: Map<string, string>,
|
||||||
|
libraryMappings?: LibraryMapping[]
|
||||||
|
): Promise<any | null> {
|
||||||
|
// Determine category based on library mapping
|
||||||
|
let category = 'Music';
|
||||||
|
let libraryName: string | null = null;
|
||||||
|
|
||||||
|
if (libraryMappings && libraryMappings.length > 0) {
|
||||||
|
// Try to find library by ParentId first
|
||||||
|
if (item.ParentId) {
|
||||||
|
libraryName = libraryIdToName.get(item.ParentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found by ParentId, try to extract from path
|
||||||
|
if (!libraryName && item.Path) {
|
||||||
|
libraryName = extractLibraryFromPath(item.Path, libraryIdToName, libraryMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryName) {
|
||||||
|
const mapping = libraryMappings.find(m => m.libraryName === libraryName);
|
||||||
|
|
||||||
|
// Check if library should be skipped
|
||||||
|
if (mapping && mapping.category === 'skip') {
|
||||||
|
return null; // Skip this item
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping && mapping.category !== 'skip') {
|
||||||
|
category = mapping.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const poster = item.ImageTags?.Primary
|
const poster = item.ImageTags?.Primary
|
||||||
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
|
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
|
||||||
: null;
|
: null;
|
||||||
@@ -421,7 +702,7 @@ async function convertJellyfinAlbumToMedia(item: JellyfinItem, config: JellyfinC
|
|||||||
banner: banner,
|
banner: banner,
|
||||||
description: item.Overview || null,
|
description: item.Overview || null,
|
||||||
rating: item.CommunityRating || null,
|
rating: item.CommunityRating || null,
|
||||||
category: 'Music',
|
category: category,
|
||||||
type: 'Album',
|
type: 'Album',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
aspectRatio: '1/1',
|
aspectRatio: '1/1',
|
||||||
@@ -501,6 +782,19 @@ export async function importFromJellyfin(
|
|||||||
);
|
);
|
||||||
logCallback(`Found ${existingCast.size} existing cast members in database`);
|
logCallback(`Found ${existingCast.size} existing cast members in database`);
|
||||||
|
|
||||||
|
// Step 0.5: Fetch Jellyfin libraries for category mapping
|
||||||
|
let libraryIdToName = new Map<string, string>();
|
||||||
|
if (options.libraryMappings && options.libraryMappings.length > 0) {
|
||||||
|
try {
|
||||||
|
logCallback('Fetching Jellyfin libraries for category mapping...');
|
||||||
|
const libraries = await fetchJellyfinLibraries(config);
|
||||||
|
libraryIdToName = new Map(libraries.map(lib => [lib.Id, lib.Name]));
|
||||||
|
logCallback(`Found ${libraries.length} libraries`);
|
||||||
|
} catch (error) {
|
||||||
|
logCallback('Warning: Failed to fetch libraries, using default categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate total items to process
|
// Calculate total items to process
|
||||||
let totalItems = 0;
|
let totalItems = 0;
|
||||||
if (importMovies) totalItems++;
|
if (importMovies) totalItems++;
|
||||||
@@ -537,7 +831,13 @@ export async function importFromJellyfin(
|
|||||||
const isUpdate = existing !== undefined;
|
const isUpdate = existing !== undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaData = convertJellyfinMovieToMedia(movie, config);
|
const mediaData = await convertJellyfinMovieToMedia(movie, config, libraryIdToName, options.libraryMappings);
|
||||||
|
|
||||||
|
// Skip if library is marked as skip
|
||||||
|
if (!mediaData) {
|
||||||
|
logCallback(`⊘ Skipped movie: ${movie.Name} (library marked as skip)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
@@ -609,7 +909,13 @@ export async function importFromJellyfin(
|
|||||||
const isUpdate = existing !== undefined;
|
const isUpdate = existing !== undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaData = await convertJellyfinSeriesToMedia(show, config);
|
const mediaData = await convertJellyfinSeriesToMedia(show, config, libraryIdToName, options.libraryMappings);
|
||||||
|
|
||||||
|
// Skip if library is marked as skip
|
||||||
|
if (!mediaData) {
|
||||||
|
logCallback(`⊘ Skipped series: ${show.Name} (library marked as skip)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
@@ -681,7 +987,13 @@ export async function importFromJellyfin(
|
|||||||
const isUpdate = existing !== undefined;
|
const isUpdate = existing !== undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mediaData = await convertJellyfinAlbumToMedia(album, config);
|
const mediaData = await convertJellyfinAlbumToMedia(album, config, libraryIdToName, options.libraryMappings);
|
||||||
|
|
||||||
|
// Skip if library is marked as skip
|
||||||
|
if (!mediaData) {
|
||||||
|
logCallback(`⊘ Skipped album: ${album.Name} (library marked as skip)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface UserSettings {
|
|||||||
autoPlayTrailers: boolean;
|
autoPlayTrailers: boolean;
|
||||||
language: string;
|
language: string;
|
||||||
theme: 'light' | 'dark' | 'system';
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
jellyfinLibraryMappings?: string; // JSON string of LibraryMapping[]
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user