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:
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+28
-228
@@ -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,176 +150,27 @@ 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">
|
{/* Seasons Tab */}
|
||||||
{displayedCast.map(person => (
|
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
|
||||||
<div
|
<SeasonsTab episodes={media.episodes} />
|
||||||
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>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
{/* Episodes Section - Only show if episodes data exists and Seasons tab is active */}
|
|
||||||
{media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && (
|
|
||||||
<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">{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">
|
{/* Tracks Tab */}
|
||||||
{Object.keys(episodesBySeason)
|
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
|
||||||
.map(Number)
|
<TracksTab tracks={media.tracks} />
|
||||||
.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 */}
|
{/* Series Tab */}
|
||||||
{media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && (
|
{media.category === 'Games' && media.series && media.series.length > 0 && activeTab === 'Series' && (
|
||||||
<section className="mt-20">
|
<SeriesTab media={media} allMedia={allMedia} onMediaClick={(media) => window.location.href = `/${media.id}`} />
|
||||||
<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">
|
|
||||||
{media.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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 || '' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user