export interface XBVRConfig { url: string; apiKey?: string; } export interface ImportProgress { current: number; total: number; stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; message: string; videosImported: number; actorsImported: number; errors: string[]; } export interface XBVRVideo { title: string; videoLength: number; thumbnailUrl: string; video_url: string; } export interface XBVRVideoDetail { id: number; title: string; description: string; date: number; thumbnailUrl: string; rating_avg: number; screenType: string; stereoMode: string; videoLength: number; paysite?: { name: string; }; actors: Array<{ id: number; name: string; }>; categories: Array<{ tag: { name: string; }; }>; } export interface XBVRSceneList { scenes: Array<{ name: string; list: XBVRVideo[]; }>; } export type LogCallback = (message: string) => void; export type ProgressCallback = (progress: Partial) => void; export async function importFromXBVR( config: XBVRConfig, logCallback: LogCallback, progressCallback: ProgressCallback ): Promise { const progress: ImportProgress = { current: 0, total: 0, stage: 'fetching', message: 'Connecting to DeoVR API...', videosImported: 0, actorsImported: 0, errors: [] }; try { logCallback('Starting DeoVR import...'); // Step 0: Fetch existing media and cast to check for duplicates 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 existingTitles = new Set( existingMediaData.data?.items?.map((m: any) => m.title) || [] ); logCallback(`Found ${existingTitles.size} existing videos in database`); logCallback('Fetching existing cast from Kyoo API...'); const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast?limit=1000'); const existingCastData = await existingCastResponse.json(); const existingActors = new Map( (existingCastData.data?.items || []).map((c: any) => [c.name, c]) ); logCallback(`Found ${existingActors.size} existing actors in database`); // Step 1: Fetch scene list from DeoVR API logCallback(`Fetching scene list from ${config.url}/deovr...`); progressCallback({ message: 'Fetching scene list from DeoVR...' }); const scenesListResponse = await fetch(`${config.url}/deovr`); if (!scenesListResponse.ok) { throw new Error(`Failed to connect to DeoVR API: ${scenesListResponse.statusText}`); } const scenesListData: XBVRSceneList = await scenesListResponse.json(); logCallback('Received scene list structure'); // Extract only videos from the 'Recent' scene group const allVideos: XBVRVideo[] = []; if (scenesListData.scenes && Array.isArray(scenesListData.scenes)) { const recentGroup = scenesListData.scenes.find((group) => group.name === 'Recent'); if (recentGroup && recentGroup.list && Array.isArray(recentGroup.list)) { allVideos.push(...recentGroup.list); } } logCallback(`Found ${allVideos.length} videos in 'Recent' scene group`); // Step 2: Fetch details for each video progressCallback({ total: allVideos.length, stage: 'importing', message: 'Fetching video details...' }); const videoDetails: XBVRVideoDetail[] = []; const actorSet = new Map(); for (let i = 0; i < allVideos.length; i++) { const video = allVideos[i]; try { logCallback(`Fetching details for video: ${video.title} (${i + 1}/${allVideos.length})`); const detailResponse = await fetch(video.video_url); if (!detailResponse.ok) { throw new Error(`Failed to fetch details: ${detailResponse.statusText}`); } const detailData: XBVRVideoDetail = await detailResponse.json(); videoDetails.push(detailData); // Extract actors from video details if (detailData.actors && Array.isArray(detailData.actors)) { detailData.actors.forEach((actor) => { // Skip actors containing 'aka:' anywhere in the name if (actor.name.toLowerCase().includes('aka:')) { return; } // Deduplicate by actor ID if (!actorSet.has(actor.id)) { actorSet.set(actor.id, actor); } }); } logCallback(`✓ Fetched details for: ${video.title}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logCallback(`✗ Failed to fetch details for ${video.title}: ${errorMessage}`); progress.errors.push(`Failed to fetch details for ${video.title}: ${errorMessage}`); progressCallback({ errors: progress.errors }); } progressCallback({ current: i + 1, message: `Fetching video details... ${Math.round(((i + 1) / allVideos.length) * 100)}%` }); } const uniqueActors = Array.from(actorSet.values()); logCallback(`Found ${uniqueActors.length} unique actors across all videos`); // Step 3: Import actors first progressCallback({ total: uniqueActors.length + videoDetails.length, current: 0, message: 'Importing actors...' }); let actorsImported = 0; const actorErrors: string[] = []; for (let i = 0; i < uniqueActors.length; i++) { const actor = uniqueActors[i]; // Skip actors containing 'aka:' anywhere in the name if (actor.name.toLowerCase().includes('aka:')) { logCallback(`⊘ Skipped 'aka:' actor: ${actor.name}`); progressCallback({ current: i + 1 }); continue; } const existingActor = existingActors.get(actor.name); try { if (existingActor) { // Update existing actor - XBVR doesn't have photos, so just ensure it exists logCallback(`⊘ Actor already exists: ${actor.name}`); } else { // Create new actor const response = await fetch('http://192.168.1.102:6400/api/cast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: actor.name, photo: null, bio: null, birthDate: null, birthPlace: null, occupations: ['Actor'] }) }); if (response.ok) { actorsImported++; logCallback(`✓ Imported actor: ${actor.name}`); } else { const error = await response.text(); actorErrors.push(`Failed to import actor ${actor.name}: ${error}`); logCallback(`✗ Failed to import actor: ${actor.name}`); } } } catch (error) { actorErrors.push(`Error importing actor ${actor.name}: ${error}`); logCallback(`✗ Error importing actor: ${actor.name}`); } progressCallback({ current: i + 1, actorsImported, errors: actorErrors }); } logCallback(`Imported ${actorsImported}/${uniqueActors.length} actors`); // Step 4: Import videos progressCallback({ current: uniqueActors.length, message: 'Importing videos...' }); let videosImported = 0; const videoErrors: string[] = []; for (let i = 0; i < videoDetails.length; i++) { const video = videoDetails[i]; // Skip videos starting with 'aka:' if (video.title.toLowerCase().startsWith('aka:')) { logCallback(`⊘ Skipped 'aka:' video: ${video.title}`); progressCallback({ current: uniqueActors.length + i + 1 }); continue; } // Check for duplicate if (existingTitles.has(video.title)) { logCallback(`⊘ Skipped duplicate: ${video.title}`); progressCallback({ current: uniqueActors.length + i + 1 }); continue; } try { // Extract categories/tags const categories = video.categories && Array.isArray(video.categories) ? video.categories.map((c) => c.tag?.name).filter(Boolean) : []; // Extract actors const staff = video.actors && Array.isArray(video.actors) ? video.actors.map((a) => ({ name: a.name, role: 'Actor', photo: null, characterName: a.name, characterImage: null })) : []; // Convert Unix timestamp to date const releaseDate = video.date ? new Date(video.date * 1000).toISOString().split('T')[0] : null; const year = video.date ? new Date(video.date * 1000).getFullYear() : new Date().getFullYear(); // Determine aspect ratio based on DeoVR screenType and stereoMode let aspectRatio: '2/3' | '16/9' | '1/1' = '16/9'; if (video.screenType === '360' || video.screenType === '360180') { aspectRatio = '1/1'; // VR360 videos are typically square for SBS } else if (video.screenType === '180' || video.screenType === 'dome') { aspectRatio = '16/9'; // VR180 videos are typically 16:9 for SBS } else if (video.stereoMode === 'tb' && (video.screenType === '360' || video.screenType === '180')) { aspectRatio = '1/1'; // Top-bottom format is taller } const mediaData = { title: video.title, year: year, poster: video.thumbnailUrl || null, banner: null, description: video.description || null, rating: video.rating_avg || null, category: 'Adult', type: 'Movie', status: 'completed', aspectRatio: aspectRatio, runtime: video.videoLength || null, director: null, writer: null, releaseDate: releaseDate, genres: categories, tags: categories, studios: video.paysite?.name ? [video.paysite.name] : [], staff: staff }; const 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) { videosImported++; logCallback(`✓ Imported video: ${video.title}`); } else { const error = await response.text(); videoErrors.push(`Failed to import video ${video.title}: ${error}`); logCallback(`✗ Failed to import video: ${video.title}`); } } catch (error) { videoErrors.push(`Error importing video ${video.title}: ${error}`); logCallback(`✗ Error importing video: ${video.title}`); } progressCallback({ current: uniqueActors.length + i + 1, videosImported, errors: [...actorErrors, ...videoErrors] }); } logCallback(`Imported ${videosImported}/${videoDetails.length} videos`); // Complete progress.stage = 'complete'; progress.message = 'Import complete!'; progress.current = uniqueActors.length + videoDetails.length; progress.total = uniqueActors.length + videoDetails.length; progress.videosImported = videosImported; progress.actorsImported = actorsImported; progress.errors = [...actorErrors, ...videoErrors]; 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; } }