From 555209ed4bc3ba7eff07acf32c7f10691b79cf69 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Sat, 11 Apr 2026 01:24:50 +0200 Subject: [PATCH] Add Jellyfin importer and UI improvements Introduce a full Jellyfin importer and related UI enhancements. - Add new lib/jellyfinImporter.ts: implements Jellyfin API clients, conversion helpers, and import/cleanup flows (movies, series, music, cast) with progress/log callbacks. - Wire Jellyfin integration into ImporterView: add config/options state, import and cleanup handlers, and two new UI cards for importing and cleaning up Jellyfin media; adjust progress display to support different media types and cast naming. - Update API types (src/api.ts) to include ApiEpisode and episodes on ApiMediaItem and propagate episodes through convertApiToMedia. - Improve DetailView: add cast show/hide controls, display counts, use characterName when available, and format episode season/episode, air date and duration. - Enhance Header: theme/scroll-aware styling, scroll listener, themed search/input/avatar styling, and improved nav color handling. - Simplify MediaDetailRoute in App.tsx: always fetch media by id and remove allMedia dependency to avoid stale resolution. - Update src/types.ts to support source/category mapping required by the Jellyfin importer. These changes add Jellyfin as an import source and polish the app UI and detail handling for better UX and more complete media metadata. --- src/App.tsx | 25 +- src/api.ts | 16 +- src/components/DetailView.tsx | 39 +- src/components/Header.tsx | 109 +++- src/components/ImporterView.tsx | 295 ++++++++- src/lib/jellyfinImporter.ts | 1005 +++++++++++++++++++++++++++++++ src/types.ts | 10 +- 7 files changed, 1438 insertions(+), 61 deletions(-) create mode 100644 src/lib/jellyfinImporter.ts diff --git a/src/App.tsx b/src/App.tsx index 5123b80..b076f0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -389,28 +389,21 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC useEffect(() => { const loadMedia = async () => { if (id) { - // First check if media is in allMedia - const media = allMedia.find(m => m.id === id); - if (media) { - setSelectedMedia(media); - } else { - // If not found, fetch from API - try { - const fetchedMedia = await fetchMediaById(id); - if (fetchedMedia) { - setSelectedMedia(fetchedMedia); - } else { - navigate('/'); - } - } catch (error) { - console.error('Failed to fetch media:', error); + try { + const fetchedMedia = await fetchMediaById(id); + if (fetchedMedia) { + setSelectedMedia(fetchedMedia); + } else { navigate('/'); } + } catch (error) { + console.error('Failed to fetch media:', error); + navigate('/'); } } }; loadMedia(); - }, [id, allMedia]); + }, [id]); if (!selectedMedia) return null; diff --git a/src/api.ts b/src/api.ts index 45df67a..09616d3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -27,6 +27,18 @@ export interface PaginatedResponse { } // Media Types +export interface ApiEpisode { + id: number; + media_id: number; + season: number; + episode_number: number; + title: string; + description: string; + air_date: string; + duration: number; + thumbnail: string; +} + export interface ApiMediaItem { id: number; title: string; @@ -57,6 +69,7 @@ export interface ApiMediaItem { playCount?: number; lastActivity?: string | null; playtime?: number; + episodes?: ApiEpisode[]; } export interface ApiStaff { @@ -320,7 +333,8 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media { completionStatus: apiItem.completionStatus, playCount: apiItem.playCount, lastActivity: apiItem.lastActivity, - playtime: apiItem.playtime + playtime: apiItem.playtime, + episodes: apiItem.episodes }; } diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index e424e99..cbf4872 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -1,5 +1,6 @@ import { Media, Staff } from '@/types'; import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; import { Play, Bookmark, @@ -8,7 +9,8 @@ import { ChevronLeft, ChevronRight, Search, - ListFilter + ListFilter, + ChevronDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -23,6 +25,11 @@ interface DetailViewProps { export default function DetailView({ media, onPersonClick }: DetailViewProps) { const navigate = useNavigate(); + const [castLimit, setCastLimit] = useState(6); + const [showAllCast, setShowAllCast] = useState(false); + + const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []); + const hasMoreCast = (media.staff?.length || 0) > castLimit; return (
{/* Banner */} @@ -199,17 +206,25 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {

Cast & Crew

-
- - +
+ + {showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length} + + {hasMoreCast && ( + + )}
- {media.staff.map(person => ( + {displayedCast.map(person => (

{person.name}

-

{person.role}

+

{person.characterName || person.role}

{person.characterName} @@ -265,9 +280,9 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {

- S1:E{episode.number} • {episode.title} + S{episode.season}:E{episode.episode_number} • {episode.title}

- {episode.date} • {episode.duration} + {episode.air_date} • {episode.duration}m

{episode.description} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index bdca39b..92b1677 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,8 +1,9 @@ import { Search, User, X, Plus, Download, Settings } from 'lucide-react'; import { cn } from '@/lib/utils'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Link, NavLink } from 'react-router-dom'; import { MediaCategory } from '@/types'; +import { useTheme } from '@/contexts/ThemeContext'; interface HeaderProps { onSearch: (query: string) => void; @@ -23,6 +24,17 @@ export default function Header({ }: HeaderProps) { const [isSearchOpen, setIsSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [scrolled, setScrolled] = useState(false); + const { theme } = useTheme(); + + useEffect(() => { + const handleScroll = () => { + setScrolled(window.scrollY > 10); + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); const handleSearchChange = (e: React.ChangeEvent) => { const query = e.target.value; @@ -39,41 +51,61 @@ export default function Header({ }; return ( -

- -
-
+
+
kyoo
{/* Progress Section */} @@ -508,16 +785,26 @@ export default function ImporterView() {
- {(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'} + + {(progress as any).gamesImported !== undefined ? 'Games' : + (progress as any).moviesImported !== undefined ? 'Movies' : + (progress as any).seriesImported !== undefined ? 'Series' : + (progress as any).musicImported !== undefined ? 'Music' : 'Videos'} +
-

{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}

+

+ {(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : + (progress as any).moviesImported !== undefined ? (progress as any).moviesImported : + (progress as any).seriesImported !== undefined ? (progress as any).seriesImported : + (progress as any).musicImported !== undefined ? (progress as any).musicImported : progress.videosImported} +

- Actors + {(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}
-

{progress.actorsImported}

+

{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}

diff --git a/src/lib/jellyfinImporter.ts b/src/lib/jellyfinImporter.ts new file mode 100644 index 0000000..da5d6c7 --- /dev/null +++ b/src/lib/jellyfinImporter.ts @@ -0,0 +1,1005 @@ +const BASE_URL = import.meta.env.VITE_API_URL; + +// Import the source mapping +import { SOURCE_CATEGORY_MAPPING } from '@/types'; + +export interface JellyfinConfig { + url: string; + apiKey: string; +} + +export interface JellyfinImportOptions { + importMovies?: boolean; + importSeries?: boolean; + importMusic?: boolean; + importCast?: boolean; + limit?: number; +} + +export interface ImportProgress { + current: number; + total: number; + stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; + message: string; + moviesImported: number; + seriesImported: number; + musicImported: number; + castImported: number; + errors: string[]; +} + +// Jellyfin API Types +export interface JellyfinItem { + Id: string; + Name: string; + Type: string; + SortName?: string; + Overview?: string; + PremiereDate?: string; + ProductionYear?: number; + CommunityRating?: number; + RunTimeTicks?: number; + Genres?: string[]; + Tags?: string[]; + Studios?: Array<{ Name: string; Id: string }>; + People?: Array<{ + Name: string; + Id: string; + Type: string; + Role?: string; + PrimaryImageTag?: string; + ImageBlurHashes?: any; + }>; + ImageTags?: { + Primary?: string; + Banner?: string; + Logo?: string; + Backdrop?: string; + }; + ParentLogoItemId?: string; + ParentBackdropItemId?: string; + ParentBackdropImageTags?: string[]; + SeriesId?: string; + SeriesName?: string; + SeasonId?: string; + SeasonName?: string; + IndexNumber?: number; + ParentIndexNumber?: number; + Album?: string; + AlbumArtist?: string[]; + Artists?: string[]; + AlbumId?: string; + LocationType?: string; + DateCreated?: string; + DateLastMediaAdded?: string; +} + +export interface JellyfinItemsResponse { + Items: JellyfinItem[]; + TotalRecordCount: number; + StartIndex: number; +} + +export interface JellyfinPerson { + Id: string; + Name: string; + Type: string; + PrimaryImageTag?: string; + ImageBlurHashes?: any; + PremiereDate?: string; + ProductionYear?: number; + Overview?: string; + BirthDate?: string; + DeathDate?: string; + PlaceOfBirth?: string; +} + +export type LogCallback = (message: string) => void; +export type ProgressCallback = (progress: Partial) => void; + +// Helper function to normalize URL (avoid double slashes) +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +// Helper function to get user ID from Jellyfin +async function getJellyfinUserId(config: JellyfinConfig): Promise { + const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Users`, config.apiKey); + + if (!response.ok) { + throw new Error(`Failed to get user ID from Jellyfin: ${response.statusText}`); + } + + const users = await response.json(); + if (!users || users.length === 0) { + throw new Error('No users found in Jellyfin'); + } + + // Return the first user ID (typically the admin/current user) + return users[0].Id; +} + +// Helper function to get image URL from Jellyfin +function getJellyfinImageUrl(config: JellyfinConfig, itemId: string, imageTag: string, imageType: 'Primary' | 'Banner' | 'Backdrop' | 'Logo' = 'Primary'): string { + return `${normalizeUrl(config.url)}/Items/${itemId}/Images/${imageType}?tag=${imageTag}`; +} + +// Helper function to convert ticks to minutes +function ticksToMinutes(ticks: number): number { + return Math.floor(ticks / 600000000); +} + +// Helper function to format date +function formatDate(dateString?: string): string | null { + if (!dateString) return null; + try { + const date = new Date(dateString); + return date.toISOString().split('T')[0]; + } catch { + return null; + } +} + +// Helper function to get year from date +function getYear(dateString?: string): number { + if (!dateString) return new Date().getFullYear(); + try { + const date = new Date(dateString); + return date.getFullYear(); + } catch { + return new Date().getFullYear(); + } +} + +// Helper function to fetch with authentication +async function fetchWithAuth(url: string, apiKey: string, options: RequestInit = {}): Promise { + const headers = { + 'X-MediaBrowser-Token': apiKey, + 'Accept': 'application/json', + ...options.headers + }; + return fetch(url, { ...options, headers }); +} + +// Fetch items from Jellyfin +async function fetchJellyfinItems(config: JellyfinConfig, itemType: string, limit?: number): Promise { + 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', + '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(); + return data.Items || []; +} + +// Fetch people (cast) from Jellyfin +async function fetchJellyfinPeople(config: JellyfinConfig, limit?: number): Promise { + const params = new URLSearchParams({ + 'Recursive': 'true', + 'Fields': 'PrimaryImageTag,ImageBlurHashes,PremiereDate,ProductionYear,Overview,BirthDate,DeathDate,PlaceOfBirth', + 'SortBy': 'SortName', + 'SortOrder': 'Ascending' + }); + + if (limit) { + params.append('Limit', limit.toString()); + } else { + params.append('Limit', '10000'); + } + + const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Persons?${params.toString()}`, config.apiKey); + + if (!response.ok) { + throw new Error(`Failed to fetch people from Jellyfin: ${response.statusText}`); + } + + const data: JellyfinItemsResponse = await response.json(); + return data.Items || []; +} + +// Fetch tracks for an album from Jellyfin +async function fetchJellyfinAlbumTracks(config: JellyfinConfig, albumId: string): Promise { + const userId = await getJellyfinUserId(config); + + const params = new URLSearchParams({ + 'ParentId': albumId, + 'SortBy': 'SortName', + 'SortOrder': 'Ascending' + }); + + const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Users/${userId}/Items?${params.toString()}`, config.apiKey); + + if (!response.ok) { + throw new Error(`Failed to fetch tracks for album from Jellyfin: ${response.statusText}`); + } + + const data: JellyfinItemsResponse = await response.json(); + return data.Items || []; +} + +// Fetch episodes for a series from Jellyfin +async function fetchJellyfinSeriesEpisodes(config: JellyfinConfig, seriesId: string): Promise { + const userId = await getJellyfinUserId(config); + + const params = new URLSearchParams({ + 'ParentId': seriesId, + 'IncludeItemTypes': 'Episode', + 'Recursive': 'true', + 'Fields': 'ParentIndexNumber,IndexNumber,Overview,PremiereDate,RunTimeTicks,PrimaryImageTag', + 'SortBy': 'ParentIndexNumber,IndexNumber', + 'SortOrder': 'Ascending' + }); + + const response = await fetchWithAuth(`${normalizeUrl(config.url)}/Users/${userId}/Items?${params.toString()}`, config.apiKey); + + if (!response.ok) { + throw new Error(`Failed to fetch episodes for series from Jellyfin: ${response.statusText}`); + } + + const data: JellyfinItemsResponse = await response.json(); + return data.Items || []; +} + +// Convert Jellyfin movie to API media format +function convertJellyfinMovieToMedia(item: JellyfinItem, config: JellyfinConfig): any { + const poster = item.ImageTags?.Primary + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary') + : null; + const banner = item.ImageTags?.Banner + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Banner, 'Banner') + : null; + const backdrop = item.ImageTags?.Backdrop + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Backdrop, 'Backdrop') + : null; + + const staff = item.People?.filter(p => p.Type === 'Actor').map(person => ({ + name: person.Name, + role: 'Actor', + photo: person.PrimaryImageTag ? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary') : null, + characterName: person.Role || null, + characterImage: person.PrimaryImageTag ? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary') : null, + occupations: ['Actor'] + })) || []; + + const directors = item.People?.filter(p => p.Type === 'Director').map(p => p.Name) || []; + const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || []; + + return { + title: item.Name, + sortingName: item.SortName || null, + year: item.ProductionYear?.toString() || getYear(item.PremiereDate).toString(), + poster: poster, + banner: banner || backdrop, + description: item.Overview || null, + rating: item.CommunityRating || null, + category: 'Movies', + type: 'Movie', + status: 'completed', + aspectRatio: '2/3', + runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null, + director: directors.length > 0 ? directors[0] : null, + writer: writers.length > 0 ? writers[0] : null, + releaseDate: formatDate(item.PremiereDate), + source: 'jellyfin', + genres: item.Genres || [], + tags: item.Tags || [], + studios: item.Studios?.map(s => s.Name) || [], + staff: staff + }; +} + +// Convert Jellyfin series to API media format +async function convertJellyfinSeriesToMedia(item: JellyfinItem, config: JellyfinConfig): Promise { + const poster = item.ImageTags?.Primary + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary') + : null; + const banner = item.ImageTags?.Banner + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Banner, 'Banner') + : null; + const backdrop = item.ImageTags?.Backdrop + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Backdrop, 'Backdrop') + : null; + + const staff = item.People?.filter(p => p.Type === 'Actor').map(person => ({ + name: person.Name, + role: 'Actor', + photo: person.PrimaryImageTag ? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary') : null, + characterName: person.Role || null, + characterImage: person.PrimaryImageTag ? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary') : null, + occupations: ['Actor'] + })) || []; + + const directors = item.People?.filter(p => p.Type === 'Director').map(p => p.Name) || []; + const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || []; + + // Fetch episodes for this series + let episodes: any[] = []; + try { + const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id); + episodes = jellyfinEpisodes.map(ep => ({ + season: ep.ParentIndexNumber || 1, + episode_number: ep.IndexNumber || 1, + title: ep.Name, + description: ep.Overview || null, + air_date: formatDate(ep.PremiereDate), + duration: ep.RunTimeTicks ? ticksToMinutes(ep.RunTimeTicks) : null, + thumbnail: ep.ImageTags?.Primary ? getJellyfinImageUrl(config, ep.Id, ep.ImageTags.Primary, 'Primary') : null + })); + } catch (error) { + console.warn(`Failed to fetch episodes for series ${item.Name}:`, error); + } + + return { + title: item.Name, + sortingName: item.SortName || null, + year: item.ProductionYear?.toString() || getYear(item.PremiereDate).toString(), + poster: poster, + banner: banner || backdrop, + description: item.Overview || null, + rating: item.CommunityRating || null, + category: 'TV Series', + type: 'TV', + status: 'ongoing', + aspectRatio: '2/3', + runtime: item.RunTimeTicks ? ticksToMinutes(item.RunTimeTicks) : null, + director: directors.length > 0 ? directors[0] : null, + writer: writers.length > 0 ? writers[0] : null, + releaseDate: formatDate(item.PremiereDate), + source: 'jellyfin', + genres: item.Genres || [], + tags: item.Tags || [], + studios: item.Studios?.map(s => s.Name) || [], + staff: staff, + episodes: episodes + }; +} + +// Convert Jellyfin music album to API media format +async function convertJellyfinAlbumToMedia(item: JellyfinItem, config: JellyfinConfig): Promise { + const poster = item.ImageTags?.Primary + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Primary, 'Primary') + : null; + const banner = item.ImageTags?.Banner + ? getJellyfinImageUrl(config, item.Id, item.ImageTags.Banner, 'Banner') + : null; + + // Handle AlbumArtist - can be string or array + let albumArtists: string[] = []; + if (item.AlbumArtist) { + if (Array.isArray(item.AlbumArtist)) { + albumArtists = item.AlbumArtist; + } else { + albumArtists = [item.AlbumArtist]; + } + } + + const staff = albumArtists.map(artist => ({ + name: artist, + role: 'Artist', + photo: null, + characterName: null, + characterImage: null, + occupations: ['Artist'] + })); + + // Fetch tracks for this album + let tracks: any[] = []; + try { + const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id); + tracks = jellyfinTracks.map((track, index) => ({ + track_number: track.IndexNumber || (index + 1), + title: track.Name, + duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null, + artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown' + })); + } catch (error) { + console.warn(`Failed to fetch tracks for album ${item.Name}:`, error); + } + + return { + title: item.Name, + sortingName: item.SortName || null, + year: item.ProductionYear?.toString() || getYear(item.PremiereDate).toString(), + poster: poster, + banner: banner, + description: item.Overview || null, + rating: item.CommunityRating || null, + category: 'Music', + type: 'Album', + status: 'completed', + aspectRatio: '1/1', + runtime: null, + director: null, + writer: null, + releaseDate: formatDate(item.PremiereDate), + source: 'jellyfin', + genres: item.Genres || [], + tags: item.Tags || [], + studios: item.Studios?.map(s => s.Name) || [], + staff: staff, + tracks: tracks + }; +} + +// Convert Jellyfin person to API cast format +function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): any { + const photo = person.PrimaryImageTag + ? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary') + : null; + + return { + name: person.Name, + photo: photo, + bio: person.Overview || null, + birthDate: person.BirthDate ? formatDate(person.BirthDate) : null, + birthPlace: person.PlaceOfBirth || null, + occupations: [person.Type === 'Actor' ? 'Actor' : person.Type || 'Person'] + }; +} + +// Main import function +export async function importFromJellyfin( + config: JellyfinConfig, + options: JellyfinImportOptions, + logCallback: LogCallback, + progressCallback: ProgressCallback +): Promise { + const progress: ImportProgress = { + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to Jellyfin API...', + moviesImported: 0, + seriesImported: 0, + musicImported: 0, + castImported: 0, + errors: [] + }; + + const { + importMovies = true, + importSeries = true, + importMusic = true, + importCast = true, + limit + } = options; + + try { + logCallback('Starting Jellyfin import...'); + + // Step 0: Fetch existing media and cast to check for duplicates + logCallback('Fetching existing media from Kyoo API...'); + const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`); + const existingMediaData = await existingMediaResponse.json(); + const existingMedia = new Map( + (existingMediaData.data?.items || []).map((m: any) => [m.title, m]) + ); + logCallback(`Found ${existingMedia.size} existing media items in database`); + + logCallback('Fetching existing cast from Kyoo API...'); + const existingCastResponse = await fetch(`${BASE_URL}/api/cast`); + const existingCastData = await existingCastResponse.json(); + const existingCast = new Map( + (existingCastData.data?.items || []).map((c: any) => [c.name, c]) + ); + logCallback(`Found ${existingCast.size} existing cast members in database`); + + // Calculate total items to process + let totalItems = 0; + if (importMovies) totalItems++; + if (importSeries) totalItems++; + if (importMusic) totalItems++; + if (importCast) totalItems++; + + let currentItem = 0; + + // Import Movies + if (importMovies) { + currentItem++; + progressCallback({ + total: totalItems, + current: currentItem, + stage: 'fetching', + message: `Fetching movies from Jellyfin...` + }); + logCallback('Fetching movies from Jellyfin...'); + + try { + const movies = await fetchJellyfinItems(config, 'Movie', limit); + logCallback(`Found ${movies.length} movies in Jellyfin`); + + progressCallback({ + message: 'Importing movies...', + stage: 'importing' + }); + + let moviesImported = 0; + for (let i = 0; i < movies.length; i++) { + const movie = movies[i]; + const existing = existingMedia.get(movie.Name); + const isUpdate = existing !== undefined; + + try { + const mediaData = convertJellyfinMovieToMedia(movie, config); + + let response; + if (isUpdate) { + response = await fetch(`${BASE_URL}/api/media/${(existing as any).id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mediaData) + }); + } else { + response = await fetch(`${BASE_URL}/api/media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mediaData) + }); + } + + if (response.ok) { + moviesImported++; + logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} movie: ${movie.Name}`); + } else { + const error = await response.text(); + progress.errors.push(`Failed to ${isUpdate ? 'update' : 'import'} movie ${movie.Name}: ${error}`); + logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} movie: ${movie.Name}`); + } + } catch (error) { + progress.errors.push(`Error processing movie ${movie.Name}: ${error}`); + logCallback(`✗ Error processing movie: ${movie.Name}`); + } + + progressCallback({ + moviesImported, + errors: progress.errors + }); + } + + logCallback(`Processed ${moviesImported}/${movies.length} movies`); + progress.moviesImported = moviesImported; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.errors.push(`Movie import failed: ${errorMessage}`); + logCallback(`✗ Movie import failed: ${errorMessage}`); + } + } + + // Import Series + if (importSeries) { + currentItem++; + progressCallback({ + total: totalItems, + current: currentItem, + stage: 'fetching', + message: `Fetching series from Jellyfin...` + }); + logCallback('Fetching series from Jellyfin...'); + + try { + const series = await fetchJellyfinItems(config, 'Series', limit); + logCallback(`Found ${series.length} series in Jellyfin`); + + progressCallback({ + message: 'Importing series...', + stage: 'importing' + }); + + let seriesImported = 0; + for (let i = 0; i < series.length; i++) { + const show = series[i]; + const existing = existingMedia.get(show.Name); + const isUpdate = existing !== undefined; + + try { + const mediaData = await convertJellyfinSeriesToMedia(show, config); + + let response; + if (isUpdate) { + response = await fetch(`${BASE_URL}/api/media/${(existing as any).id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mediaData) + }); + } else { + response = await fetch(`${BASE_URL}/api/media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mediaData) + }); + } + + if (response.ok) { + seriesImported++; + logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} series: ${show.Name}`); + } else { + const error = await response.text(); + progress.errors.push(`Failed to ${isUpdate ? 'update' : 'import'} series ${show.Name}: ${error}`); + logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} series: ${show.Name}`); + } + } catch (error) { + progress.errors.push(`Error processing series ${show.Name}: ${error}`); + logCallback(`✗ Error processing series: ${show.Name}`); + } + + progressCallback({ + seriesImported, + errors: progress.errors + }); + } + + logCallback(`Processed ${seriesImported}/${series.length} series`); + progress.seriesImported = seriesImported; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.errors.push(`Series import failed: ${errorMessage}`); + logCallback(`✗ Series import failed: ${errorMessage}`); + } + } + + // Import Music + if (importMusic) { + currentItem++; + progressCallback({ + total: totalItems, + current: currentItem, + stage: 'fetching', + message: `Fetching music from Jellyfin...` + }); + logCallback('Fetching music from Jellyfin...'); + + try { + const albums = await fetchJellyfinItems(config, 'MusicAlbum', limit); + logCallback(`Found ${albums.length} albums in Jellyfin`); + + progressCallback({ + message: 'Importing music...', + stage: 'importing' + }); + + let musicImported = 0; + for (let i = 0; i < albums.length; i++) { + const album = albums[i]; + const existing = existingMedia.get(album.Name); + const isUpdate = existing !== undefined; + + try { + const mediaData = await convertJellyfinAlbumToMedia(album, config); + + let response; + if (isUpdate) { + response = await fetch(`${BASE_URL}/api/media/${(existing as any).id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mediaData) + }); + } else { + response = await fetch(`${BASE_URL}/api/media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mediaData) + }); + } + + if (response.ok) { + musicImported++; + logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} album: ${album.Name}`); + } else { + const error = await response.text(); + progress.errors.push(`Failed to ${isUpdate ? 'update' : 'import'} album ${album.Name}: ${error}`); + logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} album: ${album.Name}`); + } + } catch (error) { + progress.errors.push(`Error processing album ${album.Name}: ${error}`); + logCallback(`✗ Error processing album: ${album.Name}`); + } + + progressCallback({ + musicImported, + errors: progress.errors + }); + } + + logCallback(`Processed ${musicImported}/${albums.length} albums`); + progress.musicImported = musicImported; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.errors.push(`Music import failed: ${errorMessage}`); + logCallback(`✗ Music import failed: ${errorMessage}`); + } + } + + // Import Cast + if (importCast) { + currentItem++; + progressCallback({ + total: totalItems, + current: currentItem, + stage: 'fetching', + message: `Fetching cast from Jellyfin...` + }); + logCallback('Fetching cast from Jellyfin...'); + + try { + const people = await fetchJellyfinPeople(config, limit); + logCallback(`Found ${people.length} people in Jellyfin`); + + progressCallback({ + message: 'Importing cast...', + stage: 'importing' + }); + + let castImported = 0; + for (let i = 0; i < people.length; i++) { + const person = people[i]; + const existing = existingCast.get(person.Name); + const isUpdate = existing !== undefined; + + try { + const castData = convertJellyfinPersonToCast(person, config); + + let response; + if (isUpdate) { + response = await fetch(`${BASE_URL}/api/cast/${(existing as any).id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(castData) + }); + } else { + response = await fetch(`${BASE_URL}/api/cast`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(castData) + }); + } + + if (response.ok) { + castImported++; + logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} cast: ${person.Name}`); + } else { + const error = await response.text(); + progress.errors.push(`Failed to ${isUpdate ? 'update' : 'import'} cast ${person.Name}: ${error}`); + logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} cast: ${person.Name}`); + } + } catch (error) { + progress.errors.push(`Error processing cast ${person.Name}: ${error}`); + logCallback(`✗ Error processing cast: ${person.Name}`); + } + + progressCallback({ + castImported, + errors: progress.errors + }); + } + + logCallback(`Processed ${castImported}/${people.length} cast members`); + progress.castImported = castImported; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.errors.push(`Cast import failed: ${errorMessage}`); + logCallback(`✗ Cast import failed: ${errorMessage}`); + } + } + + // Complete + progress.stage = 'complete'; + progress.message = 'Import complete!'; + progress.current = totalItems; + progress.total = totalItems; + logCallback('Import completed successfully!'); + + return progress; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.stage = 'error'; + progress.message = `Import failed: ${errorMessage}`; + progress.errors = [...progress.errors, errorMessage]; + logCallback(`✗ Import failed: ${errorMessage}`); + return progress; + } +} + +// Cleanup function to remove Jellyfin media that no longer exists in Jellyfin +export async function cleanupJellyfinMedia( + config: JellyfinConfig, + options: JellyfinImportOptions, + logCallback: LogCallback, + progressCallback: ProgressCallback +): Promise { + const progress: ImportProgress = { + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to Jellyfin API for cleanup...', + moviesImported: 0, + seriesImported: 0, + musicImported: 0, + castImported: 0, + errors: [] + }; + + const { + importMovies = true, + importSeries = true, + importMusic = true, + importCast = true + } = options; + + try { + logCallback('Starting Jellyfin cleanup...'); + + // Fetch all existing media from Kyoo API + logCallback('Fetching existing media from Kyoo API...'); + const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`); + const existingMediaData = await existingMediaResponse.json(); + const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: any) => m.source === 'jellyfin'); + logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`); + + // Fetch all existing cast from Kyoo API + logCallback('Fetching existing cast from Kyoo API...'); + const existingCastResponse = await fetch(`${BASE_URL}/api/cast`); + const existingCastData = await existingCastResponse.json(); + const jellyfinCast = (existingCastData.data?.items || []).filter((c: any) => c.photo && c.photo.includes(normalizeUrl(config.url))); + logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`); + + // Fetch current items from Jellyfin + const jellyfinItemIds = new Set(); + const jellyfinItemNames = new Set(); + const jellyfinPersonNames = new Set(); + + if (importMovies) { + logCallback('Fetching current movies from Jellyfin...'); + const movies = await fetchJellyfinItems(config, 'Movie'); + movies.forEach(m => { + jellyfinItemIds.add(m.Id); + jellyfinItemNames.add(m.Name); + }); + logCallback(`Found ${movies.length} movies in Jellyfin`); + } + + if (importSeries) { + logCallback('Fetching current series from Jellyfin...'); + const series = await fetchJellyfinItems(config, 'Series'); + series.forEach(s => { + jellyfinItemIds.add(s.Id); + jellyfinItemNames.add(s.Name); + }); + logCallback(`Found ${series.length} series in Jellyfin`); + } + + if (importMusic) { + logCallback('Fetching current music from Jellyfin...'); + const albums = await fetchJellyfinItems(config, 'MusicAlbum'); + albums.forEach(a => { + jellyfinItemIds.add(a.Id); + jellyfinItemNames.add(a.Name); + }); + logCallback(`Found ${albums.length} albums in Jellyfin`); + } + + if (importCast) { + logCallback('Fetching current cast from Jellyfin...'); + const people = await fetchJellyfinPeople(config); + people.forEach(p => { + jellyfinPersonNames.add(p.Name); + }); + logCallback(`Found ${people.length} people in Jellyfin`); + } + + // Calculate total items to process + const totalItems = jellyfinMedia.length + jellyfinCast.length; + progress.total = totalItems; + progressCallback({ total: totalItems }); + + let current = 0; + let mediaDeleted = 0; + let castDeleted = 0; + + // Check and delete media that no longer exists in Jellyfin + progressCallback({ + message: 'Checking media for cleanup...', + stage: 'importing' + }); + + for (const media of jellyfinMedia) { + current++; + try { + // Check if media still exists in Jellyfin by name + if (!jellyfinItemNames.has(media.title)) { + const response = await fetch(`${BASE_URL}/api/media/${media.id}`, { + method: 'DELETE' + }); + + if (response.ok) { + mediaDeleted++; + logCallback(`✓ Deleted media: ${media.title}`); + } else { + const error = await response.text(); + progress.errors.push(`Failed to delete media ${media.title}: ${error}`); + logCallback(`✗ Failed to delete media: ${media.title}`); + } + } else { + logCallback(`⊘ Kept media: ${media.title} (still in Jellyfin)`); + } + } catch (error) { + progress.errors.push(`Error checking media ${media.title}: ${error}`); + logCallback(`✗ Error checking media: ${media.title}`); + } + + progressCallback({ + current, + errors: progress.errors + }); + } + + // Check and delete cast that no longer exists in Jellyfin + for (const cast of jellyfinCast) { + current++; + try { + // Check if person still exists in Jellyfin by name + if (!jellyfinPersonNames.has(cast.name)) { + const response = await fetch(`${BASE_URL}/api/cast/${cast.id}`, { + method: 'DELETE' + }); + + if (response.ok) { + castDeleted++; + logCallback(`✓ Deleted cast: ${cast.name}`); + } else { + const error = await response.text(); + progress.errors.push(`Failed to delete cast ${cast.name}: ${error}`); + logCallback(`✗ Failed to delete cast: ${cast.name}`); + } + } else { + logCallback(`⊘ Kept cast: ${cast.name} (still in Jellyfin)`); + } + } catch (error) { + progress.errors.push(`Error checking cast ${cast.name}: ${error}`); + logCallback(`✗ Error checking cast: ${cast.name}`); + } + + progressCallback({ + current, + errors: progress.errors + }); + } + + logCallback(`Cleanup complete: deleted ${mediaDeleted} media items and ${castDeleted} cast members`); + + // Complete + progress.stage = 'complete'; + progress.message = 'Cleanup complete!'; + progress.current = totalItems; + progress.moviesImported = mediaDeleted; + progress.castImported = castDeleted; + logCallback('Cleanup completed successfully!'); + + return progress; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.stage = 'error'; + progress.message = `Cleanup failed: ${errorMessage}`; + progress.errors = [...progress.errors, errorMessage]; + logCallback(`✗ Cleanup failed: ${errorMessage}`); + return progress; + } +} diff --git a/src/types.ts b/src/types.ts index d58564e..ca8cd5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,12 +28,14 @@ export interface Media { } export interface Episode { - id: string; - number: number; + id: number; + media_id: number; + season: number; + episode_number: number; title: string; - date: string; - duration: string; description: string; + air_date: string; + duration: number; thumbnail: string; }