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.
This commit is contained in:
Lars Behrends
2026-04-11 01:24:50 +02:00
parent 52d272c701
commit 555209ed4b
7 changed files with 1438 additions and 61 deletions

View File

@@ -389,12 +389,6 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
useEffect(() => { useEffect(() => {
const loadMedia = async () => { const loadMedia = async () => {
if (id) { 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 { try {
const fetchedMedia = await fetchMediaById(id); const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) { if (fetchedMedia) {
@@ -407,10 +401,9 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
navigate('/'); navigate('/');
} }
} }
}
}; };
loadMedia(); loadMedia();
}, [id, allMedia]); }, [id]);
if (!selectedMedia) return null; if (!selectedMedia) return null;

View File

@@ -27,6 +27,18 @@ export interface PaginatedResponse<T> {
} }
// Media Types // 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 { export interface ApiMediaItem {
id: number; id: number;
title: string; title: string;
@@ -57,6 +69,7 @@ export interface ApiMediaItem {
playCount?: number; playCount?: number;
lastActivity?: string | null; lastActivity?: string | null;
playtime?: number; playtime?: number;
episodes?: ApiEpisode[];
} }
export interface ApiStaff { export interface ApiStaff {
@@ -320,7 +333,8 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
completionStatus: apiItem.completionStatus, completionStatus: apiItem.completionStatus,
playCount: apiItem.playCount, playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity, lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime playtime: apiItem.playtime,
episodes: apiItem.episodes
}; };
} }

View File

