Compare commits
6 Commits
dda118a2f7
...
6438a23301
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6438a23301 | ||
|
|
d6ad4c80b3 | ||
|
|
73c578f1ec | ||
|
|
1caadd12e1 | ||
|
|
6d5397505a | ||
|
|
d6a0aac5f7 |
13
.env.example
13
.env.example
@@ -2,3 +2,16 @@
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
|
||||
# Importer Configurations
|
||||
# XBVR Importer
|
||||
VITE_XBVR_URL=""
|
||||
|
||||
# StashAPP Importer
|
||||
VITE_STASHAPP_URL=""
|
||||
VITE_STASHAPP_API_KEY=""
|
||||
|
||||
# Playnite Importer
|
||||
VITE_PLAYNITE_IP="localhost"
|
||||
VITE_PLAYNITE_PORT="19821"
|
||||
VITE_PLAYNITE_API_TOKEN=""
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
23
api_examples/create_adult_cast.json
Normal file
23
api_examples/create_adult_cast.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Jane Smith",
|
||||
"photo": "https://example.com/jane-smith.jpg",
|
||||
"bio": "Adult film actress and model",
|
||||
"birthDate": "1998-03-20",
|
||||
"birthPlace": "Miami, Florida",
|
||||
"occupations": ["Actress", "Model"],
|
||||
"adult_specifics": {
|
||||
"bust_size": "36",
|
||||
"cup_size": "DD",
|
||||
"waist_size": "26",
|
||||
"hip_size": "38",
|
||||
"height": "170",
|
||||
"weight": "58",
|
||||
"hair_color": "Brunette",
|
||||
"eye_color": "Green",
|
||||
"ethnicity": "Latina",
|
||||
"tattoos": "Lower back",
|
||||
"piercings": "None",
|
||||
"measurements": "36-26-38",
|
||||
"shoe_size": "8"
|
||||
}
|
||||
}
|
||||
28
api_examples/create_album.json
Normal file
28
api_examples/create_album.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Thriller",
|
||||
"year": 1982,
|
||||
"poster": "https://example.com/thriller-cover.jpg",
|
||||
"description": "Sixth studio album by Michael Jackson",
|
||||
"rating": 9.0,
|
||||
"category": "Music",
|
||||
"type": "Album",
|
||||
"status": "Released",
|
||||
"genres": ["Pop", "Funk", "Rock"],
|
||||
"tags": ["Classic", "Best-selling"],
|
||||
"studios": ["Epic Records"],
|
||||
"staff": [],
|
||||
"tracks": [
|
||||
{
|
||||
"track_number": 1,
|
||||
"title": "Wanna Be Startin' Somethin'",
|
||||
"duration": "6:03",
|
||||
"artist": "Michael Jackson"
|
||||
},
|
||||
{
|
||||
"track_number": 2,
|
||||
"title": "Baby Be Mine",
|
||||
"duration": "4:20",
|
||||
"artist": "Michael Jackson"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
api_examples/create_cast.json
Normal file
8
api_examples/create_cast.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Tom Hardy",
|
||||
"photo": "https://example.com/tom.jpg",
|
||||
"bio": "English actor known for versatile roles",
|
||||
"birthDate": "1977-09-15",
|
||||
"birthPlace": "Hammersmith, London, England",
|
||||
"occupations": ["Actor", "Producer", "Writer"]
|
||||
}
|
||||
9
api_examples/create_episode.json
Normal file
9
api_examples/create_episode.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"season": 1,
|
||||
"episode_number": 3,
|
||||
"title": "...And the Bag's in the River",
|
||||
"description": "Walter and Jesse deal with the aftermath.",
|
||||
"air_date": "2008-02-03",
|
||||
"duration": 47,
|
||||
"thumbnail": "https://example.com/ep3.jpg"
|
||||
}
|
||||
32
api_examples/create_movie.json
Normal file
32
api_examples/create_movie.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"title": "The Matrix",
|
||||
"year": 1999,
|
||||
"poster": "https://example.com/matrix-poster.jpg",
|
||||
"banner": "https://example.com/matrix-banner.jpg",
|
||||
"description": "A computer hacker learns about the true nature of reality.",
|
||||
"rating": 8.7,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 136,
|
||||
"director": "The Wachowskis",
|
||||
"writer": "The Wachowskis",
|
||||
"releaseDate": "1999-03-31",
|
||||
"genres": ["Sci-Fi", "Action"],
|
||||
"tags": ["Cyberpunk", "AI", "Simulation"],
|
||||
"studios": ["Warner Bros."],
|
||||
"staff": [
|
||||
{
|
||||
"name": "Keanu Reeves",
|
||||
"photo": "https://example.com/keanu.jpg",
|
||||
"bio": "Canadian actor",
|
||||
"birthDate": "1964-09-02",
|
||||
"birthPlace": "Beirut, Lebanon",
|
||||
"role": "Actor",
|
||||
"characterName": "Neo",
|
||||
"characterImage": null,
|
||||
"occupations": ["Actor"]
|
||||
}
|
||||
]
|
||||
}
|
||||
6
api_examples/create_track.json
Normal file
6
api_examples/create_track.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"track_number": 3,
|
||||
"title": "On the Run",
|
||||
"duration": "3:35",
|
||||
"artist": "Pink Floyd"
|
||||
}
|
||||
29
api_examples/create_tv.json
Normal file
29
api_examples/create_tv.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"title": "Stranger Things",
|
||||
"year": 2016,
|
||||
"poster": "https://example.com/st-poster.jpg",
|
||||
"description": "When a young boy disappears, his mother uncovers a mystery.",
|
||||
"rating": 8.7,
|
||||
"category": "TV",
|
||||
"type": "TV",
|
||||
"status": "Ongoing",
|
||||
"runtime": 50,
|
||||
"director": "The Duffer Brothers",
|
||||
"writer": "The Duffer Brothers",
|
||||
"releaseDate": "2016-07-15",
|
||||
"genres": ["Sci-Fi", "Horror", "Drama"],
|
||||
"tags": ["80s", "Supernatural", "Government Conspiracy"],
|
||||
"studios": ["Netflix"],
|
||||
"staff": [],
|
||||
"episodes": [
|
||||
{
|
||||
"season": 1,
|
||||
"episode_number": 1,
|
||||
"title": "Chapter One: The Vanishing of Will Byers",
|
||||
"description": "On his way home from a friend's house, young Will sees something terrifying.",
|
||||
"air_date": "2016-07-15",
|
||||
"duration": 47,
|
||||
"thumbnail": "https://example.com/st-ep1.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
api_examples/get_adult_cast.json
Normal file
30
api_examples/get_adult_cast.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Jane Doe",
|
||||
"photo": "https://example.com/jane.jpg",
|
||||
"bio": "Adult film actress",
|
||||
"birthDate": "1995-05-15",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actress"],
|
||||
"bust_size": "34",
|
||||
"cup_size": "D",
|
||||
"waist_size": "24",
|
||||
"hip_size": "34",
|
||||
"height": "165",
|
||||
"weight": "52",
|
||||
"hair_color": "Blonde",
|
||||
"eye_color": "Blue",
|
||||
"ethnicity": "Caucasian"
|
||||
}
|
||||
],
|
||||
"total": 25,
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
32
api_examples/get_adult_cast_single.json
Normal file
32
api_examples/get_adult_cast_single.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 10,
|
||||
"name": "Jane Doe",
|
||||
"photo": "https://example.com/jane.jpg",
|
||||
"bio": "Adult film actress",
|
||||
"birthDate": "1995-05-15",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actress"],
|
||||
"filmography": [],
|
||||
"adult_specifics": {
|
||||
"id": 5,
|
||||
"cast_id": 10,
|
||||
"bust_size": "34",
|
||||
"cup_size": "D",
|
||||
"waist_size": "24",
|
||||
"hip_size": "34",
|
||||
"height": "165",
|
||||
"weight": "52",
|
||||
"hair_color": "Blonde",
|
||||
"eye_color": "Blue",
|
||||
"ethnicity": "Caucasian",
|
||||
"tattoos": "None",
|
||||
"piercings": "Ears",
|
||||
"measurements": "34-24-34",
|
||||
"shoe_size": "7"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
api_examples/get_cast.json
Normal file
20
api_examples/get_cast.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
27
api_examples/get_cast_media.json
Normal file
27
api_examples/get_cast_media.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Revenant",
|
||||
"year": 2015,
|
||||
"poster": "https://example.com/revenant.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Hugh Glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
36
api_examples/get_cast_single.json
Normal file
36
api_examples/get_cast_single.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"occupations": ["Actor", "Producer"],
|
||||
"filmography": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Revenant",
|
||||
"year": 2015,
|
||||
"poster": "https://example.com/revenant.jpg",
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"role": "Actor",
|
||||
"characterName": "Hugh Glass"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
api_examples/get_episodes.json
Normal file
29
api_examples/get_episodes.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_id": 2,
|
||||
"season": 1,
|
||||
"episode_number": 1,
|
||||
"title": "Pilot",
|
||||
"description": "Walter White is diagnosed with lung cancer.",
|
||||
"air_date": "2008-01-20",
|
||||
"duration": 49,
|
||||
"thumbnail": "https://example.com/ep1.jpg"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_id": 2,
|
||||
"season": 1,
|
||||
"episode_number": 2,
|
||||
"title": "Cat's in the Bag...",
|
||||
"description": "Walter and Jesse attempt to dispose of the body.",
|
||||
"air_date": "2008-01-27",
|
||||
"duration": 48,
|
||||
"thumbnail": "https://example.com/ep2.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
api_examples/get_media.json
Normal file
30
api_examples/get_media.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"banner": null,
|
||||
"description": "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
"rating": 8.8,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 148,
|
||||
"director": "Christopher Nolan",
|
||||
"writer": "Christopher Nolan",
|
||||
"releaseDate": "2010-07-16",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalPages": 15
|
||||
}
|
||||
}
|
||||
39
api_examples/get_media_single.json
Normal file
39
api_examples/get_media_single.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"title": "Inception",
|
||||
"year": 2010,
|
||||
"poster": "https://example.com/poster.jpg",
|
||||
"banner": null,
|
||||
"description": "A thief who steals corporate secrets through dream-sharing technology.",
|
||||
"rating": 8.8,
|
||||
"category": "Movie",
|
||||
"type": "Movie",
|
||||
"status": "Released",
|
||||
"aspectRatio": "2.39:1",
|
||||
"runtime": 148,
|
||||
"director": "Christopher Nolan",
|
||||
"writer": "Christopher Nolan",
|
||||
"releaseDate": "2010-07-16",
|
||||
"createdAt": "2024-01-15 10:30:00",
|
||||
"updatedAt": "2024-01-15 10:30:00",
|
||||
"genres": ["Sci-Fi", "Action", "Thriller"],
|
||||
"tags": ["Mind-bending", "Dream", "Heist"],
|
||||
"studios": ["Warner Bros.", "Legendary Pictures"],
|
||||
"staff": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Leonardo DiCaprio",
|
||||
"photo": "https://example.com/leo.jpg",
|
||||
"bio": "American actor and film producer",
|
||||
"birthDate": "1974-11-11",
|
||||
"birthPlace": "Los Angeles, California",
|
||||
"role": "Actor",
|
||||
"characterName": "Dom Cobb",
|
||||
"characterImage": null,
|
||||
"occupations": ["Actor", "Producer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
api_examples/get_tracks.json
Normal file
23
api_examples/get_tracks.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"media_id": 3,
|
||||
"track_number": 1,
|
||||
"title": "Speak to Me",
|
||||
"duration": "1:30",
|
||||
"artist": "Pink Floyd"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"media_id": 3,
|
||||
"track_number": 2,
|
||||
"title": "Breathe",
|
||||
"duration": "2:43",
|
||||
"artist": "Pink Floyd"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
api_examples/update_adult_cast.json
Normal file
8
api_examples/update_adult_cast.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Jane Smith (Updated)",
|
||||
"bio": "Updated bio",
|
||||
"adult_specifics": {
|
||||
"hair_color": "Red",
|
||||
"weight": "56"
|
||||
}
|
||||
}
|
||||
4
api_examples/update_cast.json
Normal file
4
api_examples/update_cast.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Tom Hardy (Updated)",
|
||||
"bio": "Updated bio description"
|
||||
}
|
||||
4
api_examples/update_episode.json
Normal file
4
api_examples/update_episode.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Updated Episode Title",
|
||||
"description": "Updated description"
|
||||
}
|
||||
32
api_examples/update_game.json
Normal file
32
api_examples/update_game.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"type": "Game",
|
||||
"title": "1-2-Switch",
|
||||
"playtime": 120,
|
||||
"completionStatus": "Completed",
|
||||
"favorite": true,
|
||||
"communityScore": 55,
|
||||
"userScore": 80,
|
||||
"achievements": [
|
||||
{
|
||||
"name": "First Victory",
|
||||
"description": "Win your first game",
|
||||
"icon": "https://example.com/achievement-icon.png",
|
||||
"unlocked": true,
|
||||
"unlocked_date": "2026-04-09T18:00:00"
|
||||
},
|
||||
{
|
||||
"name": "Master Player",
|
||||
"description": "Win 100 games",
|
||||
"icon": "https://example.com/master-icon.png",
|
||||
"unlocked": true,
|
||||
"unlocked_date": "2026-04-09T20:30:00"
|
||||
},
|
||||
{
|
||||
"name": "Champion",
|
||||
"description": "Win 1000 games",
|
||||
"icon": "https://example.com/champion-icon.png",
|
||||
"unlocked": false,
|
||||
"unlocked_date": null
|
||||
}
|
||||
]
|
||||
}
|
||||
5
api_examples/update_media.json
Normal file
5
api_examples/update_media.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "The Matrix (Updated)",
|
||||
"rating": 8.8,
|
||||
"status": "Released"
|
||||
}
|
||||
4
api_examples/update_track.json
Normal file
4
api_examples/update_track.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Updated Track Title",
|
||||
"duration": "4:00"
|
||||
}
|
||||
3752
public/adult.json
3752
public/adult.json
File diff suppressed because one or more lines are too long
92
src/App.tsx
92
src/App.tsx
@@ -10,33 +10,35 @@ import BrowseView from './components/BrowseView';
|
||||
import DetailView from './components/DetailView';
|
||||
import CastView from './components/CastView';
|
||||
import CastDetailView from './components/CastDetailView';
|
||||
import AddMediaView from './components/AddMediaView';
|
||||
import ImporterView from './components/ImporterView';
|
||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||
import { Media, Staff, MediaCategory } from './types';
|
||||
import { fetchMediaFromLocalJson, fetchMediaById } from './api';
|
||||
import { fetchAllMedia, fetchMediaById } from './api';
|
||||
|
||||
export default function App() {
|
||||
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
|
||||
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail' | 'add' | 'import'>('browse');
|
||||
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||
|
||||
// Load adult media on component mount
|
||||
// Load media from API on component mount
|
||||
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAdultMedia = async () => {
|
||||
const loadMediaFromApi = async () => {
|
||||
try {
|
||||
const media = await fetchMediaFromLocalJson();
|
||||
// Add category to adult media
|
||||
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
|
||||
setAdultMedia(categorizedMedia);
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to load adult media:', error);
|
||||
console.error('Failed to load media from API:', error);
|
||||
}
|
||||
};
|
||||
loadAdultMedia();
|
||||
loadMediaFromApi();
|
||||
}, []);
|
||||
|
||||
const toggleCategory = (category: MediaCategory) => {
|
||||
@@ -61,24 +63,63 @@ export default function App() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleAddMediaView = () => {
|
||||
setCurrentView('add');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleImporterView = () => {
|
||||
setCurrentView('import');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const allMedia = useMemo(() => {
|
||||
// Merge mock media, adult media, detail media and custom media
|
||||
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let list: Media[] = [];
|
||||
|
||||
if (apiMedia.length > 0) {
|
||||
// API has data, use it
|
||||
list = [...apiMedia];
|
||||
} else {
|
||||
// API is empty, use mock data as fallback
|
||||
list = [...MOCK_MEDIA];
|
||||
}
|
||||
|
||||
// Add custom media and detail media
|
||||
list = [...list, ...customMedia];
|
||||
if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
list.push(DETAIL_MEDIA);
|
||||
}
|
||||
|
||||
// Filter by active category AND ensure it's enabled
|
||||
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
|
||||
}, [activeCategory, enabledCategories, customMedia, adultMedia]);
|
||||
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
|
||||
|
||||
const handleAddMedia = (newMedia: Media) => {
|
||||
setCustomMedia(prev => [...prev, newMedia]);
|
||||
const handleAddMedia = async () => {
|
||||
// Reload all media from API to get the newly added item
|
||||
try {
|
||||
const media = await fetchAllMedia();
|
||||
setApiMedia(media);
|
||||
} catch (error) {
|
||||
console.error('Failed to reload media from API:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const allStaff = useMemo(() => {
|
||||
const staff: Staff[] = [];
|
||||
// Use all available media (mock + adult + custom + detail) but filter by enabled categories
|
||||
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
|
||||
// Use API data if available, otherwise fall back to mock data
|
||||
let baseList: Media[] = [];
|
||||
|
||||
if (apiMedia.length > 0) {
|
||||
// API has data, use it
|
||||
baseList = [...apiMedia];
|
||||
} else {
|
||||
// API is empty, use mock data as fallback
|
||||
baseList = [...MOCK_MEDIA];
|
||||
}
|
||||
|
||||
// Add custom media and detail media
|
||||
baseList = [...baseList, ...customMedia];
|
||||
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
|
||||
baseList.push(DETAIL_MEDIA);
|
||||
}
|
||||
@@ -95,7 +136,7 @@ export default function App() {
|
||||
});
|
||||
});
|
||||
return staff;
|
||||
}, [enabledCategories, customMedia, adultMedia]);
|
||||
}, [enabledCategories, customMedia, apiMedia]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allMedia;
|
||||
@@ -167,6 +208,8 @@ export default function App() {
|
||||
<Header
|
||||
onBrowse={handleBack}
|
||||
onCast={handleCastClick}
|
||||
onAddMedia={handleAddMediaView}
|
||||
onImporter={handleImporterView}
|
||||
onSearch={handleSearch}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
@@ -181,7 +224,6 @@ export default function App() {
|
||||
<BrowseView
|
||||
mediaList={filteredMedia}
|
||||
onMediaClick={handleMediaClick}
|
||||
onAddMedia={handleAddMedia}
|
||||
activeCategory={activeCategory}
|
||||
/>
|
||||
) : currentView === 'cast' ? (
|
||||
@@ -201,6 +243,16 @@ export default function App() {
|
||||
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
|
||||
/>
|
||||
)
|
||||
) : currentView === 'add' ? (
|
||||
<AddMediaView
|
||||
activeCategory={activeCategory}
|
||||
onBack={handleBack}
|
||||
onAddComplete={handleAddMedia}
|
||||
/>
|
||||
) : currentView === 'import' ? (
|
||||
<ImporterView
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : (
|
||||
selectedMedia && (
|
||||
<DetailView
|
||||
|
||||
638
src/api.ts
638
src/api.ts
@@ -1,6 +1,6 @@
|
||||
import { Media, Staff } from './types';
|
||||
|
||||
const BASE_URL = 'http://192.168.1.102:57000';
|
||||
const BASE_URL = 'http://192.168.1.102:6400';
|
||||
|
||||
function normalizeUrl(url: string | null): string {
|
||||
if (!url) return '';
|
||||
@@ -12,137 +12,260 @@ function normalizeUrl(url: string | null): string {
|
||||
return `${BASE_URL}/${cleanPath}`;
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: {
|
||||
items: ApiMediaItem[];
|
||||
};
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
// Media Types
|
||||
export interface ApiMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
overview: string;
|
||||
poster_url: string;
|
||||
poster_aspect_ratio: string | null;
|
||||
backdrop_url: string | null;
|
||||
backdrop_aspect_ratio: string | null;
|
||||
rating: string;
|
||||
runtime_minutes: number;
|
||||
release_date: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
rating: number | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
aspectRatio: string | null;
|
||||
runtime: number | null;
|
||||
director: string | null;
|
||||
writer: string | null;
|
||||
cast: string | null;
|
||||
genre: string | null;
|
||||
metadata: string;
|
||||
actors?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail_path: string | null;
|
||||
metadata?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
releaseDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: ApiStaff[];
|
||||
categories?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
source?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
}
|
||||
|
||||
export interface ApiMetadata {
|
||||
xbvr_id: number;
|
||||
xbvr_url: string | null;
|
||||
cast: string[];
|
||||
actors: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail_path: string | null;
|
||||
}>;
|
||||
tags: string[];
|
||||
is_available: boolean;
|
||||
is_watched: boolean;
|
||||
watch_count: number;
|
||||
video_length: number;
|
||||
video_width: number | null;
|
||||
video_height: number | null;
|
||||
video_codec: string | null;
|
||||
file_path: string | null;
|
||||
cover_url: string;
|
||||
[key: string]: any;
|
||||
export interface ApiStaff {
|
||||
id: number;
|
||||
name: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
role: string;
|
||||
characterName: string | null;
|
||||
characterImage: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface CreateMediaInput {
|
||||
title: string;
|
||||
year: number;
|
||||
poster?: string | null;
|
||||
banner?: string | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
category?: string | null;
|
||||
type?: string;
|
||||
status?: string;
|
||||
aspectRatio?: string | null;
|
||||
runtime?: number | null;
|
||||
director?: string | null;
|
||||
writer?: string | null;
|
||||
releaseDate?: string | null;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
studios?: string[];
|
||||
staff?: CreateStaffInput[];
|
||||
}
|
||||
|
||||
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
|
||||
|
||||
export interface CreateStaffInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
role: string;
|
||||
characterName?: string | null;
|
||||
characterImage?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
// Cast Types
|
||||
export interface ApiCastItem {
|
||||
id: number;
|
||||
name: string;
|
||||
photo: string | null;
|
||||
bio: string | null;
|
||||
birthDate: string | null;
|
||||
birthPlace: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
occupations?: string[];
|
||||
filmography?: ApiCastMediaItem[];
|
||||
}
|
||||
|
||||
export interface ApiCastMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
poster: string | null;
|
||||
category: string | null;
|
||||
type: string;
|
||||
role: string;
|
||||
characterName: string | null;
|
||||
}
|
||||
|
||||
export interface CreateCastInput {
|
||||
name: string;
|
||||
photo?: string | null;
|
||||
bio?: string | null;
|
||||
birthDate?: string | null;
|
||||
birthPlace?: string | null;
|
||||
occupations?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCastInput extends Partial<CreateCastInput> {}
|
||||
|
||||
|
||||
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
|
||||
let metadata: ApiMetadata;
|
||||
try {
|
||||
metadata = JSON.parse(apiItem.metadata);
|
||||
} catch (e) {
|
||||
metadata = {
|
||||
xbvr_id: 0,
|
||||
xbvr_url: null,
|
||||
cast: [],
|
||||
actors: [],
|
||||
tags: [],
|
||||
is_available: false,
|
||||
is_watched: false,
|
||||
watch_count: 0,
|
||||
video_length: 0,
|
||||
video_width: null,
|
||||
video_height: null,
|
||||
video_codec: null,
|
||||
file_path: null,
|
||||
cover_url: apiItem.poster_url,
|
||||
};
|
||||
}
|
||||
|
||||
// Use actors from the main item if available, otherwise from metadata
|
||||
const actors = apiItem.actors || metadata.actors || [];
|
||||
const staff: Staff[] = actors.map((actor, index) => ({
|
||||
id: `actor-${actor.id}`,
|
||||
name: actor.name,
|
||||
role: 'Actor',
|
||||
photo: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
|
||||
characterName: actor.name,
|
||||
characterImage: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
|
||||
// Convert staff from API to Media staff format
|
||||
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
|
||||
id: staffMember.id.toString(),
|
||||
name: staffMember.name,
|
||||
role: staffMember.role,
|
||||
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
characterName: staffMember.characterName || staffMember.name,
|
||||
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
|
||||
}));
|
||||
|
||||
|
||||
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
|
||||
// Determine aspect ratio from API format
|
||||
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (apiItem.poster_aspect_ratio) {
|
||||
const ratio = apiItem.poster_aspect_ratio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('1.78')) {
|
||||
if (apiItem.aspectRatio) {
|
||||
const ratio = apiItem.aspectRatio.toLowerCase();
|
||||
if (ratio.includes('16:9') || ratio.includes('16/9') || ratio.includes('1.78') || ratio.includes('2.39')) {
|
||||
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';
|
||||
} else if (ratio.includes('2/3')) {
|
||||
aspectRatio = '2/3';
|
||||
}
|
||||
}
|
||||
|
||||
// Map API type to Media type allowed values
|
||||
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
|
||||
const apiType = apiItem.type?.toLowerCase();
|
||||
if (apiType === 'tv' || apiType === 'episode') {
|
||||
mediaType = 'TV';
|
||||
} else if (apiType === 'album' || apiType === 'single') {
|
||||
mediaType = apiType === 'album' ? 'Album' : 'Single';
|
||||
} else if (apiType === 'game' || apiType === 'console') {
|
||||
mediaType = apiType === 'game' ? 'Game' : 'Console';
|
||||
} else if (apiType === 'ova') {
|
||||
mediaType = 'OVA';
|
||||
} else if (apiType === 'ona') {
|
||||
mediaType = 'ONA';
|
||||
} else if (apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
|
||||
}
|
||||
|
||||
// Map API category to MediaCategory
|
||||
let mediaCategory: 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
|
||||
const apiCategory = apiItem.category?.toLowerCase();
|
||||
|
||||
if (apiCategory === 'anime') {
|
||||
mediaCategory = 'Anime';
|
||||
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
|
||||
mediaCategory = 'Movies';
|
||||
} else if (apiCategory === 'tv' || apiCategory === 'series' || apiCategory === 'tv series' || apiType === 'tv' || apiType === 'episode') {
|
||||
mediaCategory = 'TV Series';
|
||||
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
|
||||
mediaCategory = 'Music';
|
||||
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
|
||||
mediaCategory = 'Books';
|
||||
} else if (apiCategory === 'adult') {
|
||||
mediaCategory = 'Adult';
|
||||
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
|
||||
mediaCategory = 'Consoles';
|
||||
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
|
||||
mediaCategory = 'Games';
|
||||
} else {
|
||||
// If category doesn't match any known category, use the original value capitalized
|
||||
// This handles cases where the API returns unexpected category values
|
||||
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
|
||||
mediaCategory = 'Movies';
|
||||
}
|
||||
|
||||
// Map API status to Media status allowed values
|
||||
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
|
||||
const apiStatus = apiItem.status?.toLowerCase();
|
||||
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
|
||||
mediaStatus = 'watching';
|
||||
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
|
||||
mediaStatus = 'planned';
|
||||
} else if (apiStatus === 'dropped') {
|
||||
mediaStatus = 'dropped';
|
||||
} else if (apiStatus === 'reading') {
|
||||
mediaStatus = 'reading';
|
||||
} else if (apiStatus === 'listening') {
|
||||
mediaStatus = 'listening';
|
||||
} else if (apiStatus === 'playing') {
|
||||
mediaStatus = 'playing';
|
||||
} else if (apiStatus === 'on-hold') {
|
||||
mediaStatus = 'on-hold';
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id.toString() || undefined,
|
||||
title: apiItem.title || undefined,
|
||||
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
banner: normalizeUrl(apiItem.backdrop_url) || undefined,
|
||||
description: apiItem.overview || undefined,
|
||||
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined,
|
||||
genres: metadata.tags || [],
|
||||
tags: metadata.tags || [],
|
||||
studios: apiItem.director ? [apiItem.director] : undefined,
|
||||
type: 'Movie',
|
||||
status: 'completed',
|
||||
id: apiItem.id.toString(),
|
||||
title: apiItem.title,
|
||||
year: apiItem.year?.toString() || 'Unknown',
|
||||
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
|
||||
category: mediaCategory,
|
||||
banner: normalizeUrl(apiItem.banner) || undefined,
|
||||
description: apiItem.description || undefined,
|
||||
rating: apiItem.rating || undefined,
|
||||
genres: apiItem.genres || [],
|
||||
tags: apiItem.tags || [],
|
||||
studios: apiItem.studios,
|
||||
type: mediaType,
|
||||
status: mediaStatus,
|
||||
staff: staff.length > 0 ? staff : undefined,
|
||||
runtime: apiItem.runtime_minutes,
|
||||
director: apiItem.director || undefined,
|
||||
writer: apiItem.writer || undefined,
|
||||
releaseDate: apiItem.release_date || undefined,
|
||||
aspectRatio: aspectRatio
|
||||
aspectRatio: aspectRatio,
|
||||
categories: apiItem.categories,
|
||||
platforms: apiItem.platforms,
|
||||
developers: apiItem.developers,
|
||||
completionStatus: apiItem.completionStatus,
|
||||
source: apiItem.source,
|
||||
playCount: apiItem.playCount,
|
||||
lastActivity: apiItem.lastActivity,
|
||||
playtime: apiItem.playtime
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> {
|
||||
console.error('Error fetching');
|
||||
// Media API Functions
|
||||
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
@@ -154,33 +277,16 @@ export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
export async function fetchMediaById(id: number | string): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media from local JSON:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
export async function fetchMediaById(id: number): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const item = data.data.items.find(item => item.id === id);
|
||||
return item ? convertApiToMedia(item) : null;
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -189,102 +295,268 @@ export async function fetchMediaById(id: number): Promise<Media | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items
|
||||
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase())))
|
||||
.map(convertApiToMedia);
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(media),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiMediaItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return convertApiToMedia(data.data);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating media:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMedia(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting media:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cast API Functions
|
||||
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<ApiCastItem[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items;
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
console.error('Error fetching cast from API:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
return data.data.items
|
||||
.filter(item => {
|
||||
try {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map(convertApiToMedia);
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching cast by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
|
||||
|
||||
if (data.success && data.data.items) {
|
||||
return data.data.items.map(convertApiToMedia);
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by tag:', error);
|
||||
console.error('Error fetching cast media:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> {
|
||||
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
const response = await fetch(`${BASE_URL}/api/cast`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const actorMap = new Map();
|
||||
data.data.items.forEach(item => {
|
||||
item.actors?.forEach(actor => {
|
||||
if (!actorMap.has(actor.id)) {
|
||||
actorMap.set(actor.id, {
|
||||
id: actor.id,
|
||||
name: actor.name,
|
||||
thumbnail_path: actor.thumbnail_path
|
||||
});
|
||||
}
|
||||
});
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error creating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(cast),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<ApiCastItem> = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
return data.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error updating cast:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCast(id: number | string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse<{ message: string }> = await response.json();
|
||||
return data.success;
|
||||
} catch (error) {
|
||||
console.error('Error deleting cast:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches all unique staff members from media
|
||||
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
|
||||
|
||||
media.forEach(item => {
|
||||
item.staff?.forEach(staffMember => {
|
||||
const id = parseInt(staffMember.id);
|
||||
if (!actorMap.has(id)) {
|
||||
actorMap.set(id, {
|
||||
id: id,
|
||||
name: staffMember.name,
|
||||
photo: staffMember.photo
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(actorMap.values());
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return Array.from(actorMap.values());
|
||||
} catch (error) {
|
||||
console.error('Error fetching all actors:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches all unique tags from media
|
||||
export async function fetchAllTags(): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch('/adult.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
if (data.data.items) {
|
||||
const tagSet = new Set<string>();
|
||||
data.data.items.forEach(item => {
|
||||
try {
|
||||
const metadata = JSON.parse(item.metadata);
|
||||
metadata.tags?.forEach((tag: string) => tagSet.add(tag));
|
||||
} catch {
|
||||
// Ignore metadata parsing errors
|
||||
}
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}
|
||||
return [];
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
media.forEach(item => {
|
||||
item.tags?.forEach(tag => tagSet.add(tag));
|
||||
item.genres?.forEach(genre => tagSet.add(genre));
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort();
|
||||
} catch (error) {
|
||||
console.error('Error fetching all tags:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches media by actor name
|
||||
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.staff?.some(staffMember =>
|
||||
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by actor:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for compatibility - fetches media by tag
|
||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
||||
try {
|
||||
const media = await fetchAllMedia(1, 1000);
|
||||
return media.filter(item =>
|
||||
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
|
||||
item.genres?.some(g => g.toLowerCase().includes(tag.toLowerCase()))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media by tag:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from API (legacy compatibility)
|
||||
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
// Convenience function - fetch media from local JSON (legacy compatibility)
|
||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||
return fetchAllMedia();
|
||||
}
|
||||
|
||||
421
src/components/AddMediaView.tsx
Normal file
421
src/components/AddMediaView.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { MediaCategory } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createMedia, type CreateMediaInput } from '@/api';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddMediaViewProps {
|
||||
activeCategory: MediaCategory;
|
||||
onBack: () => void;
|
||||
onAddComplete: () => void;
|
||||
}
|
||||
|
||||
export default function AddMediaView({ activeCategory, onBack, onAddComplete }: AddMediaViewProps) {
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
banner: '',
|
||||
description: '',
|
||||
rating: '',
|
||||
category: activeCategory,
|
||||
type: 'Movie' as string,
|
||||
status: 'Released' as string,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1',
|
||||
runtime: '',
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
genres: '' as string,
|
||||
tags: '' as string,
|
||||
studios: '' as string
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
// Update category, default aspect ratio, and default type when activeCategory changes
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
let defaultType = 'Movie';
|
||||
|
||||
if (activeCategory === 'Music') {
|
||||
defaultAspect = '1/1';
|
||||
defaultType = 'Album';
|
||||
} else if (activeCategory === 'Games') {
|
||||
defaultAspect = '16/9';
|
||||
defaultType = 'Game';
|
||||
} else if (activeCategory === 'Adult') {
|
||||
defaultAspect = '16/9';
|
||||
defaultType = 'Movie';
|
||||
} else if (activeCategory === 'Anime') {
|
||||
defaultType = 'TV';
|
||||
} else if (activeCategory === 'TV Series') {
|
||||
defaultType = 'TV';
|
||||
} else if (activeCategory === 'Books') {
|
||||
defaultType = 'Hardcover';
|
||||
} else if (activeCategory === 'Consoles') {
|
||||
defaultType = 'Console';
|
||||
}
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect,
|
||||
type: defaultType
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
// Convert category from plural to singular to match API format
|
||||
const categoryMap: Record<string, string> = {
|
||||
'Anime': 'Anime',
|
||||
'Movies': 'Movie',
|
||||
'TV Series': 'TV',
|
||||
'Music': 'Music',
|
||||
'Books': 'Book',
|
||||
'Consoles': 'Console',
|
||||
'Games': 'Game',
|
||||
'Adult': 'Adult'
|
||||
};
|
||||
|
||||
const mediaInput: CreateMediaInput = {
|
||||
title: newMedia.title,
|
||||
year: parseInt(newMedia.year) || new Date().getFullYear(),
|
||||
poster: newMedia.poster,
|
||||
banner: newMedia.banner || null,
|
||||
description: newMedia.description || null,
|
||||
rating: newMedia.rating ? parseFloat(newMedia.rating) : null,
|
||||
category: categoryMap[newMedia.category] || newMedia.category,
|
||||
type: newMedia.type,
|
||||
status: newMedia.status,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
runtime: newMedia.runtime ? parseInt(newMedia.runtime) : null,
|
||||
director: newMedia.director || null,
|
||||
writer: newMedia.writer || null,
|
||||
releaseDate: newMedia.releaseDate || null,
|
||||
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
|
||||
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
|
||||
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : []
|
||||
};
|
||||
|
||||
try {
|
||||
const createdMedia = await createMedia(mediaInput);
|
||||
|
||||
if (createdMedia) {
|
||||
setSubmitStatus('success');
|
||||
onAddComplete();
|
||||
|
||||
// Reset form after successful submission
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
banner: '',
|
||||
description: '',
|
||||
rating: '',
|
||||
category: activeCategory,
|
||||
type: 'Movie',
|
||||
status: 'Released',
|
||||
aspectRatio: '2/3',
|
||||
runtime: '',
|
||||
director: '',
|
||||
writer: '',
|
||||
releaseDate: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: ''
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to add media. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-24 pb-12 px-6 max-w-[1200px] mx-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="mb-6 gap-2 text-zinc-600 hover:text-zinc-900"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Back to Browse
|
||||
</Button>
|
||||
|
||||
<div className="bg-white rounded-3xl shadow-xl p-8">
|
||||
<h1 className="text-3xl font-black text-zinc-900 mb-2">Add New Media</h1>
|
||||
<p className="text-zinc-500 font-medium mb-8">
|
||||
Add a new item to your {activeCategory} library.
|
||||
</p>
|
||||
|
||||
{submitStatus === 'success' && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
|
||||
<p className="text-green-800 font-bold">✓ Successfully added to library!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitStatus === 'error' && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<p className="text-red-800 font-bold">✗ Error: {errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddSubmit} className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type" className="text-sm font-black text-zinc-700">Type</Label>
|
||||
<select
|
||||
id="type"
|
||||
value={newMedia.type}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{newMedia.category === 'Music' ? (
|
||||
<>
|
||||
<option value="Album">Album</option>
|
||||
<option value="Single">Single</option>
|
||||
</>
|
||||
) : newMedia.category === 'Books' ? (
|
||||
<>
|
||||
<option value="Hardcover">Hardcover</option>
|
||||
<option value="E-book">E-book</option>
|
||||
</>
|
||||
) : newMedia.category === 'Games' ? (
|
||||
<>
|
||||
<option value="Game">Game</option>
|
||||
</>
|
||||
) : newMedia.category === 'Consoles' ? (
|
||||
<>
|
||||
<option value="Console">Console</option>
|
||||
</>
|
||||
) : newMedia.category === 'TV Series' || newMedia.category === 'Anime' ? (
|
||||
<>
|
||||
<option value="TV">TV</option>
|
||||
<option value="OVA">OVA</option>
|
||||
<option value="ONA">ONA</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="Movie">Movie</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
value={newMedia.status}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="Released">Released</option>
|
||||
<option value="Ongoing">Ongoing</option>
|
||||
<option value="Upcoming">Upcoming</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Watching">Watching</option>
|
||||
<option value="Reading">Reading</option>
|
||||
<option value="Listening">Listening</option>
|
||||
<option value="Playing">Playing</option>
|
||||
<option value="Dropped">Dropped</option>
|
||||
<option value="On Hold">On Hold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail)</option>
|
||||
<option value="1/1">1:1 (Square)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="banner" className="text-sm font-black text-zinc-700">Banner URL (Optional)</Label>
|
||||
<Input
|
||||
id="banner"
|
||||
value={newMedia.banner}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description" className="text-sm font-black text-zinc-700">Description (Optional)</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={newMedia.description}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Brief description..."
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl p-3 h-20 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rating" className="text-sm font-black text-zinc-700">Rating (Optional)</Label>
|
||||
<Input
|
||||
id="rating"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="10"
|
||||
value={newMedia.rating}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
|
||||
placeholder="8.5"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'TV Series' || newMedia.category === 'Adult') && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="runtime" className="text-sm font-black text-zinc-700">Runtime (min)</Label>
|
||||
<Input
|
||||
id="runtime"
|
||||
type="number"
|
||||
value={newMedia.runtime}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
|
||||
placeholder="120"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="releaseDate" className="text-sm font-black text-zinc-700">Release Date</Label>
|
||||
<Input
|
||||
id="releaseDate"
|
||||
type="date"
|
||||
value={newMedia.releaseDate}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="director" className="text-sm font-black text-zinc-700">Director</Label>
|
||||
<Input
|
||||
id="director"
|
||||
value={newMedia.director}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
|
||||
placeholder="Director name"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="writer" className="text-sm font-black text-zinc-700">Writer</Label>
|
||||
<Input
|
||||
id="writer"
|
||||
value={newMedia.writer}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
|
||||
placeholder="Writer name"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="genres" className="text-sm font-black text-zinc-700">Genres (comma-separated)</Label>
|
||||
<Input
|
||||
id="genres"
|
||||
value={newMedia.genres}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
|
||||
placeholder="Action, Drama, Sci-Fi"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags" className="text-sm font-black text-zinc-700">Tags (comma-separated)</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={newMedia.tags}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
|
||||
placeholder="Classic, Best-selling"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="studios" className="text-sm font-black text-zinc-700">Studios (comma-separated)</Label>
|
||||
<Input
|
||||
id="studios"
|
||||
value={newMedia.studios}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
|
||||
placeholder="Studio A, Studio B"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'SAVING...' : 'SAVE TO LIBRARY'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Media, MediaCategory } from '@/types';
|
||||
import MediaCard from './MediaCard';
|
||||
import MediaListItem from './MediaListItem';
|
||||
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } from 'lucide-react';
|
||||
import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Search, Monitor, Users, FolderTree } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
@@ -10,98 +10,45 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
|
||||
interface BrowseViewProps {
|
||||
mediaList: Media[];
|
||||
onMediaClick: (media: Media) => void;
|
||||
onAddMedia: (media: Media) => void;
|
||||
activeCategory: MediaCategory;
|
||||
}
|
||||
|
||||
export default function BrowseView({ mediaList, onMediaClick, onAddMedia, activeCategory }: BrowseViewProps) {
|
||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory }: BrowseViewProps) {
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
||||
const [sortBy, setSortBy] = useState<string>('default');
|
||||
|
||||
// Add Media Dialog State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [newMedia, setNewMedia] = useState({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory as MediaCategory,
|
||||
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1'
|
||||
});
|
||||
|
||||
// Update category and default aspect ratio when activeCategory changes
|
||||
useEffect(() => {
|
||||
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
|
||||
if (activeCategory === 'Music') defaultAspect = '1/1';
|
||||
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
|
||||
|
||||
setNewMedia(prev => ({
|
||||
...prev,
|
||||
category: activeCategory,
|
||||
aspectRatio: defaultAspect
|
||||
}));
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleAddSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMedia.title || !newMedia.poster) return;
|
||||
|
||||
onAddMedia({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
title: newMedia.title,
|
||||
year: newMedia.year || new Date().getFullYear().toString(),
|
||||
poster: newMedia.poster,
|
||||
category: newMedia.category,
|
||||
aspectRatio: newMedia.aspectRatio,
|
||||
status: 'planned'
|
||||
});
|
||||
|
||||
setNewMedia({
|
||||
title: '',
|
||||
year: '',
|
||||
poster: '',
|
||||
category: activeCategory,
|
||||
aspectRatio: '2/3'
|
||||
});
|
||||
setIsAddDialogOpen(false);
|
||||
};
|
||||
|
||||
// Filter states
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [selectedDeveloper, setSelectedDeveloper] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
// Extract unique values for filters
|
||||
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
|
||||
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
|
||||
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
|
||||
const allPlatforms = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.platforms || []))), [mediaList]);
|
||||
const allDevelopers = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.developers || []))), [mediaList]);
|
||||
const allCategories = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.categories || []))), [mediaList]);
|
||||
|
||||
const filteredMedia = useMemo(() => {
|
||||
return mediaList.filter(media => {
|
||||
if (selectedType && media.type !== selectedType) return false;
|
||||
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
|
||||
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
|
||||
if (selectedPlatform && !media.platforms?.includes(selectedPlatform)) return false;
|
||||
if (selectedDeveloper && !media.developers?.includes(selectedDeveloper)) return false;
|
||||
if (selectedCategory && !media.categories?.includes(selectedCategory)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [mediaList, selectedType, selectedGenre, selectedStudio]);
|
||||
}, [mediaList, selectedGenre, selectedStudio, selectedPlatform, selectedDeveloper, selectedCategory]);
|
||||
|
||||
// Reset to first page when mediaList or filters change
|
||||
useEffect(() => {
|
||||
@@ -141,29 +88,13 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Type Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Filter size={16} />
|
||||
{selectedType || 'Media Type'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
|
||||
{allTypes.map(type => (
|
||||
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Genre Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Star size={16} />
|
||||
{selectedGenre || 'Genres'}
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
|
||||
@@ -176,9 +107,9 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
{/* Studio Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
Studios
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
|
||||
@@ -188,15 +119,71 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(selectedType || selectedGenre || selectedStudio) && (
|
||||
{/* Platform Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedPlatform ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Monitor size={16} />
|
||||
{selectedPlatform || 'Platforms'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedPlatform(null)}>All Platforms</DropdownMenuItem>
|
||||
{allPlatforms.sort().map(platform => (
|
||||
<DropdownMenuItem key={platform} onClick={() => setSelectedPlatform(platform)}>{platform}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Developer Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedDeveloper ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<Users size={16} />
|
||||
{selectedDeveloper || 'Developers'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedDeveloper(null)}>All Developers</DropdownMenuItem>
|
||||
{allDevelopers.sort().map(developer => (
|
||||
<DropdownMenuItem key={developer} onClick={() => setSelectedDeveloper(developer)}>{developer}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Category Filter - Only for Games */}
|
||||
{activeCategory === 'Games' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedCategory ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
|
||||
<FolderTree size={16} />
|
||||
{selectedCategory || 'Categories'}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => setSelectedCategory(null)}>All Categories</DropdownMenuItem>
|
||||
{allCategories.sort().map(category => (
|
||||
<DropdownMenuItem key={category} onClick={() => setSelectedCategory(category)}>{category}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{(selectedGenre || selectedStudio || selectedPlatform || selectedDeveloper || selectedCategory) && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-zinc-400 font-bold"
|
||||
onClick={() => {
|
||||
setSelectedType(null);
|
||||
setSelectedGenre(null);
|
||||
setSelectedStudio(null);
|
||||
setSelectedPlatform(null);
|
||||
setSelectedDeveloper(null);
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
@@ -205,98 +192,12 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2">
|
||||
<Plus size={20} />
|
||||
ADD NEW
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<form onSubmit={handleAddSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-black text-zinc-900">Add New Media</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500 font-medium">
|
||||
Manually add a new item to your {activeCategory} library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 py-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newMedia.title}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g. Mob Psycho 100"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="year" className="text-sm font-black text-zinc-700">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
value={newMedia.year}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, year: e.target.value }))}
|
||||
placeholder="2024"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category" className="text-sm font-black text-zinc-700">Category</Label>
|
||||
<select
|
||||
id="category"
|
||||
value={newMedia.category}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, category: e.target.value as MediaCategory }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
{['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'].map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
|
||||
<select
|
||||
id="aspectRatio"
|
||||
value={newMedia.aspectRatio}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
|
||||
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||
>
|
||||
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option>
|
||||
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
|
||||
<option value="1/1">1:1 (Square - Music)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="poster" className="text-sm font-black text-zinc-700">Poster URL</Label>
|
||||
<Input
|
||||
id="poster"
|
||||
value={newMedia.poster}
|
||||
onChange={e => setNewMedia(prev => ({ ...prev, poster: e.target.value }))}
|
||||
placeholder="https://example.com/poster.jpg"
|
||||
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
|
||||
SAVE TO LIBRARY
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-zinc-600 font-bold gap-2">
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-zinc-600 font-bold gap-2">
|
||||
<ArrowUpDown size={16} />
|
||||
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
|
||||
</Button>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
|
||||
@@ -336,7 +237,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
|
||||
{mediaList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Filter size={32} />
|
||||
<Search size={32} />
|
||||
</div>
|
||||
<p className="text-lg font-bold">No results found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
|
||||
@@ -116,10 +116,72 @@ export default function DetailView({ media, onBack, onPersonClick }: DetailViewP
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios?.join(', ')}
|
||||
</p>
|
||||
{media.studios && media.studios.length > 0 && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Studios:</span>
|
||||
{media.studios.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{media.developers && media.developers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Developers:</span>
|
||||
{media.developers.map(dev => (
|
||||
<Badge key={dev} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{dev}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.platforms && media.platforms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Platforms:</span>
|
||||
{media.platforms.map(platform => (
|
||||
<Badge key={platform} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.categories && media.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Categories:</span>
|
||||
{media.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200 border-none px-3 py-1 font-bold text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{media.completionStatus && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Completion:</span>
|
||||
{media.completionStatus}
|
||||
</p>
|
||||
)}
|
||||
{media.source && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Source:</span>
|
||||
{media.source}
|
||||
</p>
|
||||
)}
|
||||
{media.playCount !== undefined && media.playCount !== null && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Play Count:</span>
|
||||
{media.playCount}
|
||||
</p>
|
||||
)}
|
||||
{media.playtime !== undefined && media.playtime !== null && media.playtime > 0 && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Playtime:</span>
|
||||
{media.playtime}h
|
||||
</p>
|
||||
)}
|
||||
{media.lastActivity && (
|
||||
<p className="text-xs font-bold text-zinc-500">
|
||||
<span className="text-zinc-400 uppercase tracking-widest mr-2">Last Activity:</span>
|
||||
{media.lastActivity}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Links:</span>
|
||||
<Button variant="link" className="p-0 h-auto text-[#6d28d9] font-bold text-xs">Tvdb</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, User, X } from 'lucide-react';
|
||||
import { Search, User, X, Plus, Download } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from 'react';
|
||||
import { MediaCategory } from '@/types';
|
||||
@@ -7,6 +7,8 @@ import LibrarySettings from './LibrarySettings';
|
||||
interface HeaderProps {
|
||||
onBrowse: () => void;
|
||||
onCast: () => void;
|
||||
onAddMedia: () => void;
|
||||
onImporter: () => void;
|
||||
onSearch: (query: string) => void;
|
||||
activeCategory: MediaCategory;
|
||||
onCategoryChange: (category: MediaCategory) => void;
|
||||
@@ -18,6 +20,8 @@ interface HeaderProps {
|
||||
export default function Header({
|
||||
onBrowse,
|
||||
onCast,
|
||||
onAddMedia,
|
||||
onImporter,
|
||||
onSearch,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
@@ -101,6 +105,18 @@ export default function Header({
|
||||
>
|
||||
{isSearchOpen ? <X size={20} /> : <Search size={20} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddMedia}
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onImporter}
|
||||
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<LibrarySettings
|
||||
enabledCategories={enabledCategories}
|
||||
onToggleCategory={onToggleCategory}
|
||||
|
||||
556
src/components/ImporterView.tsx
Normal file
556
src/components/ImporterView.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
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';
|
||||
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
||||
|
||||
export default function ImporterView({ onBack }: { onBack: () => void }) {
|
||||
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || '' });
|
||||
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
|
||||
url: import.meta.env.VITE_STASHAPP_URL || '',
|
||||
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''
|
||||
});
|
||||
const [playniteConfig, setPlayniteConfig] = useState<PlayniteConfig>({
|
||||
ip: import.meta.env.VITE_PLAYNITE_IP || '',
|
||||
apiToken: import.meta.env.VITE_PLAYNITE_API_TOKEN || '',
|
||||
port: parseInt(import.meta.env.VITE_PLAYNITE_PORT || '19821')
|
||||
});
|
||||
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 handlePlayniteImport = async () => {
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to Playnite API...',
|
||||
videosImported: 0,
|
||||
actorsImported: 0,
|
||||
errors: []
|
||||
});
|
||||
setImportLog([]);
|
||||
|
||||
const result = await importFromPlaynite(
|
||||
playniteConfig,
|
||||
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 */}
|
||||
{xbvrConfig.url && (
|
||||
<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 */}
|
||||
{stashappConfig.url && (
|
||||
<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 */}
|
||||
{stashappConfig.url && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Playnite Importer Card */}
|
||||
{playniteConfig.ip && playniteConfig.apiToken && (
|
||||
<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-orange-100 rounded-lg flex items-center justify-center">
|
||||
<Film className="text-orange-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900">Playnite</h3>
|
||||
<p className="text-xs text-zinc-500 font-medium">Game Library 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 games from your Playnite library via Bridge API.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">IP Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playniteConfig.ip}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, ip: 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="localhost"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playniteConfig.port || 19821}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, port: parseInt(e.target.value) || 19821 })}
|
||||
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="19821"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold text-zinc-500 mb-1 block">API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={playniteConfig.apiToken}
|
||||
onChange={(e) => setPlayniteConfig({ ...playniteConfig, apiToken: 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="pb_your_token_here"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePlayniteImport}
|
||||
disabled={progress.stage !== 'idle' || !playniteConfig.ip || !playniteConfig.apiToken}
|
||||
className="w-full bg-orange-600 hover:bg-orange-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 Playnite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</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">{(progress as any).gamesImported !== undefined ? 'Games' : 'Videos'}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-black text-zinc-900">{(progress as any).gamesImported !== undefined ? (progress as any).gamesImported : 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>
|
||||
);
|
||||
}
|
||||
@@ -34,9 +34,9 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-white/90 hover:text-white transition-colors">
|
||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-colors">
|
||||
<Settings size={20} />
|
||||
</Button>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -127,7 +127,8 @@ export const MOCK_MEDIA: Media[] = [
|
||||
studios: ['Example Studio'],
|
||||
}
|
||||
];
|
||||
|
||||
export const DETAIL_MEDIA: Media = {}
|
||||
/*
|
||||
export const DETAIL_MEDIA: Media = {
|
||||
id: 'mob-psycho',
|
||||
title: 'Mob Psycho 100',
|
||||
@@ -220,3 +221,4 @@ export const DETAIL_MEDIA: Media = {
|
||||
},
|
||||
]
|
||||
};
|
||||
*/
|
||||
336
src/lib/playniteImporter.ts
Normal file
336
src/lib/playniteImporter.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
export interface PlayniteConfig {
|
||||
ip: string;
|
||||
apiToken: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface ImportProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
stage: 'idle' | 'fetching' | 'importing' | 'complete' | 'error';
|
||||
message: string;
|
||||
gamesImported: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface PlayniteGame {
|
||||
id: string;
|
||||
name: string;
|
||||
sortingName?: string;
|
||||
description?: string;
|
||||
notes?: string;
|
||||
version?: string;
|
||||
hidden?: boolean;
|
||||
favorite?: boolean;
|
||||
userScore?: number;
|
||||
communityScore?: number;
|
||||
criticScore?: number;
|
||||
releaseDate?: string;
|
||||
completionStatus?: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
features?: string[];
|
||||
genres?: string[];
|
||||
developers?: string[];
|
||||
publishers?: string[];
|
||||
series?: string[];
|
||||
platforms?: string[];
|
||||
ageRatings?: string[];
|
||||
regions?: string[];
|
||||
links?: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
playtime?: number;
|
||||
playCount?: number;
|
||||
lastActivity?: string;
|
||||
added?: string;
|
||||
lastPlayed?: string;
|
||||
source?: string;
|
||||
isInstalled?: boolean;
|
||||
}
|
||||
|
||||
export interface PlayniteGamesResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
games: PlayniteGame[];
|
||||
}
|
||||
|
||||
export type LogCallback = (message: string) => void;
|
||||
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
|
||||
|
||||
async function fetchGameCover(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const coverResponse = await fetch(`${baseUrl}/api/games/${gameId}/cover`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!coverResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blob = await coverResponse.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
|
||||
// Determine MIME type from blob
|
||||
const mimeType = blob.type || 'image/jpeg';
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importFromPlaynite(
|
||||
config: PlayniteConfig,
|
||||
logCallback: LogCallback,
|
||||
progressCallback: ProgressCallback
|
||||
): Promise<ImportProgress> {
|
||||
const progress: ImportProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Connecting to Playnite API...',
|
||||
gamesImported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
const baseUrl = `http://${config.ip}:${config.port || 19821}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiToken}`
|
||||
};
|
||||
|
||||
try {
|
||||
logCallback('Starting Playnite import...');
|
||||
|
||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||
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 existingMedia = new Map(
|
||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
||||
);
|
||||
logCallback(`Found ${existingMedia.size} existing games in database`);
|
||||
|
||||
// Step 1: Fetch games from Playnite
|
||||
logCallback(`Fetching games from ${baseUrl}/api/games...`);
|
||||
progressCallback({ message: 'Fetching games from Playnite...' });
|
||||
|
||||
const gamesResponse = await fetch(`${baseUrl}/api/games?limit=5000`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!gamesResponse.ok) {
|
||||
throw new Error(`Failed to connect to Playnite API: ${gamesResponse.statusText}`);
|
||||
}
|
||||
|
||||
const gamesData: PlayniteGamesResponse = await gamesResponse.json();
|
||||
const games = gamesData.games || [];
|
||||
logCallback(`Found ${games.length} games in Playnite`);
|
||||
|
||||
// Step 2: Fetch detailed information for each game
|
||||
progressCallback({
|
||||
total: games.length,
|
||||
current: 0,
|
||||
stage: 'fetching',
|
||||
message: 'Fetching game details...'
|
||||
});
|
||||
|
||||
const detailedGames: PlayniteGame[] = [];
|
||||
for (let i = 0; i < games.length; i++) {
|
||||
const game = games[i];
|
||||
try {
|
||||
logCallback(`Fetching details for: ${game.name} (${i + 1}/${games.length})`);
|
||||
|
||||
const detailResponse = await fetch(`${baseUrl}/api/games/${game.id}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (detailResponse.ok) {
|
||||
const detailData: PlayniteGame = await detailResponse.json();
|
||||
detailedGames.push(detailData);
|
||||
logCallback(`✓ Fetched details for: ${game.name}`);
|
||||
} else {
|
||||
// If detail fetch fails, use basic game info
|
||||
detailedGames.push(game);
|
||||
logCallback(`⊘ Using basic info for: ${game.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// If detail fetch fails, use basic game info
|
||||
detailedGames.push(game);
|
||||
logCallback(`⊘ Using basic info for: ${game.name} (detail fetch failed)`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
message: `Fetching game details... ${Math.round(((i + 1) / games.length) * 100)}%`
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Import games
|
||||
progressCallback({
|
||||
total: detailedGames.length,
|
||||
current: 0,
|
||||
stage: 'importing',
|
||||
message: 'Importing games...'
|
||||
});
|
||||
|
||||
let gamesImported = 0;
|
||||
const gameErrors: string[] = [];
|
||||
|
||||
for (let i = 0; i < detailedGames.length; i++) {
|
||||
const game = detailedGames[i];
|
||||
|
||||
const existingGame = existingMedia.get(game.name);
|
||||
const isUpdate = existingGame !== undefined;
|
||||
|
||||
try {
|
||||
// Parse release date
|
||||
let year = new Date().getFullYear();
|
||||
let releaseDate = null;
|
||||
if (game.releaseDate) {
|
||||
const dateMatch = game.releaseDate.match(/^(\d{4})/);
|
||||
if (dateMatch) {
|
||||
year = parseInt(dateMatch[1]);
|
||||
}
|
||||
releaseDate = game.releaseDate;
|
||||
}
|
||||
|
||||
// Convert playtime from seconds to minutes
|
||||
const runtime = game.playtime ? Math.round(game.playtime / 60) : null;
|
||||
|
||||
// Calculate combined rating from all available scores (0-100 to 0-5)
|
||||
let rating = null;
|
||||
const scores = [];
|
||||
if (game.userScore !== undefined && game.userScore !== null) scores.push(game.userScore);
|
||||
if (game.communityScore !== undefined && game.communityScore !== null) scores.push(game.communityScore);
|
||||
if (game.criticScore !== undefined && game.criticScore !== null) scores.push(game.criticScore);
|
||||
if (scores.length > 0) {
|
||||
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
||||
rating = avgScore / 20;
|
||||
}
|
||||
|
||||
// Staff is for actors/performers only - leave empty for games
|
||||
const staff: any[] = [];
|
||||
// Determine type based on genres/features
|
||||
let type = 'Game';
|
||||
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
|
||||
// type = 'Movie';
|
||||
// }
|
||||
|
||||
const mediaData = {
|
||||
type: 'Game',
|
||||
title: game.name,
|
||||
sortingName: game.sortingName || null,
|
||||
description: game.description || null,
|
||||
notes: game.notes || null,
|
||||
genres: game.genres || [],
|
||||
categories: game.categories || [],
|
||||
tags: game.tags || [],
|
||||
features: game.features || [],
|
||||
platforms: game.platforms || [],
|
||||
developers: game.developers || [],
|
||||
publishers: game.publishers || [],
|
||||
series: game.series ? [game.series] : [],
|
||||
ageRatings: game.ageRatings || [],
|
||||
regions: game.regions || [],
|
||||
source: game.source || null,
|
||||
gameId: game.id,
|
||||
pluginId: null,
|
||||
completionStatus: game.completionStatus || 'Not Played',
|
||||
releaseDate: releaseDate,
|
||||
isInstalled: game.isInstalled || false,
|
||||
installDirectory: null,
|
||||
installSize: null,
|
||||
hidden: game.hidden || false,
|
||||
favorite: game.favorite || false,
|
||||
playtime: game.playtime || 0,
|
||||
playCount: game.playCount || 0,
|
||||
lastActivity: game.lastActivity || null,
|
||||
added: game.added || null,
|
||||
modified: null,
|
||||
communityScore: game.communityScore || null,
|
||||
criticScore: game.criticScore || null,
|
||||
userScore: game.userScore || null,
|
||||
hasIcon: false,
|
||||
hasCover: false,
|
||||
hasBackground: false,
|
||||
version: game.version || null,
|
||||
links: game.links || [],
|
||||
achievements: [],
|
||||
year: year.toString(),
|
||||
poster: game.coverBase64 || null,
|
||||
banner: null,
|
||||
rating: rating,
|
||||
category: 'Game',
|
||||
status: game.completionStatus === 'Completed' ? 'completed' :
|
||||
game.completionStatus === 'Playing' ? 'ongoing' :
|
||||
game.completionStatus === 'Abandoned' ? 'dropped' : 'planned',
|
||||
aspectRatio: '2/3',
|
||||
runtime: runtime,
|
||||
director: null,
|
||||
writer: null
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isUpdate) {
|
||||
response = await fetch(`http://192.168.1.102:6400/api/media/${(existingGame as any).id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mediaData)
|
||||
});
|
||||
} else {
|
||||
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) {
|
||||
gamesImported++;
|
||||
logCallback(`✓ ${isUpdate ? 'Updated' : 'Imported'} game: ${game.name}`);
|
||||
} else {
|
||||
const error = await response.text();
|
||||
gameErrors.push(`Failed to ${isUpdate ? 'update' : 'import'} game ${game.name}: ${error}`);
|
||||
logCallback(`✗ Failed to ${isUpdate ? 'update' : 'import'} game: ${game.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
gameErrors.push(`Error importing game ${game.name}: ${error}`);
|
||||
logCallback(`✗ Error importing game: ${game.name}`);
|
||||
}
|
||||
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
gamesImported,
|
||||
errors: gameErrors
|
||||
});
|
||||
}
|
||||
|
||||
logCallback(`Imported ${gamesImported}/${games.length} games`);
|
||||
|
||||
// Complete
|
||||
progress.stage = 'complete';
|
||||
progress.message = 'Import complete!';
|
||||
progress.current = games.length;
|
||||
progress.total = games.length;
|
||||
progress.gamesImported = gamesImported;
|
||||
progress.errors = gameErrors;
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
10
src/types.ts
10
src/types.ts
@@ -1,4 +1,4 @@
|
||||
export type MediaCategory = 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
|
||||
export type MediaCategory = 'Anime' | 'Movies' | 'TV Series' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games';
|
||||
|
||||
export interface Media {
|
||||
id: string;
|
||||
@@ -17,6 +17,14 @@ export interface Media {
|
||||
status?: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold';
|
||||
episodes?: Episode[];
|
||||
staff?: Staff[];
|
||||
categories?: string[];
|
||||
platforms?: string[];
|
||||
developers?: string[];
|
||||
completionStatus?: string;
|
||||
source?: string;
|
||||
playCount?: number;
|
||||
lastActivity?: string | null;
|
||||
playtime?: number;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
|
||||
14
src/vite-env.d.ts
vendored
Normal file
14
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_XBVR_URL?: string;
|
||||
readonly VITE_STASHAPP_URL?: string;
|
||||
readonly VITE_STASHAPP_API_KEY?: string;
|
||||
readonly VITE_PLAYNITE_IP?: string;
|
||||
readonly VITE_PLAYNITE_PORT?: string;
|
||||
readonly VITE_PLAYNITE_API_TOKEN?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user