Use Zustand store; modularize API & routes

Introduce a centralized Zustand store and refactor app state out of App.tsx into src/store/appStore.ts. Modularize API surface by moving media/cast/settings/converters/types into src/lib/api/* and re-exporting from src/api.ts for backward compatibility. Replace inline route helpers with dedicated route components (MediaDetailRoute, CastDetailRoute, CategoryBrowseRoute) and wire CATEGORY_PATHS/PATH_TO_CATEGORY constants. Update AddMediaView UI (icons, layout) and adjust settings/category handling to use DEFAULT_SETTINGS and the store. Add zustand to package.json/package-lock.json and include a new React SKILL.md. Overall changes improve state management, API organization, and route/component separation for better maintainability and code-splitting.
This commit is contained in:
Lars Behrends
2026-04-16 14:53:46 +02:00
parent a407b57006
commit 432416cfc5
22 changed files with 1843 additions and 1342 deletions
+163
View File
@@ -0,0 +1,163 @@
import { Staff, Media } from '../../types';
import { ApiResponse, PaginatedResponse, ApiCastItem, CreateCastInput, UpdateCastInput } from './types';
import { convertApiCastToStaff, convertApiToMedia } from './converters';
const BASE_URL = import.meta.env.VITE_API_URL;
export async function fetchAllCast(page: number = 1, limit: number = 100000): Promise<Staff[]> {
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.map(convertApiCastToStaff);
}
return [];
} catch (error) {
console.error('Error fetching cast from API:', error);
return [];
}
}
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
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 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<any>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching cast media:', error);
return [];
}
}
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
try {
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<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 functions for compatibility
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
try {
const media = await (await import('./mediaApi')).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());
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
}
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const media = await (await import('./mediaApi')).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 [];
}
}
+190
View File
@@ -0,0 +1,190 @@
import { Media, Staff, UserSettings, MediaCategory } from '../../types';
import { ApiMediaItem, ApiStaff, ApiCastItem, ApiSettingsItem, CreateSettingsInput } from './types';
const BASE_URL = import.meta.env.VITE_API_URL;
function normalizeUrl(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
return `${BASE_URL}/${cleanPath}`;
}
export function convertApiCastToStaff(apiItem: ApiCastItem): Staff {
return {
id: apiItem.id.toString(),
name: apiItem.name,
cleanname: apiItem.cleanname,
role: apiItem.occupations?.[0] || 'Actor',
photo: normalizeUrl(apiItem.photo) || `https://picsum.photos/seed/cast-${apiItem.id}/200/200`,
bio: apiItem.bio || undefined,
birthDate: apiItem.birthDate || undefined,
birthPlace: apiItem.birthPlace || undefined,
occupations: apiItem.occupations || ['Actor'],
createdAt: apiItem.createdAt,
updatedAt: apiItem.updatedAt,
bust_size: apiItem.bust_size,
cup_size: apiItem.cup_size,
waist_size: apiItem.waist_size,
hip_size: apiItem.hip_size,
height: apiItem.height,
weight: apiItem.weight,
hair_color: apiItem.hair_color,
eye_color: apiItem.eye_color,
ethnicity: apiItem.ethnicity,
filmography: apiItem.filmography?.map(item => ({
id: item.id,
title: item.title,
year: item.year,
poster: normalizeUrl(item.poster) || `https://picsum.photos/seed/${item.id}/400/600`,
category: item.category,
type: item.type,
role: item.role,
characterName: item.characterName
})),
media_types: apiItem.media_types,
adult_specifics: apiItem.adult_specifics
};
}
export function convertApiToMedia(apiItem: ApiMediaItem): Media {
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`,
}));
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
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/1') || ratio.includes('1.00')) {
aspectRatio = '1/1';
} else if (ratio.includes('2/3')) {
aspectRatio = '2/3';
}
}
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';
}
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 {
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
mediaCategory = 'Movies';
}
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(),
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,
source: apiItem.source || undefined,
status: mediaStatus,
staff: staff.length > 0 ? staff : undefined,
aspectRatio: aspectRatio,
categories: apiItem.categories,
platforms: apiItem.platforms,
developers: apiItem.developers,
completionStatus: apiItem.completionStatus,
playCount: apiItem.playCount,
lastActivity: apiItem.lastActivity,
playtime: apiItem.playtime,
episodes: apiItem.episodes,
tracks: apiItem.tracks
};
}
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
return {
id: apiItem.id,
enabledCategories: apiItem.enabled_categories as MediaCategory[],
itemsPerPage: apiItem.items_per_page || 20,
gridItemSize: apiItem.grid_item_size || 5,
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
showAdultContent: apiItem.show_adult_content || false,
autoPlayTrailers: apiItem.auto_play_trailers || false,
language: apiItem.language || 'en',
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
jellyfinLibraryMappings: apiItem.jellyfin_library_mappings,
createdAt: apiItem.created_at,
updatedAt: apiItem.updated_at,
};
}
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
return {
enabled_categories: settings.enabledCategories,
items_per_page: settings.itemsPerPage,
grid_item_size: settings.gridItemSize,
default_view: settings.defaultView,
show_adult_content: settings.showAdultContent,
auto_play_trailers: settings.autoPlayTrailers,
language: settings.language,
theme: settings.theme,
jellyfin_library_mappings: settings.jellyfinLibraryMappings,
};
}
+105
View File
@@ -0,0 +1,105 @@
import { Media } from '../../types';
import { ApiResponse, PaginatedResponse, ApiMediaItem, CreateMediaInput, UpdateMediaInput } from './types';
import { convertApiToMedia } from './converters';
const BASE_URL = import.meta.env.VITE_API_URL;
export async function fetchAllMedia(page: number = 1, limit: number = 10000): Promise<Media[]> {
try {
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<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 from API:', error);
return [];
}
}
export async function fetchMediaById(id: number | string): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`);
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 fetching media by ID:', error);
return null;
}
}
export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
try {
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<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;
}
}
+83
View File
@@ -0,0 +1,83 @@
import { UserSettings } from '../../types';
import { ApiResponse, ApiSettingsItem, CreateSettingsInput, UpdateSettingsInput } from './types';
import { convertApiToSettings, convertSettingsToApi } from './converters';
const BASE_URL = import.meta.env.VITE_API_URL;
export async function fetchSettings(): Promise<UserSettings | null> {
try {
const response = await fetch(`${BASE_URL}/api/settings`);
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error fetching settings:', error);
return null;
}
}
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
try {
const apiSettings = convertSettingsToApi(settings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Create settings error response:', errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error creating settings:', error);
return null;
}
}
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
try {
const apiSettings = convertSettingsToApi(settings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
if (!response.ok) {
if (response.status === 404) {
return createSettings(settings);
}
const errorText = await response.text();
console.error('Update settings error response:', errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiSettingsItem> = await response.json();
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error updating settings:', error);
return null;
}
}
+212
View File
@@ -0,0 +1,212 @@
// API Response Types
export interface ApiResponse<T> {
success: boolean;
data: T;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages?: number;
}
// Media Types
export interface ApiEpisode {
id: number;
media_id: number;
season: number;
episode_number: number;
title: string;
description: string;
air_date: string;
duration: number;
thumbnail: string;
}
export interface ApiTrack {
id: number;
media_id: number;
track_number: number;
title: string;
duration: number | null;
artist: string;
}
export interface ApiMediaItem {
id: number;
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;
source?: string | null;
createdAt: string;
updatedAt: string;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: ApiStaff[];
categories?: string[];
platforms?: string[];
developers?: string[];
completionStatus?: string;
playCount?: number;
lastActivity?: string | null;
playtime?: number;
episodes?: ApiEpisode[];
tracks?: ApiTrack[];
}
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;
source?: 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;
cleanname?: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
createdAt: string;
updatedAt: string;
occupations?: string[];
filmography?: ApiCastMediaItem[];
media_types?: string[];
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
adult_specifics?: {
id: number;
cast_id: number;
bust_size?: number | null;
cup_size?: string | null;
waist_size?: number | null;
hip_size?: number | null;
height?: number | null;
weight?: number | null;
hair_color?: string | null;
eye_color?: string | null;
ethnicity?: string | null;
tattoos?: string | null;
piercings?: string | null;
measurements?: string | null;
shoe_size?: number | null;
};
}
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> {}
// Settings Types
export interface ApiSettingsItem {
id?: number;
enabled_categories: string[];
items_per_page: number;
grid_item_size?: number;
default_view: string;
show_adult_content: boolean;
auto_play_trailers: boolean;
language: string;
theme: string;
jellyfin_library_mappings?: string;
created_at?: string;
updated_at?: string;
}
export interface CreateSettingsInput {
enabled_categories: string[];
items_per_page?: number;
grid_item_size?: number;
default_view?: string;
show_adult_content?: boolean;
auto_play_trailers?: boolean;
language?: string;
theme?: string;
jellyfin_library_mappings?: string;
}
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
+42 -14
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff, Episode, Track } from '@/types';
export interface JellyfinConfig {
url: string;
@@ -56,7 +56,7 @@ export interface JellyfinItem {
Type: string;
Role?: string;
PrimaryImageTag?: string;
ImageBlurHashes?: any;
ImageBlurHashes?: Record<string, Record<string, string>>;
}>;
ImageTags?: {
Primary?: string;
@@ -96,7 +96,7 @@ export interface JellyfinPerson {
Name: string;
Type: string;
PrimaryImageTag?: string;
ImageBlurHashes?: any;
ImageBlurHashes?: Record<string, Record<string, string>>;
PremiereDate?: string;
ProductionYear?: number;
Overview?: string;
@@ -105,6 +105,28 @@ export interface JellyfinPerson {
PlaceOfBirth?: string;
}
export interface JellyfinEpisode {
Id: string;
Name: string;
Overview?: string;
PremiereDate?: string;
RunTimeTicks?: number;
ParentIndexNumber?: number;
IndexNumber?: number;
ImageTags?: {
Primary?: string;
};
}
export interface JellyfinTrack {
Id: string;
Name: string;
IndexNumber?: number;
RunTimeTicks?: number;
AlbumArtist?: string;
Artists?: string[];
}
export type LogCallback = (message: string) => void;
export type ProgressCallback = (progress: Partial<ImportProgress>) => void;
@@ -575,10 +597,12 @@ async function convertJellyfinSeriesToMedia(
const writers = item.People?.filter(p => p.Type === 'Writer').map(p => p.Name) || [];
// Fetch episodes for this series
let episodes: any[] = [];
let episodes: Episode[] = [];
try {
const jellyfinEpisodes = await fetchJellyfinSeriesEpisodes(config, item.Id);
episodes = jellyfinEpisodes.map(ep => ({
id: parseInt(ep.Id),
media_id: parseInt(item.Id),
season: ep.ParentIndexNumber || 1,
episode_number: ep.IndexNumber || 1,
title: ep.Name,
@@ -682,14 +706,16 @@ async function convertJellyfinAlbumToMedia(
}));
// Fetch tracks for this album
let tracks: any[] = [];
let tracks: Track[] = [];
try {
const jellyfinTracks = await fetchJellyfinAlbumTracks(config, item.Id);
tracks = jellyfinTracks.map((track, index) => ({
id: parseInt(track.Id),
media_id: parseInt(item.Id),
track_number: track.IndexNumber || (index + 1),
title: track.Name,
duration: track.RunTimeTicks ? `${Math.floor(track.RunTimeTicks / 600000000 / 60)}:${String(Math.floor((track.RunTimeTicks / 600000000) % 60)).padStart(2, '0')}` : null,
artist: track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown'
duration: track.RunTimeTicks ? Math.floor(track.RunTimeTicks / 600000000) : null,
artist: (track.AlbumArtist || track.Artists?.[0] || albumArtists[0] || 'Unknown') as string
}));
} catch (error) {
console.warn(`Failed to fetch tracks for album ${item.Name}:`, error);
@@ -721,18 +747,20 @@ async function convertJellyfinAlbumToMedia(
}
// Convert Jellyfin person to API cast format
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): any {
function convertJellyfinPersonToCast(person: JellyfinPerson, config: JellyfinConfig): Staff {
const photo = person.PrimaryImageTag
? getJellyfinImageUrl(config, person.Id, person.PrimaryImageTag, 'Primary')
: null;
return {
id: person.Id,
name: person.Name,
role: person.Type || 'Actor',
photo: photo,
bio: person.Overview || null,
birthDate: person.BirthDate ? formatDate(person.BirthDate) : null,
birthPlace: person.PlaceOfBirth || null,
occupations: [person.Type === 'Actor' ? 'Actor' : person.Type || 'Person']
occupations: ['Actor']
};
}
@@ -771,7 +799,7 @@ export async function importFromJellyfin(
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map(
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
);
logCallback(`Found ${existingMedia.size} existing media items in database`);
@@ -779,7 +807,7 @@ export async function importFromJellyfin(
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json();
const existingCast = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingCast.size} existing cast members in database`);
@@ -1173,14 +1201,14 @@ export async function cleanupJellyfinMedia(
logCallback('Fetching existing media from Kyoo API...');
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=10000`);
const existingMediaData = await existingMediaResponse.json();
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: any) => m.source === 'jellyfin');
const jellyfinMedia = (existingMediaData.data?.items || []).filter((m: Media) => m.source === 'jellyfin');
logCallback(`Found ${jellyfinMedia.length} Jellyfin media items in database`);
// Fetch all existing cast from Kyoo API
logCallback('Fetching existing cast from Kyoo API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json();
const jellyfinCast = (existingCastData.data?.items || []).filter((c: any) => c.photo && c.photo.includes(normalizeUrl(config.url)));
const jellyfinCast = (existingCastData.data?.items || []).filter((c: Staff) => c.photo && c.photo.includes(normalizeUrl(config.url)));
logCallback(`Found ${jellyfinCast.length} Jellyfin cast members in database`);
// Fetch current items from Jellyfin
+64 -5
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
export interface PlayniteConfig {
ip: string;
@@ -54,6 +54,9 @@ export interface PlayniteGame {
lastPlayed?: string;
source?: string;
isInstalled?: boolean;
coverBase64?: string;
backgroundBase64?: string;
iconBase64?: string;
}
export interface PlayniteGamesResponse {
@@ -65,7 +68,7 @@ export interface PlayniteGamesResponse {
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`, {
@@ -89,6 +92,50 @@ async function fetchGameCover(baseUrl: string, headers: Record<string, string>,
}
}
async function fetchGameBackground(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const backgroundResponse = await fetch(`${baseUrl}/api/games/${gameId}/background`, {
method: 'GET',
headers
});
if (!backgroundResponse.ok) {
return null;
}
const blob = await backgroundResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mimeType = blob.type || 'image/jpeg';
return `data:${mimeType};base64,${base64}`;
} catch (error) {
return null;
}
}
async function fetchGameIcon(baseUrl: string, headers: Record<string, string>, gameId: string): Promise<string | null> {
try {
const iconResponse = await fetch(`${baseUrl}/api/games/${gameId}/icon`, {
method: 'GET',
headers
});
if (!iconResponse.ok) {
return null;
}
const blob = await iconResponse.blob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mimeType = blob.type || 'image/png';
return `data:${mimeType};base64,${base64}`;
} catch (error) {
return null;
}
}
*/
export async function importFromPlaynite(
config: PlayniteConfig,
logCallback: LogCallback,
@@ -117,7 +164,7 @@ export async function importFromPlaynite(
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json();
const existingMedia = new Map(
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
(existingMediaData.data?.items || []).map((m: Media) => [m.title, m])
);
logCallback(`Found ${existingMedia.size} existing games in database`);
@@ -159,6 +206,18 @@ export async function importFromPlaynite(
if (detailResponse.ok) {
const detailData: PlayniteGame = await detailResponse.json();
/*
// Fetch images
const [cover, background, icon] = await Promise.all([
fetchGameCover(baseUrl, headers, game.id),
fetchGameBackground(baseUrl, headers, game.id),
fetchGameIcon(baseUrl, headers, game.id)
]);
detailData.coverBase64 = cover;
detailData.backgroundBase64 = background;
detailData.iconBase64 = icon;
*/
detailedGames.push(detailData);
logCallback(`✓ Fetched details for: ${game.name}`);
} else {
@@ -231,7 +290,7 @@ export async function importFromPlaynite(
}
// Staff is for actors/performers only - leave empty for games
const staff: any[] = [];
const staff: Staff[] = [];
// Determine type based on genres/features
let type = 'Game';
//if (game.genres?.includes('Visual Novel') || game.genres?.includes('Adventure')) {
+34 -11
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
export interface StashAPPConfig {
url: string;
@@ -81,7 +81,30 @@ export interface StashAPPScene {
export interface StashAPPScenePerformer {
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 StashAPPPerformer {
@@ -163,8 +186,8 @@ export async function updateActorsFromStashAPP(
logCallback('Fetching existing cast from Kyoo API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -249,12 +272,12 @@ export async function updateActorsFromStashAPP(
for (let i = 0; i < performers.length; i++) {
const performer = performers[i];
const existingActor: any = existingActors.get(performer.name);
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: any = {
const updateData: Partial<Staff> = {
name: performer.name,
};
@@ -386,15 +409,15 @@ export async function importFromStashAPP(
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: any) => m.title) || []
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
logCallback('Fetching existing cast from Kyoo API...');
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
const existingActors = new Map<string, Staff>(
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);
@@ -525,12 +548,12 @@ export async function importFromStashAPP(
for (let i = 0; i < uniquePerformers.length; i++) {
const performer = uniquePerformers[i];
const existingActor: any = existingActors.get(performer.name);
const existingActor: Staff | undefined = existingActors.get(performer.name);
try {
if (existingActor) {
// Update existing actor
const updateData: any = {
const updateData: Partial<Staff> = {
name: performer.name,
};
+4 -4
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_URL;
// Import the source mapping
import { SOURCE_CATEGORY_MAPPING } from '@/types';
// Import the source mapping and types
import { SOURCE_CATEGORY_MAPPING, Media, Staff } from '@/types';
export interface XBVRConfig {
url: string;
@@ -83,7 +83,7 @@ export async function importFromXBVR(
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
const existingMediaData = await existingMediaResponse.json();
const existingTitles = new Set(
existingMediaData.data?.items?.map((m: any) => m.title) || []
existingMediaData.data?.items?.map((m: Media) => m.title) || []
);
logCallback(`Found ${existingTitles.size} existing videos in database`);
@@ -91,7 +91,7 @@ export async function importFromXBVR(
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
const existingCastData = await existingCastResponse.json();
const existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
(existingCastData.data?.items || []).map((c: Staff) => [c.name, c])
);
logCallback(`Found ${existingActors.size} existing actors in database`);