Files
mystuff_frontend/src/api.ts
Lars Behrends 04156486e2 Add user settings UI and API integration
Introduce a full user settings feature: add a SettingsView component and UserSettings type, plus API helpers to fetch, create, and update settings (convertors between API and app shapes). App now loads settings on mount, persists category toggles to the API, exposes a /settings route, and passes itemsPerPage into BrowseView and CastView. Header gains a settings icon/link and BrowseView/CastView update pagination option defaults. This enables centralized library/display/content preferences and syncs them with the backend.
2026-04-10 14:14:27 +02:00

769 lines
23 KiB
TypeScript

import { Media, Staff, UserSettings, MediaCategory } from './types';
const BASE_URL = 'http://192.168.1.102:6400';
function normalizeUrl(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Remove leading slash if present and add base URL
const cleanPath = url.startsWith('/') ? url.slice(1) : url;
return `${BASE_URL}/${cleanPath}`;
}
// 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 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;
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 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;
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> {}
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 {
// 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 API format
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';
}
}
// 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(),
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,
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
};
}
// Media API Functions
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;
}
}
// Cast API Functions
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<ApiMediaItem>> = 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 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());
} 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 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();
}
// Settings API Types
export interface ApiSettingsItem {
id?: number;
enabled_categories: string[];
items_per_page: number;
default_view: string;
show_adult_content: boolean;
auto_play_trailers: boolean;
language: string;
theme: string;
created_at?: string;
updated_at?: string;
}
export interface CreateSettingsInput {
enabled_categories: string[];
items_per_page?: number;
default_view?: string;
show_adult_content?: boolean;
auto_play_trailers?: boolean;
language?: string;
theme?: string;
}
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
return {
id: apiItem.id,
enabledCategories: apiItem.enabled_categories as MediaCategory[],
itemsPerPage: apiItem.items_per_page || 20,
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',
createdAt: apiItem.created_at,
updatedAt: apiItem.updated_at,
};
}
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
return {
enabled_categories: settings.enabledCategories,
items_per_page: settings.itemsPerPage,
default_view: settings.defaultView,
show_adult_content: settings.showAdultContent,
auto_play_trailers: settings.autoPlayTrailers,
language: settings.language,
theme: settings.theme,
};
}
// Settings API Functions
export async function fetchSettings(): Promise<UserSettings | null> {
try {
const response = await fetch(`${BASE_URL}/api/settings`);
if (!response.ok) {
// If settings don't exist (404), return null to use defaults
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);
console.log('Creating settings:', apiSettings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
console.log('Create settings response status:', response.status);
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();
console.log('Create settings response:', data);
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);
console.log('Updating settings:', apiSettings);
const response = await fetch(`${BASE_URL}/api/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiSettings),
});
console.log('Update settings response status:', response.status);
if (!response.ok) {
// If settings don't exist (404), try creating them instead
if (response.status === 404) {
console.log('Settings not found, attempting to create...');
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();
console.log('Update settings response:', data);
if (data.success && data.data) {
return convertApiToSettings(data.data);
}
return null;
} catch (error) {
console.error('Error updating settings:', error);
return null;
}
}