/** * XBVR Importer Module * * This module provides functionality to import VR adult video content from an XBVR instance into the Omnyx media database. * It fetches scene data from the DeoVR API endpoint, extracts actors and video details, and handles both new imports * and updates to existing entries. The module specifically filters for content in the 'Recent' scene group. * * @module xbvrImporter */ const BASE_URL = import.meta.env.VITE_API_URL; // Import the source mapping and types import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types'; /** * Configuration for connecting to an XBVR instance */ export interface XBVRConfig { /** URL of the XBVR server */ url: string; /** API key for authentication (optional) */ apiKey?: string; /** If true, update existing media entries; if false, only import new entries */ updateExisting?: boolean; } /** * Progress tracking for the import operation */ export interface ImportProgress { /** Current number of items processed */ current: number; /** Total number of items to process */ total: number; /** Current stage of the import process */ stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error'; /** Human-readable status message */ message: string; /** Number of videos successfully imported */ videosImported: number; /** Number of actors successfully imported */ actorsImported: number; /** Array of error messages encountered during import */ errors: string[]; } /** * Basic video information from the DeoVR scene list */ export interface XBVRVideo { /** Video title */ title: string; /** Video length in seconds */ videoLength: number; /** URL to the video thumbnail */ thumbnailUrl: string; /** URL to fetch detailed video information */ video_url: string; } /** * Detailed video information as returned by the XBVR API */ export interface XBVRVideoDetail { /** Unique video identifier */ id: number; /** Video title */ title: string; /** Video description */ description: string; /** Release date as Unix timestamp */ date: number; /** URL to the video thumbnail */ thumbnailUrl: string; /** Average rating */ rating_avg: number; /** Screen type (e.g., '180', '360', 'dome') */ screenType: string; /** Stereo mode (e.g., 'sbs', 'tb') */ stereoMode: string; /** Video length in seconds */ videoLength: number; /** Pay site information */ paysite?: { name: string; }; /** Array of actors in the video */ actors: Array<{ id: number; name: string; }>; /** Array of category tags */ categories: Array<{ tag: { name: string; }; }>; } /** * Scene list structure as returned by the DeoVR API */ export interface XBVRSceneList { /** Array of scene groups */ scenes: Array<{ /** Name of the scene group (e.g., 'Recent', 'Favorites') */ name: string; /** List of videos in this group */ list: XBVRVideo[]; }>; } /** * Callback function for logging import progress messages * @param message - The log message to display */ export type LogCallback = (message: string) => void; /** * Callback function for updating import progress * @param progress - Partial progress object with updated fields */ export type ProgressCallback = (progress: Partial) => void; /** * Imports VR adult videos and actors from an XBVR instance into the Omnyx media database * * This function performs the following steps: * 1. Fetches existing media and cast from Omnyx to check for duplicates * 2. Fetches the scene list from the DeoVR API endpoint * 3. Extracts videos from the 'Recent' scene group * 4. Fetches detailed information for each video * 5. Imports or updates actors first * 6. Imports or updates videos with their associated actors * * Videos and actors containing 'aka:' in their name are automatically skipped. * * @param config - Configuration for connecting to XBVR * @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 * * @example * ```typescript * const progress = await importFromXBVR( * { url: 'http://localhost:9999', apiKey: 'your-api-key' }, * (msg) => console.log(msg), * (prog) => updateUI(prog) * ); * console.log(`Imported ${progress.videosImported} videos and ${progress.actorsImported} actors`); * ``` */ 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 Omnyx API...'); const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`); const existingMediaData = await existingMediaResponse.json(); const existingTitles = new Set( existingMediaData.data?.items?.map((m: Media) => m.title) || [] ); logCallback(`Found ${existingTitles.size} existing videos in database`); logCallback('Fetching existing cast from Omnyx API...'); const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`); const existingCastData = await existingCastResponse.json(); const existingActors = new Map( (existingCastData.data?.items || []).map((c: Staff) => [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(`${BASE_URL}/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)) { if (!config.updateExisting) { logCallback(`⊘ Skipped duplicate: ${video.title} (updateExisting is false)`); progressCallback({ current: uniqueActors.length + i + 1 }); continue; } logCallback(`→ Updating existing: ${video.title}`); } 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, source: SOURCE_CATEGORY_MAPPING['xbvr']?.includes('Adult') ? 'xbvr' : null, genres: categories, tags: categories, studios: video.paysite?.name ? [video.paysite.name] : [], staff: staff }; const response = await fetch(`${BASE_URL}/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; } }