Introduce a centralized Zustand store and refactor app state out of App.tsx into src/store/appStore.ts. Modularize API surface by moving media/cast/settings/converters/types into src/lib/api/* and re-exporting from src/api.ts for backward compatibility. Replace inline route helpers with dedicated route components (MediaDetailRoute, CastDetailRoute, CategoryBrowseRoute) and wire CATEGORY_PATHS/PATH_TO_CATEGORY constants. Update AddMediaView UI (icons, layout) and adjust settings/category handling to use DEFAULT_SETTINGS and the store. Add zustand to package.json/package-lock.json and include a new React SKILL.md. Overall changes improve state management, API organization, and route/component separation for better maintainability and code-splitting.
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
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<ImportProgress>) => void;
|
|
/*
|
|
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
|
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<string, string>, gameId: string): Promise<string | null> {
|
|
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<string, string>, gameId: string): Promise<string | null> {
|
|
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<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(`${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;
|
|
}
|
|
}
|