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
+324 -12
View File
@@ -8,12 +8,19 @@ export interface JellyfinConfig {
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 {
importMovies?: boolean;
importSeries?: boolean;
importMusic?: boolean;
importCast?: boolean;
limit?: number;
libraryMappings?: LibraryMapping[];
}
export interface ImportProgress {
@@ -72,6 +79,9 @@ export interface JellyfinItem {
LocationType?: string;
DateCreated?: string;
DateLastMediaAdded?: string;
CollectionType?: string;
ParentId?: string;
Path?: string;
}
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
async function fetchWithAuth(url: string, apiKey: string, options: RequestInit = {}): Promise<Response> {
const headers = {
@@ -161,6 +218,79 @@ async function fetchWithAuth(url: string, apiKey: string, options: RequestInit =
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
async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limit?: number): Promise<JellyfinItem[]> {
const userId = await getJellyfinUserId(config);
@@ -168,7 +298,7 @@ async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limi
const params = new URLSearchParams({
'IncludeItemTypes': itemType,
'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',
'SortOrder': 'Ascending'
});
@@ -189,6 +319,52 @@ async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limi
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
async function fetchJellyfinPeople(config: JellyfinConfig, limit?: number): Promise<JellyfinPerson[]> {
const params = new URLSearchParams({
@@ -258,7 +434,41 @@ async function fetchJellyfinSeriesEpisodes(config: JellyfinConfig, seriesId: str
}
// 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
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
: null;
@@ -289,8 +499,8 @@ function convertJellyfinMovieToMedia(item: JellyfinItem, config: JellyfinConfig)
banner: banner || backdrop,
description: item.Overview || null,
rating: item.CommunityRating || null,
category: 'Movies',
type: 'Movie',
category: category,
type: category === 'Anime' ? 'Anime' : 'Movie',
status: 'completed',
aspectRatio: '2/3',
runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null,
@@ -306,7 +516,41 @@ function convertJellyfinMovieToMedia(item: JellyfinItem, config: JellyfinConfig)
}
// 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
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
: null;
@@ -345,6 +589,9 @@ async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: Jellyfin
} catch (error) {
console.warn(`Failed to fetch episodes for series ${item.Name}:`, error);
}
// Set type based on category
const type = category === 'Anime' ? 'Anime' : 'TV';
return {
title: item.Name,
@@ -354,8 +601,8 @@ async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: Jellyfin
banner: banner || backdrop,
description: item.Overview || null,
rating: item.CommunityRating || null,
category: 'TV Series',
type: 'TV',
category: category,
type: type,
status: 'ongoing',
aspectRatio: '2/3',
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
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
? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary')
: null;
@@ -421,7 +702,7 @@ async function convertJellyfinAlbumToMedia(item: JellyfinItem, config: JellyfinC
banner: banner,
description: item.Overview || null,
rating: item.CommunityRating || null,
category: 'Music',
category: category,
type: 'Album',
status: 'completed',
aspectRatio: '1/1',
@@ -501,6 +782,19 @@ export async function importFromJellyfin(
);
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
let totalItems = 0;
if (importMovies) totalItems++;
@@ -537,7 +831,13 @@ export async function importFromJellyfin(
const isUpdate = existing !== undefined;
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;
if (isUpdate) {
@@ -609,7 +909,13 @@ export async function importFromJellyfin(
const isUpdate = existing !== undefined;
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;
if (isUpdate) {
@@ -681,7 +987,13 @@ export async function importFromJellyfin(
const isUpdate = existing !== undefined;
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;
if (isUpdate) {