Refactor detail tabs; add series & Playnite options

Split DetailView into focused tab components (Overview, Cast, Seasons, Tracks, Series) and moved related UI/logic into src/components/details/tabs/*. DetailView now composes these tabs and accepts allMedia for series lookups; MediaDetailRoute forwards allMedia.

Support for series was added across the stack: API types and converters now include series, Media type gained series and cleanname fields, and BrowseView now lists/filters by series (label updated to 'Series' and dropdown default changed to '--- Alle ---').

Playnite importer: introduced PlayniteImportOptions (limit, nameFilter), added UI inputs to ImporterView, increased existing media fetch limit, added name filtering, import limiting, deduplication and improved cleanname-based matching/logging. Adjusted progress/total handling to account for deduped items.
This commit is contained in:
Lars Behrends
2026-04-25 23:54:18 +02:00
parent 34bb4a27be
commit 9a72ba3064
13 changed files with 442 additions and 242 deletions
+4 -4
View File
@@ -58,7 +58,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]); const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]); const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]); const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]); const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.series || []))), [mediaList]);
const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]); const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]);
const filteredMedia = useMemo(() => { const filteredMedia = useMemo(() => {
@@ -67,7 +67,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false; if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false; if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false; if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false; if (selectedCategory && !media.series?.includes(selectedCategory)) return false;
if (selectedSource && media.source !== selectedSource) return false; if (selectedSource && media.source !== selectedSource) return false;
return true; return true;
}); });
@@ -201,11 +201,11 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}> <button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all duration-300 outline-none select-none focus-visible:ring-2 focus-visible:ring-[#6d28d9]/50 hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground h-9 gap-2 px-4 font-bold backdrop-blur-sm", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10 border-[#6d28d9]/20" : "text-muted-foreground border-border/50")}>
<FolderTree size={16} /> <FolderTree size={16} />
{selectedCategory || 'Categories'} {selectedCategory || 'Series'}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto"> <DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem> <DropdownMenuItem onClick={() => setSelectedCategory(null)}>--- Alle ---</DropdownMenuItem>
{allCategories.sort().map(category => ( {allCategories.sort().map(category => (
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem> <DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
))} ))}
+24 -224
View File
@@ -1,41 +1,29 @@
import { Media, Staff, Track } from '@/types'; import { Media, Staff } from '@/types';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useState, useMemo, useEffect } from 'react'; import { useState } from 'react';
import { import { ChevronLeft, Calendar, Clock } from 'lucide-react';
Play,
Bookmark,
MoreHorizontal,
Star,
ChevronLeft,
ChevronRight,
Search,
ListFilter,
ChevronDown,
Calendar,
Clock,
Eye
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import OverviewTab from './details/tabs/OverviewTab';
import CastTab from './details/tabs/CastTab';
import SeasonsTab from './details/tabs/SeasonsTab';
import TracksTab from './details/tabs/TracksTab';
import SeriesTab from './details/tabs/SeriesTab';
interface DetailViewProps { interface DetailViewProps {
media: Media; media: Media;
allMedia: Media[];
onPersonClick: (person: Staff) => void; onPersonClick: (person: Staff) => void;
} }
export default function DetailView({ media, onPersonClick }: DetailViewProps) { export default function DetailView({ media, allMedia, onPersonClick }: DetailViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false);
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
const [progress, setProgress] = useState(70.8); const [progress, setProgress] = useState(70.8);
const hasEpisodes = media.episodes && media.episodes.length > 0; const hasEpisodes = media.episodes && media.episodes.length > 0;
const hasTracks = media.tracks && media.tracks.length > 0; const hasTracks = media.tracks && media.tracks.length > 0;
const hasCast = media.staff && media.staff.length > 0; const hasCast = media.staff && media.staff.length > 0;
const hasFranchise = media.category === 'Games' && media.series && media.series.length > 0;
const tabs = [ const tabs = [
'Overview', 'Overview',
...(hasCast ? ['Cast'] : []), ...(hasCast ? ['Cast'] : []),
@@ -43,6 +31,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
'History', 'History',
...(hasEpisodes ? ['Seasons'] : []), ...(hasEpisodes ? ['Seasons'] : []),
...(hasTracks ? ['Tracks'] : []), ...(hasTracks ? ['Tracks'] : []),
...(hasFranchise ? ['Series'] : []),
'Reviews', 'Reviews',
'Suggestions', 'Suggestions',
'Watch On' 'Watch On'
@@ -50,46 +39,6 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
const [activeTab, setActiveTab] = useState(tabs[0]); const [activeTab, setActiveTab] = useState(tabs[0]);
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!media.episodes) return {};
const grouped: Record<number, typeof media.episodes> = {};
media.episodes.forEach(episode => {
if (!grouped[episode.season]) {
grouped[episode.season] = [];
}
grouped[episode.season].push(episode);
});
// Sort episodes within each season by episode number
Object.keys(grouped).forEach(season => {
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
});
return grouped;
}, [media.episodes]);
// Expand first season by default on mount
useEffect(() => {
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
if (seasons.length > 0) {
setExpandedSeasons(new Set([seasons[0]]));
}
}, [episodesBySeason]);
const toggleSeason = (season: number) => {
setExpandedSeasons(prev => {
const newSet = new Set(prev);
if (newSet.has(season)) {
newSet.delete(season);
} else {
newSet.add(season);
}
return newSet;
});
};
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 */}
@@ -201,175 +150,26 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) {
))} ))}
</div> </div>
{/* Genre Tags */} {/* Overview Tab */}
{activeTab === 'Overview' && ( {activeTab === 'Overview' && <OverviewTab media={media} />}
<div className="flex flex-wrap gap-2 mb-6">
{media.genres?.map(genre => (
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
{genre}
</Badge>
))}
</div>
)}
{/* Description */} {/* Cast Tab */}
{activeTab === 'Overview' && (
<div
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
)}
{/* Acting Section - Horizontal Scrollable */}
{media.staff && media.staff.length > 0 && activeTab === 'Cast' && ( {media.staff && media.staff.length > 0 && activeTab === 'Cast' && (
<section className="mt-12"> <CastTab staff={media.staff} onPersonClick={onPersonClick} />
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{displayedCast.map(person => (
<div
key={person.id}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
</div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
))}
{hasMoreCast && (
<button
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${media.staff!.length - castLimit} more`}
</span>
</button>
)} )}
</div> {/* Seasons Tab */}
</section>
)}
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && ( {media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
<section className="mt-20"> <SeasonsTab episodes={media.episodes} />
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
<span className="opacity-40">{media.episodes.length}</span> Episode{media.episodes.length !== 1 ? 's' : ''}
</div>
<div className="text-sm font-bold text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-4">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={() => toggleSeason(season)}
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
>
<div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<ChevronDown
size={24}
className={`transition-transform duration-300 text-muted-foreground ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedSeasons.has(season) && (
<div className="p-6 pt-0 space-y-6">
{episodesBySeason[season].map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-2xl overflow-hidden shadow-sm relative border border-border/30">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div>
<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 duration-300">
E{episode.episode_number} {episode.title}
</h3>
<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}
</p>
</div>
</div>
<Separator className="mt-6 bg-border/50" />
</div>
))}
</div>
)}
</div>
))}
</div>
</section>
)} )}
{/* Tracks Section - Only show if tracks data exists and Tracks tab is active */} {/* Tracks Tab */}
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && ( {media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
<section className="mt-20"> <TracksTab tracks={media.tracks} />
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> )}
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
<span className="opacity-40">{media.tracks.length}</span> Track{media.tracks.length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-2"> {/* Series Tab */}
{media.tracks.map(track => ( {media.category === 'Games' && media.series && media.series.length > 0 && activeTab === 'Series' && (
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30"> <SeriesTab media={media} allMedia={allMedia} onMediaClick={(media) => window.location.href = `/${media.id}`} />
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
<div className="flex-1">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
{track.title}
</h3>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
</div>
))}
</div>
</section>
)} )}
</div> </div>
</div> </div>
+28 -1
View File
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; 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, PlayniteImportOptions } from '@/lib/playniteImporter';
import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter'; import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter';
import { fetchSettings, updateSettings } from '@/api'; import { fetchSettings, updateSettings } from '@/api';
@@ -25,6 +25,10 @@ export default function ImporterView() {
port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined, port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined,
updateExisting: true updateExisting: true
}); });
const [playniteOptions, setPlayniteOptions] = useState<PlayniteImportOptions>({
limit: undefined,
nameFilter: undefined
});
const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({ const [jellyfinConfig, setJellyfinConfig] = useState<JellyfinConfig>({
url: import.meta.env.VITE_JELLYFIN_URL || '', url: import.meta.env.VITE_JELLYFIN_URL || '',
apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || '' apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || ''
@@ -199,6 +203,7 @@ export default function ImporterView() {
const result = await importFromPlaynite( const result = await importFromPlaynite(
playniteConfig, playniteConfig,
playniteOptions,
addLog, addLog,
(progressUpdate) => { (progressUpdate) => {
setProgress(prev => ({ ...prev, ...progressUpdate })); setProgress(prev => ({ ...prev, ...progressUpdate }));
@@ -639,6 +644,28 @@ export default function ImporterView() {
/> />
<label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</label> <label htmlFor="playnite-update-existing" className="text-xs text-muted-foreground cursor-pointer">Bestehende aktualisieren</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={playniteOptions.limit || ''}
onChange={(e) => setPlayniteOptions({ ...playniteOptions, 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>
<div>
<label className="text-xs font-bold text-muted-foreground mb-1 block">Name Filter (optional, for testing)</label>
<input
type="text"
value={playniteOptions.nameFilter || ''}
onChange={(e) => setPlayniteOptions({ ...playniteOptions, nameFilter: 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. Reside"
/>
</div>
<Button <Button
onClick={handlePlayniteImport} onClick={handlePlayniteImport}
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken} disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
+46
View File
@@ -0,0 +1,46 @@
import { Staff } from '@/types';
import { useState } from 'react';
interface CastTabProps {
staff: Staff[];
onPersonClick: (person: Staff) => void;
}
export default function CastTab({ staff, onPersonClick }: CastTabProps) {
const [castLimit, setCastLimit] = useState(6);
const [showAllCast, setShowAllCast] = useState(false);
const displayedCast = showAllCast ? staff : (staff?.slice(0, castLimit) || []);
const hasMoreCast = (staff?.length || 0) > castLimit;
return (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Acting</h2>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{displayedCast.map(person => (
<div
key={person.id}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 cursor-pointer group"
onClick={() => onPersonClick(person)}
>
<div className="w-full h-56 rounded-xl overflow-hidden mb-3 border border-border/30">
<img src={person.photo} alt={person.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" referrerPolicy="no-referrer" />
</div>
<h4 className="font-bold text-foreground truncate group-hover:text-[#6d28d9] transition-colors duration-300">{person.name}</h4>
<p className="text-xs text-muted-foreground truncate">{person.characterName || person.role}</p>
</div>
))}
{hasMoreCast && (
<button
onClick={() => setShowAllCast(!showAllCast)}
className="flex-shrink-0 w-48 bg-card p-4 rounded-2xl shadow-sm border border-border/50 hover:shadow-xl hover:border-[#6d28d9]/30 transition-all duration-300 flex items-center justify-center"
>
<span className="font-bold text-[#6d28d9]">
{showAllCast ? 'Show Less' : `+${staff!.length - castLimit} more`}
</span>
</button>
)}
</div>
</section>
);
}
@@ -0,0 +1,27 @@
import { Media } from '@/types';
import { Badge } from '@/components/ui/badge';
interface OverviewTabProps {
media: Media;
}
export default function OverviewTab({ media }: OverviewTabProps) {
return (
<>
{/* Genre Tags */}
<div className="flex flex-wrap gap-2 mb-6">
{media.genres?.map(genre => (
<Badge key={genre} variant="secondary" className="bg-muted/50 text-foreground hover:bg-muted/80 border border-border/50 px-3 py-1 font-bold text-sm">
{genre}
</Badge>
))}
</div>
{/* Description */}
<div
className="text-foreground leading-relaxed mb-8 max-w-4xl prose prose-sm dark:prose-invert"
dangerouslySetInnerHTML={{ __html: media.description || '' }}
/>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { Episode } from '@/types';
import { useState, useMemo, useEffect } from 'react';
import { Search, MoreHorizontal, ListFilter, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
interface SeasonsTabProps {
episodes: Episode[];
}
export default function SeasonsTab({ episodes }: SeasonsTabProps) {
const [expandedSeasons, setExpandedSeasons] = useState<Set<number>>(new Set());
// Group episodes by season
const episodesBySeason = useMemo(() => {
if (!episodes) return {};
const grouped: Record<number, typeof episodes> = {};
episodes.forEach(episode => {
if (!grouped[episode.season]) {
grouped[episode.season] = [];
}
grouped[episode.season].push(episode);
});
// Sort episodes within each season by episode number
Object.keys(grouped).forEach(season => {
grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number);
});
return grouped;
}, [episodes]);
// Expand first season by default on mount
useEffect(() => {
const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b);
if (seasons.length > 0) {
setExpandedSeasons(new Set([seasons[0]]));
}
}, [episodesBySeason]);
const toggleSeason = (season: number) => {
setExpandedSeasons(prev => {
const newSet = new Set(prev);
if (newSet.has(season)) {
newSet.delete(season);
} else {
newSet.add(season);
}
return newSet;
});
};
return (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
<span className="opacity-40">{episodes.length}</span> Episode{episodes.length !== 1 ? 's' : ''}
</div>
<div className="text-sm font-bold text-muted-foreground">
{Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-4">
{Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b)
.map(season => (
<div key={season} className="border border-border/50 rounded-2xl overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={() => toggleSeason(season)}
className="w-full flex items-center justify-between p-6 bg-card/50 hover:bg-muted/50 transition-colors duration-300"
>
<div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-foreground">Season {season}</h3>
<Badge variant="outline" className="border-[#6d28d9]/30 text-[#6d28d9] font-bold">
{episodesBySeason[season].length} Episode{episodesBySeason[season].length !== 1 ? 's' : ''}
</Badge>
</div>
<ChevronDown
size={24}
className={`transition-transform duration-300 text-muted-foreground ${
expandedSeasons.has(season) ? 'rotate-180' : ''
}`}
/>
</button>
{expandedSeasons.has(season) && (
<div className="p-6 pt-0 space-y-6">
{episodesBySeason[season].map(episode => (
<div key={episode.id} className="group cursor-pointer">
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:w-[240px] shrink-0 aspect-video rounded-2xl overflow-hidden shadow-sm relative border border-border/30">
<img src={episode.thumbnail} alt={episode.title} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" referrerPolicy="no-referrer" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors duration-300" />
</div>
<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 duration-300">
E{episode.episode_number} {episode.title}
</h3>
<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}
</p>
</div>
</div>
<Separator className="mt-6 bg-border/50" />
</div>
))}
</div>
)}
</div>
))}
</div>
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Media } from '@/types';
import MediaCard from '../../MediaCard';
interface SeriesTabProps {
media: Media;
allMedia: Media[];
onMediaClick: (media: Media) => void;
}
export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabProps) {
// Filter games that share at least one series with the current game
const seriesGames = allMedia.filter(
(m) =>
m.category === 'Games' &&
m.id !== media.id &&
m.series &&
media.series &&
m.series.some((s) => media.series!.includes(s))
);
if (seriesGames.length === 0) {
return (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Series</h2>
<p className="text-muted-foreground">No other games found in the same series.</p>
</section>
);
}
return (
<section className="mt-12">
<h2 className="text-2xl font-black text-foreground mb-6">Series</h2>
<div className="flex flex-wrap gap-2 mb-6">
{media.series?.map((s) => (
<span
key={s}
className="px-3 py-1 bg-[#6d28d9]/10 text-[#6d28d9] border border-[#6d28d9]/30 rounded-full text-sm font-bold"
>
{s}
</span>
))}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{seriesGames.map((game) => (
<MediaCard
key={game.id}
media={game}
onClick={() => onMediaClick(game)}
/>
))}
</div>
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Track } from '@/types';
import { Search, MoreHorizontal, ListFilter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface TracksTabProps {
tracks: Track[];
}
export default function TracksTab({ tracks }: TracksTabProps) {
return (
<section className="mt-20">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#6d28d9] font-black text-2xl">
<span className="opacity-40">{tracks.length}</span> Track{tracks.length !== 1 ? 's' : ''}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<Input placeholder="Search" className="pl-10 w-[200px] bg-muted/50 backdrop-blur-sm border-none rounded-full h-9 text-sm" />
</div>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<MoreHorizontal size={20} />
</Button>
<Button variant="ghost" size="icon" className="text-muted-foreground hover:bg-muted/50 rounded-xl transition-all duration-300">
<ListFilter size={20} />
</Button>
</div>
</div>
<div className="space-y-2">
{tracks.map(track => (
<div key={track.id} className="group cursor-pointer flex items-center gap-4 p-4 rounded-2xl hover:bg-muted/50 transition-colors duration-300 border border-transparent hover:border-border/30">
<span className="text-sm font-bold text-muted-foreground w-8">{track.track_number}</span>
<div className="flex-1">
<h3 className="font-black text-foreground group-hover:text-[#6d28d9] transition-colors duration-300">
{track.title}
</h3>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<span className="text-xs font-bold text-muted-foreground">{track.duration ? `${track.duration}m` : '-'}</span>
</div>
))}
</div>
</section>
);
}
@@ -44,6 +44,7 @@ export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetai
return ( return (
<DetailView <DetailView
media={selectedMedia} media={selectedMedia}
allMedia={allMedia}
onPersonClick={onPersonClick} onPersonClick={onPersonClick}
/> />
); );
+1
View File
@@ -147,6 +147,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
staff: staff.length > 0 ? staff : undefined, staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
categories: apiItem.categories, categories: apiItem.categories,
series: apiItem.series,
platforms: apiItem.platforms, platforms: apiItem.platforms,
developers: apiItem.developers, developers: apiItem.developers,
completionStatus: apiItem.completionStatus, completionStatus: apiItem.completionStatus,
+1
View File
@@ -58,6 +58,7 @@ export interface ApiMediaItem {
studios?: string[]; studios?: string[];
staff?: ApiStaff[]; staff?: ApiStaff[];
categories?: string[]; categories?: string[];
series?: string[];
platforms?: string[]; platforms?: string[];
developers?: string[]; developers?: string[];
completionStatus?: string; completionStatus?: string;
+69 -9
View File
@@ -27,6 +27,16 @@ export interface PlayniteConfig {
updateExisting?: boolean; updateExisting?: boolean;
} }
/**
* Options for controlling the Playnite import process
*/
export interface PlayniteImportOptions {
/** Maximum number of items to import (optional) */
limit?: number;
/** Filter items by name (case-insensitive, optional - for debugging) */
nameFilter?: string;
}
/** /**
* Progress tracking for the import operation * Progress tracking for the import operation
*/ */
@@ -226,6 +236,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
* 5. Imports or updates each game in the Omnyx database * 5. Imports or updates each game in the Omnyx database
* *
* @param config - Configuration for connecting to Playnite * @param config - Configuration for connecting to Playnite
* @param options - Import options to control behavior
* @param logCallback - Callback function for logging progress messages * @param logCallback - Callback function for logging progress messages
* @param progressCallback - Callback function for updating import progress * @param progressCallback - Callback function for updating import progress
* @returns Promise resolving to the final import progress state * @returns Promise resolving to the final import progress state
@@ -234,6 +245,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
* ```typescript * ```typescript
* const progress = await importFromPlaynite( * const progress = await importFromPlaynite(
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 }, * { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
* { limit: 10, nameFilter: 'Reside' },
* (msg) => console.log(msg), * (msg) => console.log(msg),
* (prog) => updateUI(prog) * (prog) => updateUI(prog)
* ); * );
@@ -242,6 +254,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
*/ */
export async function importFromPlaynite( export async function importFromPlaynite(
config: PlayniteConfig, config: PlayniteConfig,
options: PlayniteImportOptions,
logCallback: LogCallback, logCallback: LogCallback,
progressCallback: ProgressCallback progressCallback: ProgressCallback
): Promise<ImportProgress> { ): Promise<ImportProgress> {
@@ -254,6 +267,8 @@ export async function importFromPlaynite(
errors: [] errors: []
}; };
const { limit, nameFilter } = options;
const baseUrl = `http://${config.ip}:${config.port || 19821}`; const baseUrl = `http://${config.ip}:${config.port || 19821}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -265,10 +280,13 @@ export async function importFromPlaynite(
// Step 0: Fetch existing media to check for duplicates and enable updates // Step 0: Fetch existing media to check for duplicates and enable updates
logCallback('Fetching existing media from Omnyx API...'); logCallback('Fetching existing media from Omnyx API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`); const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
const existingMediaData = await existingMediaResponse.json(); const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map( const existingMedia = new Map(
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m]) (existingMediaData.data?.items || []).map((m: Media) => [
m.cleanname || m.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-'),
m
])
); );
logCallback(`Found ${existingMedia.size} existing games in database`); logCallback(`Found ${existingMedia.size} existing games in database`);
@@ -276,7 +294,7 @@ export async function importFromPlaynite(
logCallback(`Fetching games from ${baseUrl}/api/games...`); logCallback(`Fetching games from ${baseUrl}/api/games...`);
progressCallback({ message: 'Fetching games from Playnite...' }); progressCallback({ message: 'Fetching games from Playnite...' });
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, { const gamesResponse = await fetch(`${baseUrl}/api/games?limit=${limit || 5000}`, {
method: 'GET', method: 'GET',
headers headers
}); });
@@ -286,22 +304,49 @@ export async function importFromPlaynite(
} }
const gamesData: PlayniteGamesResponse = await gamesResponse.json(); const gamesData: PlayniteGamesResponse = await gamesResponse.json();
const games = gamesData.games || []; let games = gamesData.games || [];
// Apply name filter if provided (case-insensitive)
if (nameFilter) {
const filterLower = nameFilter.toLowerCase();
games = games.filter(game => game.name?.toLowerCase().includes(filterLower));
logCallback(`Filtered to ${games.length} games matching "${nameFilter}"`);
}
// Apply limit if provided (after name filter)
if (limit && games.length > limit) {
games = games.slice(0, limit);
logCallback(`Limited to ${games.length} games`);
}
logCallback(`Found ${games.length} games in Playnite`); logCallback(`Found ${games.length} games in Playnite`);
// Deduplicate games by name (case-insensitive, trimmed)
const uniqueGamesMap = new Map<string, PlayniteGame>();
for (const game of games) {
const normalizedName = game.name.toLowerCase().trim();
if (!uniqueGamesMap.has(normalizedName)) {
uniqueGamesMap.set(normalizedName, game);
}
}
const uniqueGames = Array.from(uniqueGamesMap.values());
if (uniqueGames.length !== games.length) {
logCallback(`Deduplicated: ${games.length}${uniqueGames.length} unique games`);
}
// Step 2: Fetch detailed information for each game // Step 2: Fetch detailed information for each game
progressCallback({ progressCallback({
total: games.length, total: uniqueGames.length,
current: 0, current: 0,
stage: 'fetching', stage: 'fetching',
message: 'Fetching game details...' message: 'Fetching game details...'
}); });
const detailedGames: PlayniteGame[] = []; const detailedGames: PlayniteGame[] = [];
for (let i = 0; i < games.length; i++) { for (let i = 0; i < uniqueGames.length; i++) {
const game = games[i]; const game = uniqueGames[i];
try { try {
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`); logCallback(`Fetching details for: ${game.name} (${i + 1}/${uniqueGames.length})`);
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, { const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
method: 'GET', method: 'GET',
@@ -355,9 +400,24 @@ export async function importFromPlaynite(
for (let i = 0; i < detailedGames.length; i++) { for (let i = 0; i < detailedGames.length; i++) {
const game = detailedGames[i]; const game = detailedGames[i];
const existingGame = existingMedia.get(game.name); const cleanName = game.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
const existingGame = existingMedia.get(cleanName);
const isUpdate = existingGame !== undefined; const isUpdate = existingGame !== undefined;
if (!isUpdate) {
// Debug: show similar titles from database for games not found
const similarTitles = Array.from(existingMedia.keys()).filter((key): key is string =>
typeof key === 'string' && (key.includes(cleanName.substring(0, 10)) || cleanName.includes(key.substring(0, 10)))
).slice(0, 5);
if (similarTitles.length > 0) {
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND. Similar titles in DB: ${similarTitles.join(', ')}`);
} else {
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND (will import)`);
}
} else {
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - FOUND (will update)`);
}
// Skip if updateExisting is false and item already exists // Skip if updateExisting is false and item already exists
if (!config.updateExisting && isUpdate) { if (!config.updateExisting && isUpdate) {
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`); logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
+2
View File
@@ -3,6 +3,7 @@ export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books'
export interface Media { export interface Media {
id: string; id: string;
title: string; title: string;
cleanname?: string;
year: string; year: string;
poster: string; poster: string;
category: MediaCategory; category: MediaCategory;
@@ -19,6 +20,7 @@ export interface Media {
tracks?: Track[]; tracks?: Track[];
staff?: Staff[]; staff?: Staff[];
categories?: string[]; categories?: string[];
series?: string[];
platforms?: string[]; platforms?: string[];
developers?: string[]; developers?: string[];
completionStatus?: string; completionStatus?: string;