e5cdd6b383
Rename project branding from "Kyoo" to "Omnyx" across README, index.html, metadata.json, typedoc and various UI components. Add support for page-level settings: pageTitle, favicon (Base64 upload/preview), and customColors (color scheme) — introduced CustomColors type, persisted via API types and converters, and wired into updateSettings/fetchSettings flows. UI: SettingsView adds page settings UI (upload, preview, color pickers) and handlers; App applies pageTitle, favicon and sets CSS variables for customColors; Sidebar and Header now display the configured page title. Also update importer modules and docs to use the new project name in logs/comments.
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
/**
|
|
* 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<ImportProgress>) => 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<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 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<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(`${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;
|
|
}
|
|
}
|