From 9a72ba306448d800989eedf863528ad6ee16a62a Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Sat, 25 Apr 2026 23:54:18 +0200 Subject: [PATCH] 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. --- src/components/BrowseView.tsx | 8 +- src/components/DetailView.tsx | 256 +++----------------- src/components/ImporterView.tsx | 29 ++- src/components/details/tabs/CastTab.tsx | 46 ++++ src/components/details/tabs/OverviewTab.tsx | 27 +++ src/components/details/tabs/SeasonsTab.tsx | 132 ++++++++++ src/components/details/tabs/SeriesTab.tsx | 54 +++++ src/components/details/tabs/TracksTab.tsx | 49 ++++ src/components/routes/MediaDetailRoute.tsx | 1 + src/lib/api/converters.ts | 1 + src/lib/api/types.ts | 1 + src/lib/playniteImporter.ts | 78 +++++- src/types.ts | 2 + 13 files changed, 442 insertions(+), 242 deletions(-) create mode 100644 src/components/details/tabs/CastTab.tsx create mode 100644 src/components/details/tabs/OverviewTab.tsx create mode 100644 src/components/details/tabs/SeasonsTab.tsx create mode 100644 src/components/details/tabs/SeriesTab.tsx create mode 100644 src/components/details/tabs/TracksTab.tsx diff --git a/src/components/BrowseView.tsx b/src/components/BrowseView.tsx index 1549a3e..af13f20 100644 --- a/src/components/BrowseView.tsx +++ b/src/components/BrowseView.tsx @@ -58,7 +58,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]); const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]); const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]); - const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]); + const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.series || []))), [mediaList]); const allSources = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.source ? [m.source] : []))), [mediaList]); const filteredMedia = useMemo(() => { @@ -67,7 +67,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it if (selectedStudio && !media.studios?.includes(selectedStudio)) return false; if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false; if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false; - if (selectedCategory && !media.categories?.includes(selectedCategory)) return false; + if (selectedCategory && !media.series?.includes(selectedCategory)) return false; if (selectedSource && media.source !== selectedSource) return false; return true; }); @@ -201,11 +201,11 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it - setSelectedCategory(null)}>All Categories + setSelectedCategory(null)}>--- Alle --- {allCategories.sort().map(category => ( setSelectedCategory(category)}>{category} ))} diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 5a01f9a..fcd3c81 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -1,41 +1,29 @@ -import { Media, Staff, Track } from '@/types'; +import { Media, Staff } from '@/types'; import { useNavigate } from 'react-router-dom'; -import { useState, useMemo, useEffect } from 'react'; -import { - Play, - Bookmark, - MoreHorizontal, - Star, - ChevronLeft, - ChevronRight, - Search, - ListFilter, - ChevronDown, - Calendar, - Clock, - Eye -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { useState } from 'react'; +import { ChevronLeft, Calendar, Clock } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Separator } from '@/components/ui/separator'; import { motion } from 'motion/react'; +import OverviewTab from './details/tabs/OverviewTab'; +import CastTab from './details/tabs/CastTab'; +import SeasonsTab from './details/tabs/SeasonsTab'; +import TracksTab from './details/tabs/TracksTab'; +import SeriesTab from './details/tabs/SeriesTab'; interface DetailViewProps { media: Media; + allMedia: Media[]; onPersonClick: (person: Staff) => void; } -export default function DetailView({ media, onPersonClick }: DetailViewProps) { +export default function DetailView({ media, allMedia, onPersonClick }: DetailViewProps) { const navigate = useNavigate(); - const [castLimit, setCastLimit] = useState(6); - const [showAllCast, setShowAllCast] = useState(false); - const [expandedSeasons, setExpandedSeasons] = useState>(new Set()); const [progress, setProgress] = useState(70.8); const hasEpisodes = media.episodes && media.episodes.length > 0; const hasTracks = media.tracks && media.tracks.length > 0; const hasCast = media.staff && media.staff.length > 0; + const hasFranchise = media.category === 'Games' && media.series && media.series.length > 0; const tabs = [ 'Overview', ...(hasCast ? ['Cast'] : []), @@ -43,6 +31,7 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) { 'History', ...(hasEpisodes ? ['Seasons'] : []), ...(hasTracks ? ['Tracks'] : []), + ...(hasFranchise ? ['Series'] : []), 'Reviews', 'Suggestions', 'Watch On' @@ -50,46 +39,6 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) { const [activeTab, setActiveTab] = useState(tabs[0]); - // Group episodes by season - const episodesBySeason = useMemo(() => { - if (!media.episodes) return {}; - const grouped: Record = {}; - media.episodes.forEach(episode => { - if (!grouped[episode.season]) { - grouped[episode.season] = []; - } - grouped[episode.season].push(episode); - }); - // Sort episodes within each season by episode number - Object.keys(grouped).forEach(season => { - grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number); - }); - return grouped; - }, [media.episodes]); - - // Expand first season by default on mount - useEffect(() => { - const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b); - if (seasons.length > 0) { - setExpandedSeasons(new Set([seasons[0]])); - } - }, [episodesBySeason]); - - const toggleSeason = (season: number) => { - setExpandedSeasons(prev => { - const newSet = new Set(prev); - if (newSet.has(season)) { - newSet.delete(season); - } else { - newSet.add(season); - } - return newSet; - }); - }; - - const displayedCast = showAllCast ? media.staff : (media.staff?.slice(0, castLimit) || []); - const hasMoreCast = (media.staff?.length || 0) > castLimit; - return (
{/* Banner */} @@ -201,176 +150,27 @@ export default function DetailView({ media, onPersonClick }: DetailViewProps) { ))}
- {/* Genre Tags */} - {activeTab === 'Overview' && ( -
- {media.genres?.map(genre => ( - - {genre} - - ))} -
- )} + {/* Overview Tab */} + {activeTab === 'Overview' && } - {/* Description */} - {activeTab === 'Overview' && ( -
- )} - - {/* Acting Section - Horizontal Scrollable */} + {/* Cast Tab */} {media.staff && media.staff.length > 0 && activeTab === 'Cast' && ( -
-

Acting

-
- {displayedCast.map(person => ( -
onPersonClick(person)} - > -
- {person.name} -
-

{person.name}

-

{person.characterName || person.role}

-
- ))} - {hasMoreCast && ( - - )} -
-
+ + )} + {/* Seasons Tab */} + {media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && ( + )} - {/* Episodes Section - Only show if episodes data exists and Seasons tab is active */} - {media.episodes && media.episodes.length > 0 && activeTab === 'Seasons' && ( -
-
-
-
- {media.episodes.length} Episode{media.episodes.length !== 1 ? 's' : ''} -
-
- {Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''} -
-
-
-
- - -
- - -
-
-
- {Object.keys(episodesBySeason) - .map(Number) - .sort((a, b) => a - b) - .map(season => ( -
- - {expandedSeasons.has(season) && ( -
- {episodesBySeason[season].map(episode => ( -
-
-
- {episode.title} -
-
-
-
-

- E{episode.episode_number} • {episode.title} -

- {episode.air_date} • {episode.duration}m -
-

- {episode.description} -

-
-
- -
- ))} -
- )} -
- ))} -
-
- )} + {/* Tracks Tab */} + {media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && ( + + )} - {/* Tracks Section - Only show if tracks data exists and Tracks tab is active */} - {media.tracks && media.tracks.length > 0 && activeTab === 'Tracks' && ( -
-
-
-
- {media.tracks.length} Track{media.tracks.length !== 1 ? 's' : ''} -
-
-
-
- - -
- - -
-
- -
- {media.tracks.map(track => ( -
- {track.track_number} -
-

- {track.title} -

-

{track.artist}

-
- {track.duration ? `${track.duration}m` : '-'} -
- ))} -
-
- )} + {/* Series Tab */} + {media.category === 'Games' && media.series && media.series.length > 0 && activeTab === 'Series' && ( + window.location.href = `/${media.id}`} /> + )}
diff --git a/src/components/ImporterView.tsx b/src/components/ImporterView.tsx index a147bed..0a5d23a 100644 --- a/src/components/ImporterView.tsx +++ b/src/components/ImporterView.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter'; import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter'; -import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter'; +import { importFromPlaynite, PlayniteConfig, PlayniteImportOptions } from '@/lib/playniteImporter'; import { importFromJellyfin, cleanupJellyfinMedia, JellyfinConfig, JellyfinImportOptions, LibraryMapping, fetchJellyfinLibraries } from '@/lib/jellyfinImporter'; import { fetchSettings, updateSettings } from '@/api'; @@ -25,6 +25,10 @@ export default function ImporterView() { port: import.meta.env.VITE_PLAYNITE_PORT ? parseInt(import.meta.env.VITE_PLAYNITE_PORT) : undefined, updateExisting: true }); + const [playniteOptions, setPlayniteOptions] = useState({ + limit: undefined, + nameFilter: undefined + }); const [jellyfinConfig, setJellyfinConfig] = useState({ url: import.meta.env.VITE_JELLYFIN_URL || '', apiKey: import.meta.env.VITE_JELLYFIN_API_KEY || '' @@ -199,6 +203,7 @@ export default function ImporterView() { const result = await importFromPlaynite( playniteConfig, + playniteOptions, addLog, (progressUpdate) => { setProgress(prev => ({ ...prev, ...progressUpdate })); @@ -639,6 +644,28 @@ export default function ImporterView() { /> +
+ + setPlayniteOptions({ ...playniteOptions, limit: e.target.value ? parseInt(e.target.value) : undefined })} + disabled={progress.stage !== 'idle'} + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed" + placeholder="e.g. 10" + /> +
+
+ + setPlayniteOptions({ ...playniteOptions, nameFilter: e.target.value || undefined })} + disabled={progress.stage !== 'idle'} + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-muted disabled:cursor-not-allowed" + placeholder="e.g. Reside" + /> +
+ )} + + + ); +} diff --git a/src/components/details/tabs/OverviewTab.tsx b/src/components/details/tabs/OverviewTab.tsx new file mode 100644 index 0000000..40c404f --- /dev/null +++ b/src/components/details/tabs/OverviewTab.tsx @@ -0,0 +1,27 @@ +import { Media } from '@/types'; +import { Badge } from '@/components/ui/badge'; + +interface OverviewTabProps { + media: Media; +} + +export default function OverviewTab({ media }: OverviewTabProps) { + return ( + <> + {/* Genre Tags */} +
+ {media.genres?.map(genre => ( + + {genre} + + ))} +
+ + {/* Description */} +
+ + ); +} diff --git a/src/components/details/tabs/SeasonsTab.tsx b/src/components/details/tabs/SeasonsTab.tsx new file mode 100644 index 0000000..cdaaac0 --- /dev/null +++ b/src/components/details/tabs/SeasonsTab.tsx @@ -0,0 +1,132 @@ +import { Episode } from '@/types'; +import { useState, useMemo, useEffect } from 'react'; +import { Search, MoreHorizontal, ListFilter, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; + +interface SeasonsTabProps { + episodes: Episode[]; +} + +export default function SeasonsTab({ episodes }: SeasonsTabProps) { + const [expandedSeasons, setExpandedSeasons] = useState>(new Set()); + + // Group episodes by season + const episodesBySeason = useMemo(() => { + if (!episodes) return {}; + const grouped: Record = {}; + episodes.forEach(episode => { + if (!grouped[episode.season]) { + grouped[episode.season] = []; + } + grouped[episode.season].push(episode); + }); + // Sort episodes within each season by episode number + Object.keys(grouped).forEach(season => { + grouped[Number(season)].sort((a, b) => a.episode_number - b.episode_number); + }); + return grouped; + }, [episodes]); + + // Expand first season by default on mount + useEffect(() => { + const seasons = Object.keys(episodesBySeason).map(Number).sort((a, b) => a - b); + if (seasons.length > 0) { + setExpandedSeasons(new Set([seasons[0]])); + } + }, [episodesBySeason]); + + const toggleSeason = (season: number) => { + setExpandedSeasons(prev => { + const newSet = new Set(prev); + if (newSet.has(season)) { + newSet.delete(season); + } else { + newSet.add(season); + } + return newSet; + }); + }; + + return ( +
+
+
+
+ {episodes.length} Episode{episodes.length !== 1 ? 's' : ''} +
+
+ {Object.keys(episodesBySeason).length} Season{Object.keys(episodesBySeason).length !== 1 ? 's' : ''} +
+
+
+
+ + +
+ + +
+
+ +
+ {Object.keys(episodesBySeason) + .map(Number) + .sort((a, b) => a - b) + .map(season => ( +
+ + {expandedSeasons.has(season) && ( +
+ {episodesBySeason[season].map(episode => ( +
+
+
+ {episode.title} +
+
+
+
+

+ E{episode.episode_number} • {episode.title} +

+ {episode.air_date} • {episode.duration}m +
+

+ {episode.description} +

+
+
+ +
+ ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/details/tabs/SeriesTab.tsx b/src/components/details/tabs/SeriesTab.tsx new file mode 100644 index 0000000..890163c --- /dev/null +++ b/src/components/details/tabs/SeriesTab.tsx @@ -0,0 +1,54 @@ +import { Media } from '@/types'; +import MediaCard from '../../MediaCard'; + +interface SeriesTabProps { + media: Media; + allMedia: Media[]; + onMediaClick: (media: Media) => void; +} + +export default function SeriesTab({ media, allMedia, onMediaClick }: SeriesTabProps) { + // Filter games that share at least one series with the current game + const seriesGames = allMedia.filter( + (m) => + m.category === 'Games' && + m.id !== media.id && + m.series && + media.series && + m.series.some((s) => media.series!.includes(s)) + ); + + if (seriesGames.length === 0) { + return ( +
+

Series

+

No other games found in the same series.

+
+ ); + } + + return ( +
+

Series

+
+ {media.series?.map((s) => ( + + {s} + + ))} +
+
+ {seriesGames.map((game) => ( + onMediaClick(game)} + /> + ))} +
+
+ ); +} diff --git a/src/components/details/tabs/TracksTab.tsx b/src/components/details/tabs/TracksTab.tsx new file mode 100644 index 0000000..f804a70 --- /dev/null +++ b/src/components/details/tabs/TracksTab.tsx @@ -0,0 +1,49 @@ +import { Track } from '@/types'; +import { Search, MoreHorizontal, ListFilter } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface TracksTabProps { + tracks: Track[]; +} + +export default function TracksTab({ tracks }: TracksTabProps) { + return ( +
+
+
+
+ {tracks.length} Track{tracks.length !== 1 ? 's' : ''} +
+
+
+
+ + +
+ + +
+
+ +
+ {tracks.map(track => ( +
+ {track.track_number} +
+

+ {track.title} +

+

{track.artist}

+
+ {track.duration ? `${track.duration}m` : '-'} +
+ ))} +
+
+ ); +} diff --git a/src/components/routes/MediaDetailRoute.tsx b/src/components/routes/MediaDetailRoute.tsx index 1911651..abb6c57 100644 --- a/src/components/routes/MediaDetailRoute.tsx +++ b/src/components/routes/MediaDetailRoute.tsx @@ -44,6 +44,7 @@ export default function MediaDetailRoute({ allMedia, onPersonClick }: MediaDetai return ( ); diff --git a/src/lib/api/converters.ts b/src/lib/api/converters.ts index 484e394..f23bf56 100644 --- a/src/lib/api/converters.ts +++ b/src/lib/api/converters.ts @@ -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, diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 5af55e2..a6cfdd1 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -58,6 +58,7 @@ export interface ApiMediaItem { studios?: string[]; staff?: ApiStaff[]; categories?: string[]; + series?: string[]; platforms?: string[]; developers?: string[]; completionStatus?: string; diff --git a/src/lib/playniteImporter.ts b/src/lib/playniteImporter.ts index f055bf3..02987d2 100644 --- a/src/lib/playniteImporter.ts +++ b/src/lib/playniteImporter.ts @@ -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, 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, 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, g */ export async function importFromPlaynite( config: PlayniteConfig, + options: PlayniteImportOptions, logCallback: LogCallback, progressCallback: ProgressCallback ): Promise { @@ -254,6 +267,8 @@ export async function importFromPlaynite( errors: [] }; + const { limit, nameFilter } = options; + const baseUrl = `http://${config.ip}:${config.port || 19821}`; const headers: Record = { '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(); + 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)`); diff --git a/src/types.ts b/src/types.ts index e240dcc..9e264f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' export interface Media { id: string; title: string; + cleanname?: string; year: string; poster: string; category: MediaCategory; @@ -19,6 +20,7 @@ export interface Media { tracks?: Track[]; staff?: Staff[]; categories?: string[]; + series?: string[]; platforms?: string[]; developers?: string[]; completionStatus?: string;