imports :)
This commit is contained in:
13
src/App.tsx
13
src/App.tsx
@@ -11,12 +11,13 @@ import DetailView from './components/DetailView';
|
|||||||
import CastView from './components/CastView';
|
import CastView from './components/CastView';
|
||||||
import CastDetailView from './components/CastDetailView';
|
import CastDetailView from './components/CastDetailView';
|
||||||
import AddMediaView from './components/AddMediaView';
|
import AddMediaView from './components/AddMediaView';
|
||||||
|
import ImporterView from './components/ImporterView';
|
||||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||||
import { Media, Staff, MediaCategory } from './types';
|
import { Media, Staff, MediaCategory } from './types';
|
||||||
import { fetchAllMedia, fetchMediaById } from './api';
|
import { fetchAllMedia, fetchMediaById } from './api';
|
||||||
|
|
||||||
export default function App() {
|
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<MediaCategory>('Anime');
|
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||||
@@ -67,6 +68,11 @@ export default function App() {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImporterView = () => {
|
||||||
|
setCurrentView('import');
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
const allMedia = useMemo(() => {
|
const allMedia = useMemo(() => {
|
||||||
// Use API data if available, otherwise fall back to mock data
|
// Use API data if available, otherwise fall back to mock data
|
||||||
let list: Media[] = [];
|
let list: Media[] = [];
|
||||||
@@ -203,6 +209,7 @@ export default function App() {
|
|||||||
onBrowse={handleBack}
|
onBrowse={handleBack}
|
||||||
onCast={handleCastClick}
|
onCast={handleCastClick}
|
||||||
onAddMedia={handleAddMediaView}
|
onAddMedia={handleAddMediaView}
|
||||||
|
onImporter={handleImporterView}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
@@ -242,6 +249,10 @@ export default function App() {
|
|||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onAddComplete={handleAddMedia}
|
onAddComplete={handleAddMedia}
|
||||||
/>
|
/>
|
||||||
|
) : currentView === 'import' ? (
|
||||||
|
<ImporterView
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
selectedMedia && (
|
selectedMedia && (
|
||||||
<DetailView
|
<DetailView
|
||||||
|
|||||||
10
src/api.ts
10
src/api.ts
@@ -151,10 +151,12 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||||
if (apiItem.aspectRatio) {
|
if (apiItem.aspectRatio) {
|
||||||
const ratio = apiItem.aspectRatio.toLowerCase();
|
const ratio = apiItem.aspectRatio.toLowerCase();
|
||||||
if (ratio.includes('16:9') || ratio.includes('1.78') || ratio.includes('2.39')) {
|
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
|
||||||
aspectRatio = '16/9';
|
aspectRatio = '16/9';
|
||||||
} else if (ratio.includes('1:1') || ratio.includes('1.00')) {
|
} else if (ratio.includes('1:1') || ratio.includes('1/1') || ratio.includes('1.00')) {
|
||||||
aspectRatio = '1/1';
|
aspectRatio = '1/1';
|
||||||
|
} else if (ratio.includes('2/3')) {
|
||||||
|
aspectRatio = '2/3';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Media API Functions
|
// Media API Functions
|
||||||
export async function fetchAllMedia(page: number = 1, limit: number = 50): Promise<Media[]> {
|
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
|
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -342,7 +344,7 @@ export async function deleteMedia(id: number | string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cast API Functions
|
// Cast API Functions
|
||||||
export async function fetchAllCast(page: number = 1, limit: number = 50): Promise<ApiCastItem[]> {
|
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<ApiCastItem[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
|
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -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 { cn } from '@/lib/utils';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { MediaCategory } from '@/types';
|
import { MediaCategory } from '@/types';
|
||||||
@@ -8,6 +8,7 @@ interface HeaderProps {
|
|||||||
onBrowse: () => void;
|
onBrowse: () => void;
|
||||||
onCast: () => void;
|
onCast: () => void;
|
||||||
onAddMedia: () => void;
|
onAddMedia: () => void;
|
||||||
|
onImporter: () => void;
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
activeCategory: MediaCategory;
|
activeCategory: MediaCategory;
|
||||||
onCategoryChange: (category: MediaCategory) => void;
|
onCategoryChange: (category: MediaCategory) => void;
|
||||||
@@ -20,6 +21,7 @@ export default function Header({
|
|||||||
onBrowse,
|
onBrowse,
|
||||||
onCast,
|
onCast,
|
||||||
onAddMedia,
|
onAddMedia,
|
||||||
|
onImporter,
|
||||||
onSearch,
|
onSearch,
|
||||||
activeCategory,
|
activeCategory,
|
||||||
onCategoryChange,
|
onCategoryChange,
|
||||||
@@ -109,6 +111,12 @@ export default function Header({
|
|||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onImporter}
|
||||||
|
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={20} />
|
||||||
|
</button>
|
||||||
<LibrarySettings
|
<LibrarySettings
|
||||||
enabledCategories={enabledCategories}
|
enabledCategories={enabledCategories}
|
||||||
onToggleCategory={onToggleCategory}
|
onToggleCategory={onToggleCategory}
|
||||||
|
|||||||
444
src/components/ImporterView.tsx
Normal file
444
src/components/ImporterView.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Download, Settings, RefreshCw, CheckCircle, XCircle, AlertCircle, Users, Film, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
||||||
|
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||||
|
|
||||||
|
export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||||
|
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: 'http://192.168.1.102:4080' });
|
||||||
|
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({ url: 'http://192.168.1.102:10001' });
|
||||||
|
const [progress, setProgress] = useState<ImportProgress>({
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
stage: 'idle',
|
||||||
|
message: '',
|
||||||
|
videosImported: 0,
|
||||||
|
actorsImported: 0,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
const [importLog, setImportLog] = useState<string[]>([]);
|
||||||
|
const logContainerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div className="pt-24 pb-12 px-6 max-w-[1600px] mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-zinc-600 hover:text-[#6d28d9]"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-zinc-900">Media Importers</h1>
|
||||||
|
<p className="text-sm text-zinc-500 font-medium">Import media from external platforms</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Importer Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
{/* XBVR Importer Card */}
|
||||||
|
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Film className="text-purple-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-zinc-900">XBVR</h3>
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">Adult Video Manager</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 border-zinc-200"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-600 mb-4">
|
||||||
|
Import adult videos and actors from your XBVR database.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">XBVR URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={xbvrConfig.url}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleXBVRImport}
|
||||||
|
disabled={progress.stage !== 'idle' || !xbvrConfig.url}
|
||||||
|
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-bold"
|
||||||
|
>
|
||||||
|
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Import from XBVR
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* StashAPP Importer Card */}
|
||||||
|
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Film className="text-blue-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-zinc-900">StashAPP</h3>
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">Adult Content Manager</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 border-zinc-200"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-600 mb-4">
|
||||||
|
Import adult videos and performers from your StashAPP database.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">StashAPP URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stashappConfig.url}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={stashappConfig.apiKey || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleStashAPPImport}
|
||||||
|
disabled={progress.stage !== 'idle' || !stashappConfig.url}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Import from StashAPP
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* StashAPP Actor Updater Card */}
|
||||||
|
<div className="bg-white border border-zinc-200 rounded-xl p-6 hover:border-[#6d28d9]/50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="text-green-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-zinc-900">StashAPP Actor Updater</h3>
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">Update existing actors</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 border-zinc-200"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-600 mb-4">
|
||||||
|
Update existing actors with fresh data from StashAPP and create missing ones.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Key (optional)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={stashappConfig.apiKey || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleStashAPPActorUpdate}
|
||||||
|
disabled={progress.stage !== 'idle' || !stashappConfig.url}
|
||||||
|
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold"
|
||||||
|
>
|
||||||
|
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
||||||
|
Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="mr-2" />
|
||||||
|
Update Actors
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder for future importers */}
|
||||||
|
<div className="bg-zinc-50 border border-zinc-200 border-dashed rounded-xl p-6 flex flex-col items-center justify-center text-zinc-400">
|
||||||
|
<Download size={32} className="mb-2" />
|
||||||
|
<p className="text-sm font-medium">More importers coming soon</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Section */}
|
||||||
|
{progress.stage !== 'idle' && (
|
||||||
|
<div className="bg-white border border-zinc-200 rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{progress.stage === 'complete' ? (
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle className="text-green-600" size={20} />
|
||||||
|
</div>
|
||||||
|
) : progress.stage === 'error' ? (
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<XCircle className="text-red-600" size={20} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Loader2 className="text-purple-600 animate-spin" size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-zinc-900">{progress.message}</h3>
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">
|
||||||
|
{progress.stage === 'fetching' && 'Connecting to external service...'}
|
||||||
|
{progress.stage === 'importing' && `Processing items... ${getProgressPercentage()}%`}
|
||||||
|
{progress.stage === 'complete' && 'Import finished'}
|
||||||
|
{progress.stage === 'error' && 'An error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{progress.stage === 'complete' || progress.stage === 'error' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={resetImport}
|
||||||
|
className="gap-2 font-bold border-zinc-200"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{progress.stage === 'fetching' || progress.stage === 'importing' ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="h-2 bg-zinc-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-300 ease-out",
|
||||||
|
progress.stage === 'error' ? "bg-red-500" : "bg-[#6d28d9]"
|
||||||
|
)}
|
||||||
|
style={{ width: `${getProgressPercentage()}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-xs text-zinc-500 font-medium">
|
||||||
|
<span>{progress.current} / {progress.total} items</span>
|
||||||
|
<span>{getProgressPercentage()}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div className="bg-zinc-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Film size={16} className="text-zinc-400" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500">Videos</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-zinc-900">{progress.videosImported}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users size={16} className="text-zinc-400" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500">Actors</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-zinc-900">{progress.actorsImported}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AlertCircle size={16} className="text-zinc-400" />
|
||||||
|
<span className="text-xs font-bold text-zinc-500">Errors</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-zinc-900">{progress.errors.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log */}
|
||||||
|
{importLog.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={logContainerRef}
|
||||||
|
className="bg-zinc-900 rounded-lg p-4 max-h-64 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">
|
||||||
|
{importLog.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
{progress.errors.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-bold text-red-600 mb-2">Errors</h4>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||||
|
{progress.errors.map((error, index) => (
|
||||||
|
<p key={index} className="text-xs text-red-700 font-medium mb-1">
|
||||||
|
• {error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
735
src/lib/stashappImporter.ts
Normal file
735
src/lib/stashappImporter.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
363
src/lib/xbvrImporter.ts
Normal file
363
src/lib/xbvrImporter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user