imports :)
This commit is contained in:
@@ -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<ImportProgress>) => void;
|
||||
|
||||
export async function updateActorsFromStashAPP(
|
||||
config: StashAPPConfig,
|
||||
logCallback: LogCallback,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
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<ImportProgress> {
|
||||
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<string, StashAPPScenePerformer>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user