Refactor detail tabs; add series & Playnite options
Split DetailView into focused tab components (Overview, Cast, Seasons, Tracks, Series) and moved related UI/logic into src/components/details/tabs/*. DetailView now composes these tabs and accepts allMedia for series lookups; MediaDetailRoute forwards allMedia. Support for series was added across the stack: API types and converters now include series, Media type gained series and cleanname fields, and BrowseView now lists/filters by series (label updated to 'Series' and dropdown default changed to '--- Alle ---'). Playnite importer: introduced PlayniteImportOptions (limit, nameFilter), added UI inputs to ImporterView, increased existing media fetch limit, added name filtering, import limiting, deduplication and improved cleanname-based matching/logging. Adjusted progress/total handling to account for deduped items.
This commit is contained in:
@@ -147,6 +147,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
categories: apiItem.categories,
|
||||
series: apiItem.series,
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ApiMediaItem {
|
||||
studios?: string[];
|
||||
staff?: ApiStaff[];
|
||||
categories?: string[];
|
||||
series?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
|
||||
@@ -27,6 +27,16 @@ export interface PlayniteConfig {
|
||||
updateExisting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for controlling the Playnite import process
|
||||
*/
|
||||
export interface PlayniteImportOptions {
|
||||
/** Maximum number of items to import (optional) */
|
||||
limit?: number;
|
||||
/** Filter items by name (case-insensitive, optional - for debugging) */
|
||||
nameFilter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress tracking for the import operation
|
||||
*/
|
||||
@@ -226,6 +236,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
||||
* 5. Imports or updates each game in the Omnyx database
|
||||
*
|
||||
* @param config - Configuration for connecting to Playnite
|
||||
* @param options - Import options to control behavior
|
||||
* @param logCallback - Callback function for logging progress messages
|
||||
* @param progressCallback - Callback function for updating import progress
|
||||
* @returns Promise resolving to the final import progress state
|
||||
@@ -234,6 +245,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
||||
* ```typescript
|
||||
* const progress = await importFromPlaynite(
|
||||
* { ip: '192.168.1.100', apiToken: 'your-token', port: 19821 },
|
||||
* { limit: 10, nameFilter: 'Reside' },
|
||||
* (msg) => console.log(msg),
|
||||
* (prog) => updateUI(prog)
|
||||
* );
|
||||
@@ -242,6 +254,7 @@ async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, g
|
||||
*/
|
||||
export async function importFromPlaynite(
|
||||
config: PlayniteConfig,
|
||||
options: PlayniteImportOptions,
|
||||
logCallback: LogCallback,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
@@ -254,6 +267,8 @@ export async function importFromPlaynite(
|
||||
errors: []
|
||||
};
|
||||
|
||||
const { limit, nameFilter } = options;
|
||||
|
||||
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -265,10 +280,13 @@ export async function importFromPlaynite(
|
||||
|
||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||
logCallback('Fetching existing media from Omnyx API...');
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
|
||||
const existingMediaData = await existingMediaResponse.json();
|
||||
const existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
|
||||
(existingMediaData.data?.items || []).map((m: Media) => [
|
||||
m.cleanname || m.title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-'),
|
||||
m
|
||||
])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||
|
||||
@@ -276,7 +294,7 @@ export async function importFromPlaynite(
|
||||
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=${limit || 5000}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
@@ -286,22 +304,49 @@ export async function importFromPlaynite(
|
||||
}
|
||||
|
||||
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||
const games = gamesData.games || [];
|
||||
let games = gamesData.games || [];
|
||||
|
||||
// Apply name filter if provided (case-insensitive)
|
||||
if (nameFilter) {
|
||||
const filterLower = nameFilter.toLowerCase();
|
||||
games = games.filter(game => game.name?.toLowerCase().includes(filterLower));
|
||||
logCallback(`Filtered to ${games.length} games matching "${nameFilter}"`);
|
||||
}
|
||||
|
||||
// Apply limit if provided (after name filter)
|
||||
if (limit && games.length > limit) {
|
||||
games = games.slice(0, limit);
|
||||
logCallback(`Limited to ${games.length} games`);
|
||||
}
|
||||
|
||||
logCallback(`Found ${games.length} games in Playnite`);
|
||||
|
||||
// Deduplicate games by name (case-insensitive, trimmed)
|
||||
const uniqueGamesMap = new Map<string, PlayniteGame>();
|
||||
for (const game of games) {
|
||||
const normalizedName = game.name.toLowerCase().trim();
|
||||
if (!uniqueGamesMap.has(normalizedName)) {
|
||||
uniqueGamesMap.set(normalizedName, game);
|
||||
}
|
||||
}
|
||||
const uniqueGames = Array.from(uniqueGamesMap.values());
|
||||
if (uniqueGames.length !== games.length) {
|
||||
logCallback(`Deduplicated: ${games.length} → ${uniqueGames.length} unique games`);
|
||||
}
|
||||
|
||||
// Step 2: Fetch detailed information for each game
|
||||
progressCallback({
|
||||
total: games.length,
|
||||
total: uniqueGames.length,
|
||||
current: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Fetching game details...'
|
||||
});
|
||||
|
||||
const detailedGames: PlayniteGame[] = [];
|
||||
for (let i = 0; i < games.length; i++) {
|
||||
const game = games[i];
|
||||
for (let i = 0; i < uniqueGames.length; i++) {
|
||||
const game = uniqueGames[i];
|
||||
try {
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${uniqueGames.length})`);
|
||||
|
||||
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||
method: 'GET',
|
||||
@@ -355,9 +400,24 @@ export async function importFromPlaynite(
|
||||
for (let i = 0; i < detailedGames.length; i++) {
|
||||
const game = detailedGames[i];
|
||||
|
||||
const existingGame = existingMedia.get(game.name);
|
||||
const cleanName = game.name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-');
|
||||
const existingGame = existingMedia.get(cleanName);
|
||||
const isUpdate = existingGame !== undefined;
|
||||
|
||||
if (!isUpdate) {
|
||||
// Debug: show similar titles from database for games not found
|
||||
const similarTitles = Array.from(existingMedia.keys()).filter((key): key is string =>
|
||||
typeof key === 'string' && (key.includes(cleanName.substring(0, 10)) || cleanName.includes(key.substring(0, 10)))
|
||||
).slice(0, 5);
|
||||
if (similarTitles.length > 0) {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND. Similar titles in DB: ${similarTitles.join(', ')}`);
|
||||
} else {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - NOT FOUND (will import)`);
|
||||
}
|
||||
} else {
|
||||
logCallback(`Checking duplicate for: "${game.name}" (cleanname: "${cleanName}") - FOUND (will update)`);
|
||||
}
|
||||
|
||||
// Skip if updateExisting is false and item already exists
|
||||
if (!config.updateExisting && isUpdate) {
|
||||
logCallback(`⊘ Skipped game: ${game.name} (already exists, updateExisting is false)`);
|
||||
|
||||
Reference in New Issue
Block a user