From 1caadd12e11d939c70e2b98cc34d68df29cf9113 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Thu, 9 Apr 2026 17:13:04 +0200 Subject: [PATCH] imports :) --- src/App.tsx | 21 +- src/api.ts | 10 +- src/components/Header.tsx | 22 +- src/components/ImporterView.tsx | 444 +++++++++++++++++++ src/lib/stashappImporter.ts | 735 ++++++++++++++++++++++++++++++++ src/lib/xbvrImporter.ts | 363 ++++++++++++++++ 6 files changed, 1579 insertions(+), 16 deletions(-) create mode 100644 src/components/ImporterView.tsx create mode 100644 src/lib/stashappImporter.ts create mode 100644 src/lib/xbvrImporter.ts diff --git a/src/App.tsx b/src/App.tsx index 3dfe66a..740c9ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,12 +11,13 @@ import DetailView from './components/DetailView'; import CastView from './components/CastView'; import CastDetailView from './components/CastDetailView'; import AddMediaView from './components/AddMediaView'; +import ImporterView from './components/ImporterView'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; import { Media, Staff, MediaCategory } from './types'; import { fetchAllMedia, fetchMediaById } from './api'; export default function App() { - const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail' | 'add'>('browse'); + const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail' | 'add' | 'import'>('browse'); const [activeCategory, setActiveCategory] = useState('Anime'); const [selectedMedia, setSelectedMedia] = useState(null); const [selectedPerson, setSelectedPerson] = useState(null); @@ -67,6 +68,11 @@ export default function App() { window.scrollTo({ top: 0, behavior: 'smooth' }); }; + const handleImporterView = () => { + setCurrentView('import'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + const allMedia = useMemo(() => { // Use API data if available, otherwise fall back to mock data let list: Media[] = []; @@ -199,16 +205,17 @@ export default function App() { return (
-
@@ -237,11 +244,15 @@ export default function App() { /> ) ) : currentView === 'add' ? ( - + ) : currentView === 'import' ? ( + ) : ( selectedMedia && ( { +export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise { try { const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`); if (!response.ok) { @@ -342,7 +344,7 @@ export async function deleteMedia(id: number | string): Promise { } // Cast API Functions -export async function fetchAllCast(page: number = 1, limit: number = 50): Promise { +export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise { try { const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`); if (!response.ok) { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4a51c34..cb8136c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { Search, User, X, Plus } from 'lucide-react'; +import { Search, User, X, Plus, Download } from 'lucide-react'; import { cn } from '@/lib/utils'; import React, { useState } from 'react'; import { MediaCategory } from '@/types'; @@ -8,6 +8,7 @@ interface HeaderProps { onBrowse: () => void; onCast: () => void; onAddMedia: () => void; + onImporter: () => void; onSearch: (query: string) => void; activeCategory: MediaCategory; onCategoryChange: (category: MediaCategory) => void; @@ -16,16 +17,17 @@ interface HeaderProps { transparent?: boolean; } -export default function Header({ - onBrowse, - onCast, +export default function Header({ + onBrowse, + onCast, onAddMedia, - onSearch, + onImporter, + onSearch, activeCategory, onCategoryChange, enabledCategories, onToggleCategory, - transparent + transparent }: HeaderProps) { const [isSearchOpen, setIsSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -103,12 +105,18 @@ export default function Header({ > {isSearchOpen ? : } - + void }) { + const [xbvrConfig, setXbvrConfig] = useState({ url: 'http://192.168.1.102:4080' }); + const [stashappConfig, setStashappConfig] = useState({ url: 'http://192.168.1.102:10001' }); + const [progress, setProgress] = useState({ + current: 0, + total: 0, + stage: 'idle', + message: '', + videosImported: 0, + actorsImported: 0, + errors: [] + }); + const [importLog, setImportLog] = useState([]); + const logContainerRef = useRef(null); + + // Auto-scroll to bottom when log updates + useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [importLog]); + + const addLog = (message: string) => { + const timestamp = new Date().toLocaleTimeString(); + setImportLog(prev => [...prev, `[${timestamp}] ${message}`]); + }; + + const handleXBVRImport = async () => { + setProgress({ + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to DeoVR API...', + videosImported: 0, + actorsImported: 0, + errors: [] + }); + setImportLog([]); + + const result = await importFromXBVR( + xbvrConfig, + addLog, + (progressUpdate) => { + setProgress(prev => ({ ...prev, ...progressUpdate })); + } + ); + + setProgress(result); + }; + + const handleStashAPPImport = async () => { + setProgress({ + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to StashAPP...', + videosImported: 0, + actorsImported: 0, + errors: [] + }); + setImportLog([]); + + const result = await importFromStashAPP( + stashappConfig, + addLog, + (progressUpdate) => { + setProgress(prev => ({ ...prev, ...progressUpdate })); + } + ); + + setProgress(result); + }; + + const handleStashAPPActorUpdate = async () => { + setProgress({ + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to StashAPP...', + videosImported: 0, + actorsImported: 0, + errors: [] + }); + setImportLog([]); + + const result = await updateActorsFromStashAPP( + stashappConfig, + addLog, + (progressUpdate) => { + setProgress(prev => ({ ...prev, ...progressUpdate })); + } + ); + + setProgress(result); + }; + + const resetImport = () => { + setProgress({ + current: 0, + total: 0, + stage: 'idle', + message: '', + videosImported: 0, + actorsImported: 0, + errors: [] + }); + setImportLog([]); + }; + + const getProgressPercentage = () => { + if (progress.total === 0) return 0; + return Math.round((progress.current / progress.total) * 100); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

Media Importers

+

Import media from external platforms

+
+
+
+ + {/* Importer Cards */} +
+ {/* XBVR Importer Card */} +
+
+
+
+ +
+
+

XBVR

+

Adult Video Manager

+
+
+ +
+

+ Import adult videos and actors from your XBVR database. +

+
+
+ + setXbvrConfig({ ...xbvrConfig, url: e.target.value })} + disabled={progress.stage !== 'idle'} + className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed" + placeholder="http://192.168.1.102:10001" + /> +
+ +
+
+ + {/* StashAPP Importer Card */} +
+
+
+
+ +
+
+

StashAPP

+

Adult Content Manager

+
+
+ +
+

+ Import adult videos and performers from your StashAPP database. +

+
+
+ + setStashappConfig({ ...stashappConfig, url: e.target.value })} + disabled={progress.stage !== 'idle'} + className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed" + placeholder="http://192.168.1.102:10001" + /> +
+
+ + setStashappConfig({ ...stashappConfig, apiKey: e.target.value })} + disabled={progress.stage !== 'idle'} + className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed" + placeholder="Enter API key if required" + /> +
+ +
+
+ + {/* StashAPP Actor Updater Card */} +
+
+
+
+ +
+
+

StashAPP Actor Updater

+

Update existing actors

+
+
+ +
+

+ Update existing actors with fresh data from StashAPP and create missing ones. +

+
+
+ + setStashappConfig({ ...stashappConfig, apiKey: e.target.value })} + disabled={progress.stage !== 'idle'} + className="w-full px-3 py-2 text-sm border border-zinc-200 rounded-lg focus:ring-2 focus:ring-[#6d28d9] focus:border-transparent outline-none disabled:bg-zinc-100 disabled:cursor-not-allowed" + placeholder="Enter API key if required" + /> +
+ +
+
+ + {/* Placeholder for future importers */} +
+ +

More importers coming soon

+
+
+ + {/* Progress Section */} + {progress.stage !== 'idle' && ( +
+
+
+ {progress.stage === 'complete' ? ( +
+ +
+ ) : progress.stage === 'error' ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

{progress.message}

+

+ {progress.stage === 'fetching' && 'Connecting to external service...'} + {progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`} + {progress.stage === 'complete' && 'Import finished'} + {progress.stage === 'error' && 'An error occurred'} +

+
+
+ {progress.stage === 'complete' || progress.stage === 'error' ? ( + + ) : null} +
+ + {/* Progress Bar */} + {progress.stage === 'fetching' || progress.stage === 'importing' ? ( +
+
+
+
+
+ {progress.current} / {progress.total} items + {getProgressPercentage()}% +
+
+ ) : null} + + {/* Stats */} +
+
+
+ + Videos +
+

{progress.videosImported}

+
+
+
+ + Actors +
+

{progress.actorsImported}

+
+
+
+ + Errors +
+

{progress.errors.length}

+
+
+ + {/* Log */} + {importLog.length > 0 && ( +
+
+                {importLog.join('\n')}
+              
+
+ )} + + {/* Errors */} + {progress.errors.length > 0 && ( +
+

Errors

+
+ {progress.errors.map((error, index) => ( +

+ • {error} +

+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/lib/stashappImporter.ts b/src/lib/stashappImporter.ts new file mode 100644 index 0000000..0d370aa --- /dev/null +++ b/src/lib/stashappImporter.ts @@ -0,0 +1,735 @@ +export interface StashAPPConfig { + 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 StashAPPScene { + id: string; + title: string; + details: string; + url: string; + date: string; + rating100: number; + organized: boolean; + o_counter: number; + created_at: string; + updated_at: string; + paths: { + screenshot: string; + preview: string; + stream: string; + webp: string; + vtt: string; + sprite: string; + funscript: string; + caption: string; + }; + files: Array<{ + size: number; + duration: number; + video_codec: string; + audio_codec: string; + width: number; + height: number; + path: string; + }>; + performers: Array<{ + id: string; + name: string; + disambiguation: string; + url: string; + gender: string; + birthdate: string; + ethnicity: string; + country: string; + eye_color: string; + height_cm: number; + measurements: string; + fake_tits: boolean; + career_length: string; + tattoos: string; + piercings: string; + alias_list: string[]; + favorite: boolean; + ignore_auto_tag: boolean; + details: string; + death_date: string; + hair_color: string; + weight: number; + image_path: string; + scene_count: number; + }>; +} + +export interface StashAPPScenePerformer { + id: string; + name: string; + image_path: string; +} + +export interface StashAPPPerformer { + id: string; + name: string; + disambiguation: string; + url: string; + gender: string; + birthdate: string; + ethnicity: string; + country: string; + eye_color: string; + height_cm: number; + measurements: string; + fake_tits: boolean; + career_length: string; + tattoos: string; + piercings: string; + alias_list: string[]; + favorite: boolean; + ignore_auto_tag: boolean; + created_at: string; + updated_at: string; + details: string; + death_date: string; + hair_color: string; + weight: number; + image_path: string; + scene_count: number; +} + +export interface StashAPPScenesResponse { + data: { + findScenes: { + scenes: StashAPPScene[]; + count: number; + }; + }; +} + +export interface StashAPPPerformersResponse { + data: { + findPerformers: { + performers: StashAPPPerformer[]; + count: number; + }; + }; +} + +export type LogCallback = (message: string) => void; +export type ProgressCallback = (progress: Partial) => void; + +export async function updateActorsFromStashAPP( + config: StashAPPConfig, + logCallback: LogCallback, + progressCallback: ProgressCallback +): Promise { + const progress: ImportProgress = { + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to StashAPP...', + videosImported: 0, + actorsImported: 0, + errors: [] + }; + + try { + logCallback('Starting StashAPP actor update...'); + + // Fetch existing cast from Kyoo API + logCallback('Fetching existing cast from Kyoo API...'); + const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast'); + 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`); + + // Fetch all performers from StashAPP + logCallback(`Fetching performers from StashAPP...`); + progressCallback({ message: 'Fetching performers from StashAPP...' }); + + const graphqlQuery = { + query: ` + query FindPerformers($filter: FindFilterType) { + findPerformers(filter: $filter) { + count + performers { + id + name + disambiguation + url + gender + birthdate + ethnicity + country + eye_color + height_cm + measurements + fake_tits + career_length + tattoos + piercings + alias_list + favorite + ignore_auto_tag + created_at + updated_at + details + death_date + hair_color + weight + image_path + scene_count + } + } + } + `, + variables: { + filter: { + per_page: 1000 + } + } + }; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (config.apiKey) { + headers['ApiKey'] = config.apiKey; + } + + const performersResponse = await fetch(`${config.url}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify(graphqlQuery) + }); + + if (!performersResponse.ok) { + throw new Error(`Failed to connect to StashAPP: ${performersResponse.statusText}`); + } + + const performersData: StashAPPPerformersResponse = await performersResponse.json(); + const performers = performersData.data?.findPerformers?.performers || []; + logCallback(`Found ${performers.length} performers in StashAPP`); + + progressCallback({ + total: performers.length, + stage: 'importing', + message: 'Updating actors...' + }); + + let actorsUpdated = 0; + let actorsCreated = 0; + const actorErrors: string[] = []; + + for (let i = 0; i < performers.length; i++) { + const performer = performers[i]; + const existingActor: any = existingActors.get(performer.name); + + try { + if (existingActor) { + // Update existing actor + const updateData: any = { + name: performer.name, + }; + + // Update photo if available and different + if (performer.image_path && performer.image_path !== existingActor.photo) { + updateData.photo = performer.image_path; + } + + // Update bio with details if available + if (performer.details) { + updateData.bio = performer.details; + } else if (performer.career_length) { + updateData.bio = performer.career_length; + } + + // Update birth date if available + if (performer.birthdate) { + updateData.birthDate = performer.birthdate; + } + + // Update birth place if available + if (performer.country) { + updateData.birthPlace = performer.country; + } + + const response = await fetch(`http://192.168.1.102:6400/api/cast/${existingActor.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }); + + if (response.ok) { + actorsUpdated++; + logCallback(`✓ Updated actor: ${performer.name}`); + } else { + const error = await response.text(); + actorErrors.push(`Failed to update actor ${performer.name}: ${error}`); + logCallback(`✗ Failed to update actor: ${performer.name}`); + } + } else { + // Create new actor + const response = await fetch('http://192.168.1.102:6400/api/cast/adult', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: performer.name, + photo: performer.image_path || null, + bio: performer.details || performer.career_length || null, + birthDate: performer.birthdate || null, + birthPlace: performer.country || null, + occupations: ['Actor'], + adult_specifics: { + height: performer.height_cm ? performer.height_cm.toString() : null, + weight: performer.weight ? performer.weight.toString() : null, + hair_color: performer.hair_color || null, + eye_color: performer.eye_color || null, + ethnicity: performer.ethnicity || null, + tattoos: performer.tattoos || null, + piercings: performer.piercings || null, + measurements: performer.measurements || null + } + }) + }); + + if (response.ok) { + actorsCreated++; + logCallback(`✓ Created new Adult actor: ${performer.name}`); + } else { + const error = await response.text(); + actorErrors.push(`Failed to create actor ${performer.name}: ${error}`); + logCallback(`✗ Failed to create actor: ${performer.name}`); + } + } + } catch (error) { + actorErrors.push(`Error processing actor ${performer.name}: ${error}`); + logCallback(`✗ Error processing actor: ${performer.name}`); + } + + progressCallback({ + current: i + 1, + actorsImported: actorsCreated, + errors: actorErrors + }); + } + + logCallback(`Updated ${actorsUpdated} existing actors, created ${actorsCreated} new actors`); + + // Complete + progress.stage = 'complete'; + progress.message = 'Actor update complete!'; + progress.current = performers.length; + progress.total = performers.length; + progress.actorsImported = actorsCreated; + progress.errors = actorErrors; + logCallback('Actor update completed successfully!'); + + return progress; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + progress.stage = 'error'; + progress.message = `Actor update failed: ${errorMessage}`; + progress.errors = [...progress.errors, errorMessage]; + logCallback(`✗ Actor update failed: ${errorMessage}`); + return progress; + } +} + +export async function importFromStashAPP( + config: StashAPPConfig, + logCallback: LogCallback, + progressCallback: ProgressCallback +): Promise { + const progress: ImportProgress = { + current: 0, + total: 0, + stage: 'fetching', + message: 'Connecting to StashAPP API...', + videosImported: 0, + actorsImported: 0, + errors: [] + }; + + try { + logCallback('Starting StashAPP 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'); + 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'); + 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 scenes from StashAPP + logCallback(`Fetching scenes from StashAPP...`); + progressCallback({ message: 'Fetching scenes from StashAPP...' }); + + const graphqlQuery = { + query: ` + query FindScenes($filter: FindFilterType) { + findScenes(filter: $filter) { + scenes { + id + title + details + url + date + rating100 + organized + o_counter + created_at + updated_at + paths { + screenshot + preview + stream + webp + vtt + sprite + funscript + caption + } + files { + size + duration + video_codec + audio_codec + width + height + path + } + performers { + id + name + disambiguation + url + gender + birthdate + ethnicity + country + eye_color + height_cm + measurements + fake_tits + career_length + tattoos + piercings + alias_list + favorite + ignore_auto_tag + created_at + updated_at + details + death_date + hair_color + weight + image_path + scene_count + } + } + count + } + } + `, + variables: { + filter: { + per_page: 100, + sort: "date", + direction: "DESC" + } + } + }; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (config.apiKey) { + headers['ApiKey'] = config.apiKey; + } + + const scenesResponse = await fetch(`${config.url}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify(graphqlQuery) + }); + + if (!scenesResponse.ok) { + throw new Error(`Failed to connect to StashAPP: ${scenesResponse.statusText}`); + } + + const scenesData: StashAPPScenesResponse = await scenesResponse.json(); + const scenes = scenesData.data?.findScenes?.scenes || []; + logCallback(`Found ${scenes.length} scenes in StashAPP`); + + // Step 2: Extract unique performers + const performerSet = new Map(); + scenes.forEach(scene => { + scene.performers.forEach(performer => { + if (!performerSet.has(performer.id)) { + performerSet.set(performer.id, performer); + } + }); + }); + + const uniquePerformers = Array.from(performerSet.values()); + logCallback(`Found ${uniquePerformers.length} unique performers across all scenes`); + + // Step 3: Import performers first + progressCallback({ + total: uniquePerformers.length + scenes.length, + current: 0, + message: 'Importing performers...' + }); + + let performersImported = 0; + const performerErrors: string[] = []; + + for (let i = 0; i < uniquePerformers.length; i++) { + const performer = uniquePerformers[i]; + const existingActor: any = existingActors.get(performer.name); + + try { + if (existingActor) { + // Update existing actor + const updateData: any = { + name: performer.name, + }; + + // Update photo if available and different + if (performer.image_path && performer.image_path !== existingActor.photo) { + updateData.photo = performer.image_path; + } + + // Update bio with details if available + if (performer.details) { + updateData.bio = performer.details; + } else if (performer.career_length) { + updateData.bio = performer.career_length; + } + + // Update birth date if available + if (performer.birthdate) { + updateData.birthDate = performer.birthdate; + } + + // Update birth place if available + if (performer.country) { + updateData.birthPlace = performer.country; + } + + const response = await fetch(`http://192.168.1.102:6400/api/cast/${existingActor.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }); + + if (response.ok) { + performersImported++; + logCallback(`✓ Updated performer: ${performer.name}`); + } else { + const error = await response.text(); + performerErrors.push(`Failed to update performer ${performer.name}: ${error}`); + logCallback(`✗ Failed to update performer: ${performer.name}`); + } + } else { + // Create new actor + const response = await fetch('http://192.168.1.102:6400/api/cast/adult', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: performer.name, + photo: performer.image_path || null, + bio: performer.details || performer.career_length || null, + birthDate: performer.birthdate || null, + birthPlace: performer.country || null, + occupations: ['Actor'], + adult_specifics: { + height: performer.height_cm ? performer.height_cm.toString() : null, + weight: performer.weight ? performer.weight.toString() : null, + hair_color: performer.hair_color || null, + eye_color: performer.eye_color || null, + ethnicity: performer.ethnicity || null, + tattoos: performer.tattoos || null, + piercings: performer.piercings || null, + measurements: performer.measurements || null + } + }) + }); + + if (response.ok) { + performersImported++; + logCallback(`✓ Imported performer: ${performer.name}`); + } else { + const error = await response.text(); + performerErrors.push(`Failed to import performer ${performer.name}: ${error}`); + logCallback(`✗ Failed to import performer: ${performer.name}`); + } + } + } catch (error) { + performerErrors.push(`Error processing performer ${performer.name}: ${error}`); + logCallback(`✗ Error processing performer: ${performer.name}`); + } + + progressCallback({ + current: i + 1, + actorsImported: performersImported, + errors: performerErrors + }); + } + + logCallback(`Processed ${performersImported}/${uniquePerformers.length} performers (imported or updated)`); + + // Step 4: Import scenes + progressCallback({ + current: uniquePerformers.length, + message: 'Importing scenes...' + }); + + let scenesImported = 0; + const sceneErrors: string[] = []; + + for (let i = 0; i < scenes.length; i++) { + const scene = scenes[i]; + + // Check for duplicate + if (existingTitles.has(scene.title)) { + logCallback(`⊘ Skipped duplicate: ${scene.title}`); + progressCallback({ + current: uniquePerformers.length + i + 1 + }); + continue; + } + + try { + // Extract performers as staff + const staff = scene.performers && Array.isArray(scene.performers) + ? scene.performers.map(p => ({ + name: p.name, + role: 'Actor', + photo: p.image_path || null, + characterName: p.name, + characterImage: p.image_path || null + })) + : []; + + // Parse date + const year = scene.date ? new Date(scene.date).getFullYear() : new Date().getFullYear(); + const releaseDate = scene.date || null; + + // Determine aspect ratio from file dimensions + let aspectRatio: '2/3' | '16/9' | '1/1' = '16/9'; + if (scene.files && scene.files.length > 0) { + const file = scene.files[0]; + if (file.width && file.height) { + const ratio = file.width / file.height; + if (ratio > 1.6) { + aspectRatio = '16/9'; + } else if (ratio < 1.4 && ratio > 0.8) { + aspectRatio = '1/1'; + } else if (ratio < 0.8) { + aspectRatio = '2/3'; + } + } + } + + // Get duration from files + const runtime = scene.files && scene.files.length > 0 ? scene.files[0].duration : null; + + // Convert rating100 to 5-star scale + const rating = scene.rating100 ? scene.rating100 / 20 : null; + + const mediaData = { + title: scene.title, + year: year.toString(), + poster: scene.paths?.screenshot || null, + banner: null, + description: scene.details || null, + rating: rating, + category: 'Adult', + type: 'Movie', + status: 'completed', + aspectRatio: aspectRatio, + runtime: runtime, + director: null, + writer: null, + releaseDate: releaseDate, + genres: [], + tags: [], + studios: [], + 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) { + scenesImported++; + logCallback(`✓ Imported scene: ${scene.title}`); + } else { + const error = await response.text(); + sceneErrors.push(`Failed to import scene ${scene.title}: ${error}`); + logCallback(`✗ Failed to import scene: ${scene.title}`); + } + } catch (error) { + sceneErrors.push(`Error importing scene ${scene.title}: ${error}`); + logCallback(`✗ Error importing scene: ${scene.title}`); + } + + progressCallback({ + current: uniquePerformers.length + i + 1, + videosImported: scenesImported, + errors: [...performerErrors, ...sceneErrors] + }); + } + + logCallback(`Imported ${scenesImported}/${scenes.length} scenes`); + + // Complete + progress.stage = 'complete'; + progress.message = 'Import complete!'; + progress.current = uniquePerformers.length + scenes.length; + progress.total = uniquePerformers.length + scenes.length; + progress.videosImported = scenesImported; + progress.actorsImported = performersImported; + progress.errors = [...performerErrors, ...sceneErrors]; + 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; + } +} diff --git a/src/lib/xbvrImporter.ts b/src/lib/xbvrImporter.ts new file mode 100644 index 0000000..e955a3b --- /dev/null +++ b/src/lib/xbvrImporter.ts @@ -0,0 +1,363 @@ +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; + } +}