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; }