@@ -1,5 +1,6 @@
import { Media, Staff } from '@/types'; import { Media, Staff } from '@/types';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { import {
Play, Play,
Bookmark, Bookmark,
@@ -8,7 +9,8 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Search, Search,
ListFilter ListFilter,
ChevronDown
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -23,6 +25,11 @@ interface DetailViewProps {
export default function DetailView({ media, onPersonClick }: DetailViewProps) { export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const navigate = useNavigate(); 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 ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Banner */} {/* Banner */}
@@ -199,17 +206,25 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
<section className="mt-20"> <section className="mt-20">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-black text-foreground">Cast & Crew</h2> <h2 className="text-2xl font-black text-foreground">Cast & Crew</h2>
<div className="flex gap-2"> <div className="flex items-center gap-4">
<Button variant="outline" size="icon" className="rounded-full border-border"> <span className="text-sm font-bold text-muted-foreground">
<ChevronLeft size={18} /> {showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length}
</Button> </span>
<Button variant="outline" size="icon" className="rounded-full border-border"> {hasMoreCast && (
<ChevronRight size={18} /> <Button
variant="outline"
size="sm"
onClick={() => setShowAllCast(!showAllCast)}
className="rounded-full border-border font-bold"
>
{showAllCast ? 'Show Less' : 'Show All'}
<ChevronDown size={16} className={`ml-2 transition-transform ${showAllCast ? 'rotate-180' : ''}`} />
</Button> </Button>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{media.staff.map(person => ( {displayedCast.map(person => (
<div <div
key={person.id} key={person.id}
className="flex items-center gap-4 bg-card p-3 rounded-xl shadow-sm border border-border hover:shadow-md transition-shadow cursor-pointer group" className="flex items-center gap-4 bg-card p-3 rounded-xl shadow-sm border border-border hover:shadow-md transition-shadow cursor-pointer group"
@@ -220,7 +235,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4> <h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.role}</p> <p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div> </div>
<div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-muted"> <div className="w-16 h-20 rounded-lg overflow-hidden shrink-0 bg-muted">
<img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" /> <img src={person.characterImage} alt={person.characterName} className="w-full h-full object-contain" referrerPolicy="no-referrer" />
@@ -265,9 +280,9 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
<div className="flex-1 py-1"> <div className="flex-1 py-1">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors"> <h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors">
S1:E{episode.number} {episode.title} S{episode.season}:E{episode.episode_number} {episode.title}
</h3> </h3>
<span className="text-xs font-bold text-muted-foreground">{episode.date} {episode.duration}</span> <span className="text-xs font-bold text-muted-foreground">{episode.air_date} {episode.duration}m</span>
</div> </div>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3"> <p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{episode.description} {episode.description}

View File

@@ -1,8 +1,9 @@
import { Search, User, X, Plus, Download, Settings } from 'lucide-react'; import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { MediaCategory } from '@/types'; import { MediaCategory } from '@/types';
import { useTheme } from '@/contexts/ThemeContext';
interface HeaderProps { interface HeaderProps {
onSearch: (query: string) => void; onSearch: (query: string) => void;
@@ -23,6 +24,17 @@ export default function Header({
}: HeaderProps) { }: HeaderProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); 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<HTMLInputElement>) => { const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value; const query = e.target.value;
@@ -42,16 +54,29 @@ export default function Header({
<header <header
className={cn( className={cn(
"fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300", "fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 transition-all duration-300",
transparent ? "bg-transparent" : "bg-[#6d28d9]" transparent && !scrolled
? "bg-transparent"
: transparent && scrolled
? "backdrop-blur-md bg-background/80 border-b border-border/50"
: "bg-[#6d28d9]"
)} )}
> >
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<Link <Link
to="/" to="/"
className="text-2xl font-black text-white flex items-center gap-1" className={cn(
"text-2xl font-black flex items-center gap-1",
(transparent && !scrolled) || !transparent ? "text-white" : "text-foreground"
)}
> >
<div className="w-6 h-6 bg-white rounded-full flex items-center justify-center"> <div className={cn(
<div className="w-3 h-3 bg-[#6d28d9] rounded-full" /> "w-6 h-6 rounded-full flex items-center justify-center",
(transparent && !scrolled) || !transparent ? "bg-white" : "bg-[#6d28d9]"
)}>
<div className={cn(
"w-3 h-3 rounded-full",
(transparent && !scrolled) || !transparent ? "bg-[#6d28d9]" : "bg-white"
)} />
</div> </div>
kyoo kyoo
</Link> </Link>
@@ -62,18 +87,25 @@ export default function Header({
onClick={() => onCategoryChange(cat)} onClick={() => onCategoryChange(cat)}
className={cn( className={cn(
"text-sm font-bold transition-colors uppercase tracking-wider", "text-sm font-bold transition-colors uppercase tracking-wider",
activeCategory === cat ? "text-white" : "text-white/60 hover:text-white" (transparent && !scrolled) || !transparent
? activeCategory === cat ? "text-white" : "text-white/60 hover:text-white"
: activeCategory === cat ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)} )}
> >
{cat} {cat}
</button> </button>
))} ))}
<div className="w-px h-4 bg-white/20 mx-2" /> <div className={cn(
"w-px h-4 mx-2",
(transparent && !scrolled) || !transparent ? "bg-white/20" : "bg-border"
)} />
<NavLink <NavLink
to="/cast" to="/cast"
className={({ isActive }) => cn( className={({ isActive }) => cn(
"text-sm font-bold transition-colors uppercase tracking-wider", "text-sm font-bold transition-colors uppercase tracking-wider",
isActive ? "text-white" : "text-white/60 hover:text-white" (transparent && !scrolled) || !transparent
? isActive ? "text-white" : "text-white/60 hover:text-white"
: isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)} )}
> >
CAST CAST
@@ -83,42 +115,71 @@ export default function Header({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cn( <div className={cn(
"flex items-center transition-all duration-300 overflow-hidden", "flex items-center transition-all duration-300 overflow-hidden",
isSearchOpen ? "w-48 md:w-64 bg-white/10 rounded-full px-3 py-1" : "w-0" isSearchOpen ? "w-48 md:w-64 rounded-full px-3 py-1" : "w-0",
(transparent && !scrolled) || !transparent ? "bg-white/10" : "bg-muted"
)}> )}>
<input <input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
value={searchQuery} value={searchQuery}
onChange={handleSearchChange} onChange={handleSearchChange}
className="bg-transparent border-none outline-none text-white text-sm w-full placeholder:text-white/50" className={cn(
"bg-transparent border-none outline-none text-sm w-full",
(transparent && !scrolled) || !transparent
? "text-white placeholder:text-white/50"
: "text-foreground placeholder:text-muted-foreground"
)}
autoFocus={isSearchOpen} autoFocus={isSearchOpen}
/> />
</div> </div>
<button <button
onClick={toggleSearch} onClick={toggleSearch}
className="p-2 text-white/90 hover:text-white transition-colors" className={cn(
"p-2 transition-colors",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground"
)}
> >
{isSearchOpen ? <X size={20} /> : <Search size={20} />} {isSearchOpen ? <X size={20} /> : <Search size={20} />}
</button> </button>
<Link <Link
to="/add" to="/add"
className="p-2 text-white/90 hover:text-white transition-colors" className={cn(
"p-2 transition-colors",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground"
)}
> >
<Plus size={20} /> <Plus size={20} />
</Link> </Link>
<Link <Link
to="/import" to="/import"
className="p-2 text-white/90 hover:text-white transition-colors" className={cn(
"p-2 transition-colors",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground"
)}
> >
<Download size={20} /> <Download size={20} />
</Link> </Link>
<Link <Link
to="/settings" to="/settings"
className="p-2 text-white/90 hover:text-white transition-colors" className={cn(
"p-2 transition-colors",
(transparent && !scrolled) || !transparent
? "text-white/90 hover:text-white"
: "text-foreground hover:text-foreground"
)}
> >
<Settings size={20} /> <Settings size={20} />
</Link> </Link>
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20"> <button className={cn(
"w-8 h-8 rounded-full overflow-hidden border-2",
(transparent && !scrolled) || !transparent ? "border-white/20" : "border-border"
)}>
<img <img
src="https://picsum.photos/seed/user/100/100" src="https://picsum.photos/seed/user/100/100"
alt="User" alt="User"

View File

@@ -6,6 +6,7 @@ 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';
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000'; const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
@@ -21,6 +22,17 @@ export default function ImporterView() {
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '', apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821') port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821')
}); });
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
url: import.meta.env.VITE_JELLYFIN_URL || '',
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
});
const [jellyfinOptions, setJellyfinOptions] = useState<JellyfinImportOptions>({
importMovies: true,
importSeries: true,
importMusic: true,
importCast: true,
limit: undefined
});
const [progress, setProgress] = useState<ImportProgress>({ const [progress, setProgress] = useState<ImportProgress>({
current: 0, current: 0,
total: 0, total: 0,
@@ -137,6 +149,54 @@ export default function ImporterView() {
setProgress(result); setProgress(result);
}; };
const handleJellyfinImport = async () => {
setProgress({
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to Jellyfin API...',
videosImported: 0,
actorsImported: 0,
errors: []
});
setImportLog([]);
const result = await importFromJellyfin(
jellyfinConfig,
jellyfinOptions,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
}
);
setProgress(result);
};
const handleJellyfinCleanup = async () => {
setProgress({
current: 0,
total: 0,
stage: 'fetching',
message: 'Connecting to Jellyfin API for cleanup...',
videosImported: 0,
actorsImported: 0,
errors: []
});
setImportLog([]);
const result = await cleanupJellyfinMedia(
jellyfinConfig,
jellyfinOptions,
addLog,
(progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate }));
}
);
setProgress(result);
};
const resetImport = () => { const resetImport = () => {
setProgress({ setProgress({
current: 0, current: 0,
@@ -441,6 +501,223 @@ export default function ImporterView() {
</div> </div>
</div> </div>
)} )}
{/* Jellyfin Importer Card */}
{jellyfinConfig.url && (
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<Film className="text-indigo-600" size={24} />
</div>
<div>
<h3 className="font-bold text-foreground">Jellyfin</h3>
<p className="text-xs text-muted-foreground font-medium">Media Server</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Import movies, series, music and cast from your Jellyfin server.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Jellyfin URL</label>
<input
type="text"
value={jellyfinConfig.url}
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, url: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="http://192.168.1.102:8096"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">API Key</label>
<input
type="password"
value={jellyfinConfig.apiKey || ''}
onChange={(e) => setJellyfinConfig({ ...jellyfinConfig, apiKey: e.target.value })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="Enter API key"
/>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Import Options</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMovies}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Movies</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importSeries}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Series</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMusic}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Music</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importCast}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Cast</span>
</label>
</div>
</div>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Limit (optional, for testing)</label>
<input
type="number"
value={jellyfinOptions.limit || ''}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })}
disabled={progress.stage !== 'idle'}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed"
placeholder="e.g. 10"
/>
</div>
<Button
onClick={handleJellyfinImport}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Download size={16} className="mr-2" />
Import from Jellyfin
</>
)}
</Button>
</div>
</div>
)}
{/* Jellyfin Cleanup Card */}
{jellyfinConfig.url && (
<div className="bg-card border border-border rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<RefreshCw className="text-red-600" size={24} />
</div>
<div>
<h3 className="font-bold text-foreground">Jellyfin Cleanup</h3>
<p className="text-xs text-muted-foreground font-medium">Remove deleted media</p>
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-border"
onClick={() => {}}
>
<Settings size={16} />
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Remove Jellyfin media and cast that no longer exist in your Jellyfin server.
</p>
<div className="space-y-3">
<div>
<label className="text-xs font-bold text-muted-foreground mb-2 block">Cleanup Options</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMovies}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMovies: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Movies</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importSeries}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importSeries: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Series</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importMusic}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importMusic: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Music</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jellyfinOptions.importCast}
onChange={(e) => setJellyfinOptions({ ...jellyfinOptions, importCast: e.target.checked })}
disabled={progress.stage !== 'idle'}
className="rounded border-border"
/>
<span className="text-muted-foreground">Cast</span>
</label>
</div>
</div>
<Button
onClick={handleJellyfinCleanup}
disabled={progress.stage !== 'idle' || !jellyfinConfig.url || !jellyfinConfig.apiKey}
className="w-full bg-red-600 hover:bg-red-700 text-white font-bold"
>
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
Cleaning up...
</>
) : (
<>
<RefreshCw size={16} className="mr-2" />
Cleanup Jellyfin Media
</>
)}
</Button>
</div>
</div>
)}
</div> </div>
{/* Progress Section */} {/* Progress Section */}
@@ -508,16 +785,26 @@ export default function ImporterView() {
<div className="bg-muted rounded-lg p-4"> <div className="bg-muted rounded-lg p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Film size={16} className="text-muted-foreground" /> <Film size={16} className="text-muted-foreground" />
<span className="text-xs font-bold text-muted-foreground">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span> <span className="text-xs font-bold text-muted-foreground">
{(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'}
</span>
</div> </div>
<p className="text-2xl font-black text-foreground">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}</p> <p className="text-2xl font-black text-foreground">
{(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}
</p>
</div> </div>
<div className="bg-muted rounded-lg p-4"> <div className="bg-muted rounded-lg p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-muted-foreground" /> <Users size={16} className="text-muted-foreground" />
<span className="text-xs font-bold text-muted-foreground">Actors</span> <span className="text-xs font-bold text-muted-foreground">{(progress as any).castImported !== undefined ? 'Cast' : 'Actors'}</span>
</div> </div>
<p className="text-2xl font-black text-foreground">{progress.actorsImported}</p> <p className="text-2xl font-black text-foreground">{(progress as any).castImported !== undefined ? (progress as any).castImported : progress.actorsImported}</p>
</div> </div>
<div className="bg-muted rounded-lg p-4"> <div className="bg-muted rounded-lg p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">

1005
src/lib/jellyfinImporter.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -28,12 +28,14 @@ export interface Media {
} }
export interface Episode { export interface Episode {
id: string; id: number;
number: number; media_id: number;
season: number;
episode_number: number;
title: string; title: string;
date: string;
duration: string;
description: string; description: string;
air_date: string;
duration: number;
thumbnail: string; thumbnail: string;
} }