imports :)

This commit is contained in:
Lars Behrends
2026-04-09 17:13:04 +02:00
parent 6d5397505a
commit 1caadd12e1
6 changed files with 1579 additions and 16 deletions

363
src/lib/xbvrImporter.ts Normal file
View File

@@ -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<ImportProgress>) => void;
export async function importFromXBVR(
config: XBVRConfig,
logCallback: LogCallback,
progressCallback: ProgressCallback
): Promise<ImportProgress> {
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<number, any>();
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;
}
}