playnite init
This commit is contained in:
18
src/api.ts
18
src/api.ts
@@ -49,6 +49,14 @@ export interface ApiMediaItem {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
studios?: string[];
|
studios?: string[];
|
||||||
staff?: ApiStaff[];
|
staff?: ApiStaff[];
|
||||||
|
categories?: string[];
|
||||||
|
platforms?: string[];
|
||||||
|
developers?: string[];
|
||||||
|
completionStatus?: string;
|
||||||
|
source?: string;
|
||||||
|
playCount?: number;
|
||||||
|
lastActivity?: string | null;
|
||||||
|
playtime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiStaff {
|
export interface ApiStaff {
|
||||||
@@ -238,7 +246,15 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
status: mediaStatus,
|
status: mediaStatus,
|
||||||
staff: staff.length > 0 ? staff : undefined,
|
staff: staff.length > 0 ? staff : undefined,
|
||||||
aspectRatio: aspectRatio
|
aspectRatio: aspectRatio,
|
||||||
|
categories: apiItem.categories,
|
||||||
|
platforms: apiItem.platforms,
|
||||||
|
developers: apiItem.developers,
|
||||||
|
completionStatus: apiItem.completionStatus,
|
||||||
|
source: apiItem.source,
|
||||||
|
playCount: apiItem.playCount,
|
||||||
|
lastActivity: apiItem.lastActivity,
|
||||||
|
playtime: apiItem.playtime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Media, MediaCategory } from '@/types';
|
import { Media, MediaCategory } from '@/types';
|
||||||
import MediaCard from './MediaCard';
|
import MediaCard from './MediaCard';
|
||||||
import MediaListItem from './MediaListItem';
|
import MediaListItem from './MediaListItem';
|
||||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search } from 'lucide-react';
|
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -28,18 +28,27 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
|||||||
// Filter states
|
// Filter states
|
||||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||||
|
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||||
|
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
// Extract unique values for filters
|
// Extract unique values for filters
|
||||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||||
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 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 filteredMedia = useMemo(() => {
|
const filteredMedia = useMemo(() => {
|
||||||
return mediaList.filter(media => {
|
return mediaList.filter(media => {
|
||||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||||
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 (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||||
|
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [mediaList, selectedGenre, selectedStudio]);
|
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
|
||||||
|
|
||||||
// Reset to first page when mediaList or filters change
|
// Reset to first page when mediaList or filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,7 +119,61 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{(selectedGenre || selectedStudio) && (
|
{/* Platform Filter - Only for Games */}
|
||||||
|
{activeCategory === 'Games' && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||||
|
<Monitor size={16} />
|
||||||
|
{selectedPlatform || 'Platforms'}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||||
|
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
|
||||||
|
{allPlatforms.sort().map(platform => (
|
||||||
|
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Developer Filter - Only for Games */}
|
||||||
|
{activeCategory === 'Games' && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||||
|
<Users size={16} />
|
||||||
|
{selectedDeveloper || 'Developers'}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||||
|
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
|
||||||
|
{allDevelopers.sort().map(developer => (
|
||||||
|
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Filter - Only for Games */}
|
||||||
|
{activeCategory === 'Games' && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||||
|
<FolderTree size={16} />
|
||||||
|
{selectedCategory || 'Categories'}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||||
|
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
|
||||||
|
{allCategories.sort().map(category => (
|
||||||
|
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -118,6 +181,9 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedGenre(null);
|
setSelectedGenre(null);
|
||||||
setSelectedStudio(null);
|
setSelectedStudio(null);
|
||||||
|
setSelectedPlatform(null);
|
||||||
|
setSelectedDeveloper(null);
|
||||||
|
setSelectedCategory(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
|
|||||||
@@ -116,10 +116,72 @@ export default function DetailView({ media, onBack, onPersonClick }: DetailViewP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs font-bold text-zinc-500">
|
{media.studios && media.studios.length > 0 && (
|
||||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
|
<p className="text-xs font-bold text-zinc-500">
|
||||||
{media.studios?.join(', ')}
|
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
|
||||||
</p>
|
{media.studios.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{media.developers && media.developers.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Developers:</span>
|
||||||
|
{media.developers.map(dev => (
|
||||||
|
<Badge key={dev} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||||
|
{dev}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.platforms && media.platforms.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Platforms:</span>
|
||||||
|
{media.platforms.map(platform => (
|
||||||
|
<Badge key={platform} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||||
|
{platform}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.categories && media.categories.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Categories:</span>
|
||||||
|
{media.categories.map(category => (
|
||||||
|
<Badge key={category} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{media.completionStatus && (
|
||||||
|
<p className="text-xs font-bold text-zinc-500">
|
||||||
|
<span className="text-zinc-400 uppercase tracking-widest mr-2">Completion:</span>
|
||||||
|
{media.completionStatus}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{media.source && (
|
||||||
|
<p className="text-xs font-bold text-zinc-500">
|
||||||
|
<span className="text-zinc-400 uppercase tracking-widest mr-2">Source:</span>
|
||||||
|
{media.source}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{media.playCount !== undefined && media.playCount !== null && (
|
||||||
|
<p className="text-xs font-bold text-zinc-500">
|
||||||
|
<span className="text-zinc-400 uppercase tracking-widest mr-2">Play Count:</span>
|
||||||
|
{media.playCount}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||||
|
<p className="text-xs font-bold text-zinc-500">
|
||||||
|
<span className="text-zinc-400 uppercase tracking-widest mr-2">Playtime:</span>
|
||||||
|
{media.playtime}h
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{media.lastActivity && (
|
||||||
|
<p className="text-xs font-bold text-zinc-500">
|
||||||
|
<span className="text-zinc-400 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||||
|
{media.lastActivity}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
|
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
|
||||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ 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';
|
||||||
|
|
||||||
export default function ImporterView({ onBack }: { onBack: () => void }) {
|
export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||||
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: 'http://192.168.1.102:4080' });
|
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: 'http://192.168.1.102:4080' });
|
||||||
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({ url: 'http://192.168.1.102:10001' });
|
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({ url: 'http://192.168.1.102:10001' });
|
||||||
|
const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({ ip: 'localhost', apiToken: '', port: 19821 });
|
||||||
const [progress, setProgress] = useState<ImportProgress>({
|
const [progress, setProgress] = useState<ImportProgress>({
|
||||||
current: 0,
|
current: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -101,6 +103,29 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
|
|||||||
setProgress(result);
|
setProgress(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePlayniteImport = async () => {
|
||||||
|
setProgress({
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
stage: 'fetching',
|
||||||
|
message: 'Connecting to Playnite API...',
|
||||||
|
videosImported: 0,
|
||||||
|
actorsImported: 0,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
setImportLog([]);
|
||||||
|
|
||||||
|
const result = await importFromPlaynite(
|
||||||
|
playniteConfig,
|
||||||
|
addLog,
|
||||||
|
(progressUpdate) => {
|
||||||
|
setProgress(prev => ({ ...prev, ...progressUpdate }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress(result);
|
||||||
|
};
|
||||||
|
|
||||||
const resetImport = () => {
|
const resetImport = () => {
|
||||||
setProgress({
|
setProgress({
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -320,10 +345,82 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder for future importers */}
|
{/* Playnite Importer Card */}
|
||||||
<div className="bg-zinc-50 border border-zinc-200 border-dashed rounded-xl p-6 flex flex-col items-center justify-center text-zinc-400">
|
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||||
<Download size={32} className="mb-2" />
|
<div className="flex items-start justify-between mb-4">
|
||||||
<p className="text-sm font-medium">More importers coming soon</p>
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Film className="text-orange-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-zinc-900">Playnite</h3>
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">Game Library Manager</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 border-zinc-200"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-600 mb-4">
|
||||||
|
Import games from your Playnite library via Bridge API.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">IP Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playniteConfig.ip}
|
||||||
|
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: e.target.value })}
|
||||||
|
disabled={progress.stage !== 'idle'}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||||
|
placeholder="localhost"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">Port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={playniteConfig.port || 19821}
|
||||||
|
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
|
||||||
|
disabled={progress.stage !== 'idle'}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||||
|
placeholder="19821"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={playniteConfig.apiToken}
|
||||||
|
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: e.target.value })}
|
||||||
|
disabled={progress.stage !== 'idle'}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed"
|
||||||
|
placeholder="pb_your_token_here"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handlePlayniteImport}
|
||||||
|
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
||||||
|
className="w-full bg-orange-600 hover:bg-orange-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Import from Playnite
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -392,9 +489,9 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
|
|||||||
<div className="bg-zinc-50 rounded-lg p-4">
|
<div className="bg-zinc-50 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Film size={16} className="text-zinc-400" />
|
<Film size={16} className="text-zinc-400" />
|
||||||
<span className="text-xs font-bold text-zinc-500">Videos</span>
|
<span className="text-xs font-bold text-zinc-500">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-black text-zinc-900">{progress.videosImported}</p>
|
<p className="text-2xl font-black text-zinc-900">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-50 rounded-lg p-4">
|
<div className="bg-zinc-50 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
|||||||
314
src/lib/playniteImporter.ts
Normal file
314
src/lib/playniteImporter.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
export interface PlayniteConfig {
|
||||||
|
ip: string;
|
||||||
|
apiToken: string;
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportProgress {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||||
|
message: string;
|
||||||
|
gamesImported: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayniteGame {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sortingName?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
version?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
favorite?: boolean;
|
||||||
|
userScore?: number;
|
||||||
|
communityScore?: number;
|
||||||
|
criticScore?: number;
|
||||||
|
releaseDate?: string;
|
||||||
|
completionStatus?: string;
|
||||||
|
categories?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
features?: string[];
|
||||||
|
genres?: string[];
|
||||||
|
developers?: string[];
|
||||||
|
publishers?: string[];
|
||||||
|
series?: string[];
|
||||||
|
platforms?: string[];
|
||||||
|
ageRatings?: string[];
|
||||||
|
regions?: string[];
|
||||||
|
links?: Array<{
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
playtime?: number;
|
||||||
|
playCount?: number;
|
||||||
|
lastActivity?: string;
|
||||||
|
added?: string;
|
||||||
|
lastPlayed?: string;
|
||||||
|
source?: string;
|
||||||
|
isInstalled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayniteGamesResponse {
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
games: PlayniteGame[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogCallback = (message: string) => void;
|
||||||
|
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||||
|
|
||||||
|
export async function importFromPlaynite(
|
||||||
|
config: PlayniteConfig,
|
||||||
|
logCallback: LogCallback,
|
||||||
|
progressCallback: ProgressCallback
|
||||||
|
): Promise<ImportProgress> {
|
||||||
|
const progress: ImportProgress = {
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
stage: 'fetching',
|
||||||
|
message: 'Connecting to Playnite API...',
|
||||||
|
gamesImported: 0,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${config.apiToken}`
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
logCallback('Starting Playnite import...');
|
||||||
|
|
||||||
|
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||||
|
logCallback('Fetching existing media from Kyoo API...');
|
||||||
|
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media?limit=1000');
|
||||||
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
|
const existingMedia = new Map(
|
||||||
|
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
||||||
|
);
|
||||||
|
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||||
|
|
||||||
|
// Step 1: Fetch games from Playnite
|
||||||
|
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||||
|
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||||
|
|
||||||
|
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gamesResponse.ok) {
|
||||||
|
throw new Error(`Failed to connect to Playnite API: ${gamesResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||||
|
const games = gamesData.games || [];
|
||||||
|
logCallback(`Found ${games.length} games in Playnite`);
|
||||||
|
|
||||||
|
// Step 2: Fetch detailed information for each game
|
||||||
|
progressCallback({
|
||||||
|
total: games.length,
|
||||||
|
current: 0,
|
||||||
|
stage: 'fetching',
|
||||||
|
message: 'Fetching game details...'
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailedGames: PlayniteGame[] = [];
|
||||||
|
for (let i = 0; i < games.length; i++) {
|
||||||
|
const game = games[i];
|
||||||
|
try {
|
||||||
|
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
||||||
|
|
||||||
|
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailResponse.ok) {
|
||||||
|
const detailData: PlayniteGame = await detailResponse.json();
|
||||||
|
detailedGames.push(detailData);
|
||||||
|
logCallback(`✓ Fetched details for: ${game.name}`);
|
||||||
|
} else {
|
||||||
|
// If detail fetch fails, use basic game info
|
||||||
|
detailedGames.push(game);
|
||||||
|
logCallback(`⊘ Using basic info for: ${game.name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If detail fetch fails, use basic game info
|
||||||
|
detailedGames.push(game);
|
||||||
|
logCallback(`⊘ Using basic info for: ${game.name} (detail fetch failed)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback({
|
||||||
|
current: i + 1,
|
||||||
|
message: `Fetching game details... ${Math.round(((i + 1) / games.length) * 100)}%`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Import games
|
||||||
|
progressCallback({
|
||||||
|
total: detailedGames.length,
|
||||||
|
current: 0,
|
||||||
|
stage: 'importing',
|
||||||
|
message: 'Importing games...'
|
||||||
|
});
|
||||||
|
|
||||||
|
let gamesImported = 0;
|
||||||
|
const gameErrors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < detailedGames.length; i++) {
|
||||||
|
const game = detailedGames[i];
|
||||||
|
|
||||||
|
const existingGame = existingMedia.get(game.name);
|
||||||
|
const isUpdate = existingGame !== undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse release date
|
||||||
|
let year = new Date().getFullYear();
|
||||||
|
let releaseDate = null;
|
||||||
|
if (game.releaseDate) {
|
||||||
|
const dateMatch = game.releaseDate.match(/^(\d{4})/);
|
||||||
|
if (dateMatch) {
|
||||||
|
year = parseInt(dateMatch[1]);
|
||||||
|
}
|
||||||
|
releaseDate = game.releaseDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert playtime from seconds to minutes
|
||||||
|
const runtime = game.playtime ? Math.round(game.playtime / 60) : null;
|
||||||
|
|
||||||
|
// Calculate combined rating from all available scores (0-100 to 0-5)
|
||||||
|
let rating = null;
|
||||||
|
const scores = [];
|
||||||
|
if (game.userScore !== undefined && game.userScore !== null) scores.push(game.userScore);
|
||||||
|
if (game.communityScore !== undefined && game.communityScore !== null) scores.push(game.communityScore);
|
||||||
|
if (game.criticScore !== undefined && game.criticScore !== null) scores.push(game.criticScore);
|
||||||
|
if (scores.length > 0) {
|
||||||
|
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||||
|
rating = avgScore / 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff is for actors/performers only - leave empty for games
|
||||||
|
const staff: any[] = [];
|
||||||
|
|
||||||
|
// Determine type based on genres/features
|
||||||
|
let type = 'Game';
|
||||||
|
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||||
|
// type = 'Movie';
|
||||||
|
// }
|
||||||
|
|
||||||
|
const mediaData = {
|
||||||
|
type: 'Game',
|
||||||
|
title: game.name,
|
||||||
|
sortingName: game.sortingName || null,
|
||||||
|
description: game.description || null,
|
||||||
|
notes: game.notes || null,
|
||||||
|
genres: game.genres || [],
|
||||||
|
categories: game.categories || [],
|
||||||
|
tags: game.tags || [],
|
||||||
|
features: game.features || [],
|
||||||
|
platforms: game.platforms || [],
|
||||||
|
developers: game.developers || [],
|
||||||
|
publishers: game.publishers || [],
|
||||||
|
series: game.series ? [game.series] : [],
|
||||||
|
ageRatings: game.ageRatings || [],
|
||||||
|
regions: game.regions || [],
|
||||||
|
source: game.source || null,
|
||||||
|
gameId: game.id,
|
||||||
|
pluginId: null,
|
||||||
|
completionStatus: game.completionStatus || 'Not Played',
|
||||||
|
releaseDate: releaseDate,
|
||||||
|
isInstalled: game.isInstalled || false,
|
||||||
|
installDirectory: null,
|
||||||
|
installSize: null,
|
||||||
|
hidden: game.hidden || false,
|
||||||
|
favorite: game.favorite || false,
|
||||||
|
playtime: game.playtime || 0,
|
||||||
|
playCount: game.playCount || 0,
|
||||||
|
lastActivity: game.lastActivity || null,
|
||||||
|
added: game.added || null,
|
||||||
|
modified: null,
|
||||||
|
communityScore: game.communityScore || null,
|
||||||
|
criticScore: game.criticScore || null,
|
||||||
|
userScore: game.userScore || null,
|
||||||
|
hasIcon: false,
|
||||||
|
hasCover: false,
|
||||||
|
hasBackground: false,
|
||||||
|
version: game.version || null,
|
||||||
|
links: game.links || [],
|
||||||
|
achievements: [],
|
||||||
|
year: year.toString(),
|
||||||
|
poster: null,
|
||||||
|
banner: null,
|
||||||
|
rating: rating,
|
||||||
|
category: 'Game',
|
||||||
|
status: game.completionStatus === 'Completed' ? 'completed' :
|
||||||
|
game.completionStatus === 'Playing' ? 'ongoing' :
|
||||||
|
game.completionStatus === 'Abandoned' ? 'dropped' : 'planned',
|
||||||
|
aspectRatio: '2/3',
|
||||||
|
runtime: runtime,
|
||||||
|
director: null,
|
||||||
|
writer: null
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (isUpdate) {
|
||||||
|
response = await fetch(`http://192.168.1.102:6400/api/media/${(existingGame as any).id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(mediaData)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch('http://192.168.1.102:6400/api/media', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(mediaData)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
gamesImported++;
|
||||||
|
logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} game: ${game.name}`);
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
gameErrors.push(`Failed to ${isUpdate ? 'update' : 'import'} game ${game.name}: ${error}`);
|
||||||
|
logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} game: ${game.name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
gameErrors.push(`Error importing game ${game.name}: ${error}`);
|
||||||
|
logCallback(`✗ Error importing game: ${game.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback({
|
||||||
|
current: i + 1,
|
||||||
|
gamesImported,
|
||||||
|
errors: gameErrors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logCallback(`Imported ${gamesImported}/${games.length} games`);
|
||||||
|
|
||||||
|
// Complete
|
||||||
|
progress.stage = 'complete';
|
||||||
|
progress.message = 'Import complete!';
|
||||||
|
progress.current = games.length;
|
||||||
|
progress.total = games.length;
|
||||||
|
progress.gamesImported = gamesImported;
|
||||||
|
progress.errors = gameErrors;
|
||||||
|
logCallback('Import completed successfully!');
|
||||||
|
|
||||||
|
return progress;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
progress.stage = 'error';
|
||||||
|
progress.message = `Import failed: ${errorMessage}`;
|
||||||
|
progress.errors = [...progress.errors, errorMessage];
|
||||||
|
logCallback(`✗ Import failed: ${errorMessage}`);
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,14 @@ export interface Media {
|
|||||||
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
|
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
|
||||||
episodes?: Episode[];
|
episodes?: Episode[];
|
||||||
staff?: Staff[];
|
staff?: Staff[];
|
||||||
|
categories?: string[];
|
||||||
|
platforms?: string[];
|
||||||
|
developers?: string[];
|
||||||
|
completionStatus?: string;
|
||||||
|
source?: string;
|
||||||
|
playCount?: number;
|
||||||
|
lastActivity?: string | null;
|
||||||
|
playtime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Episode {
|
export interface Episode {
|
||||||
|
|||||||
Reference in New Issue
Block a user