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:
@@ -389,12 +389,6 @@ 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) {
|
||||
@@ -407,10 +401,9 @@ function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonC
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
loadMedia();
|
||||
}, [id, allMedia]);
|
||||
}, [id]);
|
||||
|
||||
if (!selectedMedia) return null;
|
||||
|
||||
|
||||
16
src/api.ts
16
src/api.ts
@@ -27,6 +27,18 @@ export interface PaginatedResponse<T> {
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Banner */}
|
||||
@@ -199,17 +206,25 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
<section className="mt-20">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-black text-foreground">Cast & Crew</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" className="rounded-full border-border">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="rounded-full border-border">
|
||||
<ChevronRight size={18} />
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-bold text-muted-foreground">
|
||||
{showAllCast ? media.staff.length : displayedCast.length} / {media.staff.length}
|
||||
</span>
|
||||
{hasMoreCast && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{media.staff.map(person => (
|
||||
{displayedCast.map(person => (
|
||||
<div
|
||||
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"
|
||||
@@ -220,7 +235,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 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" />
|
||||
@@ -265,9 +280,9 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
|
||||
<div className="flex-1 py-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{episode.description}
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const query = e.target.value;
|
||||
@@ -42,16 +54,29 @@ export default function Header({
|
||||
<header
|
||||
className={cn(
|
||||
"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">
|
||||
<Link
|
||||
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="w-3 h-3 bg-[#6d28d9] rounded-full" />
|
||||
<div className={cn(
|
||||
"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>
|
||||
kyoo
|
||||
</Link>
|
||||
@@ -62,18 +87,25 @@ export default function Header({
|
||||
onClick={() => onCategoryChange(cat)}
|
||||
className={cn(
|
||||
"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}
|
||||
</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
|
||||
to="/cast"
|
||||
className={({ isActive }) => cn(
|
||||
"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
|
||||
@@ -83,42 +115,71 @@ export default function Header({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"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
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
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} />}
|
||||
</button>
|
||||
<Link
|
||||
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} />
|
||||
</Link>
|
||||
<Link
|
||||
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} />
|
||||
</Link>
|
||||
<Link
|
||||
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} />
|
||||
</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
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
alt="User"
|
||||
|
||||
@@ -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">
|
||||
|
||||
1005
src/lib/jellyfinImporter.ts
Normal file
1005
src/lib/jellyfinImporter.ts
Normal file
File diff suppressed because it is too large
Load Diff
10
src/types.ts
10
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user