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:
Lars Behrends
2026-04-25 23:54:18 +02:00
parent 34bb4a27be
commit 9a72ba3064
13 changed files with 442 additions and 242 deletions
+1
View File
@@ -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,
+1
View File
@@ -58,6 +58,7 @@ export interface ApiMediaItem {
studios?: string[];
staff?: ApiStaff[];
categories?: string[];
series?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
+69 -9
View File
@@ -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)`);