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:
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
||||
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||
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';
|
||||
|
||||
@@ -21,6 +22,17 @@ export default function ImporterView() {
|
||||
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
|
||||
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>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
@@ -137,6 +149,54 @@ export default function ImporterView() {
|
||||
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 = () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
@@ -441,6 +501,223 @@ export default function ImporterView() {
|
||||
</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>
|
||||
|
||||
{/* Progress Section */}
|
||||
@@ -508,16 +785,26 @@ export default function ImporterView() {
|
||||
<div className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<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>
|
||||
<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 className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<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>
|
||||
<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 className="bg-muted rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
||||
Reference in New Issue
Block a user