From 73c578f1ec99f2a492c2eeedebc5eede29b06a63 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Fri, 10 Apr 2026 08:46:56 +0200 Subject: [PATCH] playnite init --- src/api.ts | 18 +- src/components/BrowseView.tsx | 72 +++++++- src/components/DetailView.tsx | 70 ++++++- src/components/ImporterView.tsx | 109 ++++++++++- src/lib/playniteImporter.ts | 314 ++++++++++++++++++++++++++++++++ src/types.ts | 8 + 6 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 src/lib/playniteImporter.ts diff --git a/src/api.ts b/src/api.ts index 5abad1f..d0192d6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -49,6 +49,14 @@ export interface ApiMediaItem { tags?: string[]; studios?: string[]; staff?: ApiStaff[]; + categories?: string[]; + platforms?: string[]; + developers?: string[]; + completionStatus?: string; + source?: string; + playCount?: number; + lastActivity?: string | null; + playtime?: number; } export interface ApiStaff { @@ -238,7 +246,15 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media { type: mediaType, status: mediaStatus, 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 }; } diff --git a/src/components/BrowseView.tsx b/src/components/BrowseView.tsx index 766c027..a835fe3 100644 --- a/src/components/BrowseView.tsx +++ b/src/components/BrowseView.tsx @@ -1,7 +1,7 @@ import { Media, MediaCategory } from '@/types'; import MediaCard from './MediaCard'; 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 React, { useState, useMemo, useEffect } from 'react'; import { @@ -28,18 +28,27 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }: // Filter states const [selectedGenre, setSelectedGenre] = useState(null); const [selectedStudio, setSelectedStudio] = useState(null); + const [selectedPlatform, setSelectedPlatform] = useState(null); + const [selectedDeveloper, setSelectedDeveloper] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); // Extract unique values for filters 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 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(() => { return mediaList.filter(media => { if (selectedGenre && !media.genres?.includes(selectedGenre)) 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; }); - }, [mediaList, selectedGenre, selectedStudio]); + }, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]); // Reset to first page when mediaList or filters change useEffect(() => { @@ -110,7 +119,61 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }: - {(selectedGenre || selectedStudio) && ( + {/* Platform Filter - Only for Games */} + {activeCategory === 'Games' && ( + + + + + + setSelectedPlatform(null)}>All Platforms + {allPlatforms.sort().map(platform => ( + setSelectedPlatform(platform)}>{platform} + ))} + + + )} + + {/* Developer Filter - Only for Games */} + {activeCategory === 'Games' && ( + + + + + + setSelectedDeveloper(null)}>All Developers + {allDevelopers.sort().map(developer => ( + setSelectedDeveloper(developer)}>{developer} + ))} + + + )} + + {/* Category Filter - Only for Games */} + {activeCategory === 'Games' && ( + + + + + + setSelectedCategory(null)}>All Categories + {allCategories.sort().map(category => ( + setSelectedCategory(category)}>{category} + ))} + + + )} + + {(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && ( diff --git a/src/components/ImporterView.tsx b/src/components/ImporterView.tsx index 1522ec1..20f27af 100644 --- a/src/components/ImporterView.tsx +++ b/src/components/ImporterView.tsx @@ -4,10 +4,12 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter'; import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter'; +import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter'; export default function ImporterView({ onBack }: { onBack: () => void }) { const [xbvrConfig, setXbvrConfig] = useState({ url: 'http://192.168.1.102:4080' }); const [stashappConfig, setStashappConfig] = useState({ url: 'http://192.168.1.102:10001' }); + const [playniteConfig, setPlayniteConfig] = useState({ ip: 'localhost', apiToken: '', port: 19821 }); const [progress, setProgress] = useState({ current: 0, total: 0, @@ -101,6 +103,29 @@ export default function ImporterView({ onBack }: { onBack: () => void }) { 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 = () => { setProgress({ current: 0, @@ -320,10 +345,82 @@ export default function ImporterView({ onBack }: { onBack: () => void }) { - {/* Placeholder for future importers */} -
- -

More importers coming soon

+ {/* Playnite Importer Card */} +
+
+
+
+ +
+
+

Playnite

+

Game Library Manager

+
+
+ +
+

+ Import games from your Playnite library via Bridge API. +

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
@@ -392,9 +489,9 @@ export default function ImporterView({ onBack }: { onBack: () => void }) {
- Videos + {(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}
-

{progress.videosImported}

+

{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : progress.videosImported}

diff --git a/src/lib/playniteImporter.ts b/src/lib/playniteImporter.ts new file mode 100644 index 0000000..5bbcab1 --- /dev/null +++ b/src/lib/playniteImporter.ts @@ -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) => void; + +export async function importFromPlaynite( + config: PlayniteConfig, + logCallback: LogCallback, + progressCallback: ProgressCallback +): Promise { + 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 = { + '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; + } +} diff --git a/src/types.ts b/src/types.ts index e643e62..3cf7f51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,14 @@ export interface Media { status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold'; episodes?: Episode[]; staff?: Staff[]; + categories?: string[]; + platforms?: string[]; + developers?: string[]; + completionStatus?: string; + source?: string; + playCount?: number; + lastActivity?: string | null; + playtime?: number; } export interface Episode {