const BASE_URL = import.meta.env.VITE_API_URL; // Import the source mapping and types import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; export interface PlayniteConfig { ip: string; apiToken: string; port?: number; updateExisting?: boolean; } 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; coverBase64?: string; backgroundBase64?: string; iconBase64?: string; } export interface PlayniteGamesResponse { total: number; offset: number; limit: number; games: PlayniteGame[]; } export type LogCallback = (message: string) => void; export type ProgressCallback = (progress: Partial) => void; /* async function fetchGameCover(baseUrl: string, headers: Record, gameId: string): Promise { try { const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, { method: 'GET', headers }); if (!coverResponse.ok) { return null; } const blob = await coverResponse.blob(); const arrayBuffer = await blob.arrayBuffer(); const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); // Determine MIME type from blob const mimeType = blob.type || 'image/jpeg'; return `data:${mimeType};base64,${base64}`; } catch (error) { return null; } } async function fetchGameBackground(baseUrl: string, headers: Record, gameId: string): Promise { try { const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, { method: 'GET', headers }); if (!backgroundResponse.ok) { return null; } const blob = await backgroundResponse.blob(); const arrayBuffer = await blob.arrayBuffer(); const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); const mimeType = blob.type || 'image/jpeg'; return `data:${mimeType};base64,${base64}`; } catch (error) { return null; } } async function fetchGameIcon(baseUrl: string, headers: Record, gameId: string): Promise { try { const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, { method: 'GET', headers }); if (!iconResponse.ok) { return null; } const blob = await iconResponse.blob(); const arrayBuffer = await blob.arrayBuffer(); const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); const mimeType = blob.type || 'image/png'; return `data:${mimeType};base64,${base64}`; } catch (error) { return null; } } */ 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(`${BASE_URL}/api/media?limit=1000`); const existingMediaData = await existingMediaResponse.json(); const existingMedia = new Map( (existingMediaData.data?.items || []).map((m: Media) => [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(); /* // Fetch images const [cover, background, icon] = await Promise.all([ fetchGameCover(baseUrl, headers, game.id), fetchGameBackground(baseUrl, headers, game.id), fetchGameIcon(baseUrl, headers, game.id) ]); detailData.coverBase64 = cover; detailData.backgroundBase64 = background; detailData.iconBase64 = icon; */ 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; // Skip if updateExisting is false and item already exists if (!config.updateExisting && isUpdate) { logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`); progressCallback({ current: i + 1 }); continue; } 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: Staff[] = []; // 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: SOURCE_CATEGORY_MAPPING['playnite']?.includes('Games') ? (game.source || 'playnite') : 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: game.coverBase64 || null, banner: game.backgroundBase64 || null, icon: game.iconBase64 || 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(`${BASE_URL}/api/media/${(existingGame as any).id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mediaData) }); } else { response = await fetch(`${BASE_URL}/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; } }