playnite init

This commit is contained in:
Lars Behrends
2026-04-10 08:46:56 +02:00
parent 1caadd12e1
commit 73c578f1ec
6 changed files with 577 additions and 14 deletions

314
src/lib/playniteImporter.ts Normal file
View 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;
}
}