UNPKG

backsplash-app

Version:
785 lines (686 loc) 30.8 kB
import axios, { AxiosError } from "axios"; import { WallpaperGenerationResponse, WallpaperUpscaleResponse, WallpapersResponse, WallpaperGenerationRawResponse, WallpaperLocationData, } from "@/types/wallpaper"; import { StoreService } from "@/ipc/services/storeService"; // Use the variable name that is defined for the main process environment by Vite const API_BASE_URL = process.env.SERVER_BASE_URL || ""; console.log(`[ApiService] Effective API_BASE_URL: "${API_BASE_URL}"`); const R2_PUBLIC_WALLPAPER_URL = process.env.R2_PUBLIC_WALLPAPER_URL || "https://storage.backsplashai.com"; // Default added for clarity, ensure env var is set // Ensure we have a valid base URL for API calls if (!API_BASE_URL) { throw new Error("VITE_SERVER_BASE_URL is not set"); } // Ensure we have a valid R2 public URL for direct image access if (!R2_PUBLIC_WALLPAPER_URL) { console.warn("R2_PUBLIC_WALLPAPER_URL is not set. Direct image URLs may not work."); // Depending on strictness, you might want to throw an error here too } // Set axios defaults axios.defaults.baseURL = API_BASE_URL; axios.defaults.timeout = 60000; // 60 second timeout for all requests // Cache TTL for categories (1 day in milliseconds) const CATEGORIES_CACHE_TTL = 24 * 60 * 60 * 1000; // Add these constants at the top const CACHE_REVALIDATION_DEBOUNCE = 1000; // 1 second const MAX_CACHE_RETRIES = 3; const LICENSE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours const LICENSE_GRACE_PERIOD = 7 * 24 * 60 * 60 * 1000; // 7 days grace period for offline use // Add this utility function const debounce = (fn: (...args: any[]) => void, ms = 300) => { let timeoutId: ReturnType<typeof setTimeout>; return function (this: any, ...args: any[]) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn.apply(this, args), ms); }; }; // Track ongoing background operations to prevent duplicates let isBackgroundRevalidationInProgress = false; // Helper to validate ISO string format const isValidISOString = (str: string): boolean => { if (typeof str !== "string") return false; try { // Check if it matches ISO 8601 format return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(str); } catch (e) { return false; } }; // Helper function to get the URL for a wallpaper image via our server proxy export const getWallpaperImageUrl = (objectKey: string): string => { // Ensure we have a valid object key if (!objectKey) { console.warn("[ApiService] Invalid objectKey provided to getWallpaperImageUrl:", objectKey); return ""; } if (!R2_PUBLIC_WALLPAPER_URL) { console.error("[ApiService] R2_PUBLIC_WALLPAPER_URL is not set. Cannot form wallpaper image URL."); return ""; } // Ensure base URL doesn't have trailing slash and objectKey doesn't have leading slash for clean join const base = R2_PUBLIC_WALLPAPER_URL.endsWith("/") ? R2_PUBLIC_WALLPAPER_URL.slice(0, -1) : R2_PUBLIC_WALLPAPER_URL; const key = objectKey.startsWith("/") ? objectKey.slice(1) : objectKey; const directUrl = `${base}/${key}`; console.log(`[ApiService] Creating direct wallpaper URL for objectKey: ${objectKey}${directUrl}`); return directUrl; }; // Helper function to get the URL for a category preview image // REMOVED - backend now provides this directly in category.preview_url // Helper function to get the URL for a style preview image // REMOVED - backend now provides this directly in style.preview_url // License service functions const validateLicense = async (storeService: StoreService): Promise<boolean> => { try { const licenseKey = storeService.get<"license-key">("license-key"); if (!licenseKey) { console.log("[LicenseService] No license key found"); return false; } // Get device ID to include with validation const deviceIdData = storeService.get<"device-id">("device-id"); const deviceId = deviceIdData?.data || crypto.randomUUID(); // Get cached validation to check if we need to validate again const cachedValidation = storeService.get<"license-validation">("license-validation"); const now = Date.now(); // Keep using numeric timestamps for license validation // If we have a recent validation and it's active, use it if ( cachedValidation && cachedValidation.last_checked && now - cachedValidation.last_checked < LICENSE_CHECK_INTERVAL && cachedValidation.valid && cachedValidation.subscription_active ) { console.log("[LicenseService] Using cached license validation"); return true; } // Otherwise validate against the server console.log(`[LicenseService] Validating license key with server for client: ${deviceId.substring(0, 8)}...`); const response = await axios.post("/wallpapers/validate-license", { license_key: licenseKey, client_id: deviceId }); // Update the cache with the server response (map FastAPI response to our format) const isPremium = response.data.plan === "premium"; storeService.set<"license-validation">("license-validation", { valid: response.data.valid || false, subscription_active: isPremium, // Map premium plan to subscription_active plan: response.data.plan || "free", expires_at: 0, // Our licenses don't expire last_checked: now, // Keep using numeric timestamp for license check }); return response.data.valid && isPremium; } catch (error) { console.error("[LicenseService] Error validating license:", error); // Fall back to cached validation if offline const cachedValidation = storeService.get<"license-validation">("license-validation"); // Allow grace period if we had a valid subscription before if (cachedValidation && cachedValidation.last_checked) { const gracePeriodValid = Date.now() - cachedValidation.last_checked < LICENSE_GRACE_PERIOD; if (gracePeriodValid && cachedValidation.valid && cachedValidation.subscription_active) { console.warn("[LicenseService] Using cached license during grace period due to validation error"); return true; } } return false; } }; const setLicenseKey = async (storeService: StoreService, licenseKey: string): Promise<boolean> => { try { // Save the license key storeService.set<"license-key">("license-key", licenseKey); // Immediately validate it return await validateLicense(storeService); } catch (error) { console.error("[LicenseService] Error setting license key:", error); return false; } }; // Types export interface Style { label: string; // image: string; // Old field preview_url: string; // Direct URL from backend tier?: "free" | "premium"; // Style tier information } export interface Category { id: string; title: string; styles: Style[]; preview_url: string; // Direct URL from backend } // Custom error handler for axios requests const handleAxiosError = (error: AxiosError, context: string) => { // Log detailed error console.error(`Error in ${context}:`, error); // Format a more informative error message based on the type of error let errorMessage: string; if (error.code === "ECONNREFUSED") { errorMessage = `Cannot connect to server.`; } else if (error.code === "ETIMEDOUT") { errorMessage = "Connection timed out. Server may be overloaded or unreachable."; } else if (error.response) { // Server responded with error status const responseData = error.response.data as Record<string, unknown>; errorMessage = `Server error: ${error.response.status} - ${responseData?.detail || "Unknown error"}`; } else if (error.request) { // Request was made but no response received errorMessage = "No response from server. Please check your network connection."; } else { // Error in setting up the request errorMessage = error.message || "Unknown error occurred"; } // Create an error with the message that can be passed back to the renderer const enhancedError = new Error(errorMessage); enhancedError.stack = error.stack; throw enhancedError; }; // API service that handles interagration with the FastAPI backend const createApiService = (storeService: StoreService) => ({ /** * Check if the API server is healthy */ checkHealth: async (): Promise<{ isHealthy: boolean; message: string }> => { try { const response = await axios.get("/health", { timeout: 2000 }); return { isHealthy: response.status === 200, message: response.data?.status || "API is reachable", }; } catch (error) { console.error("Health check failed:", error); return { isHealthy: false, message: "Cannot connect to the backend server", }; } }, /** * License-related functionality */ license: { /** * Validate the current license key with the server */ validate: async (): Promise<boolean> => { return validateLicense(storeService); }, /** * Set and validate a new license key */ setLicenseKey: async (licenseKey: string): Promise<boolean> => { return setLicenseKey(storeService, licenseKey); }, /** * Get current license information */ getLicenseInfo: () => { const licenseKey = storeService.get<"license-key">("license-key"); const validation = storeService.get<"license-validation">("license-validation"); return { licenseKey, isValid: validation?.valid || false, isActive: validation?.subscription_active || false, plan: validation?.plan || "free", expiresAt: validation?.expires_at || 0, lastChecked: validation?.last_checked || 0, }; }, }, categories: { /** * Fetch all categories and their styles with stale-while-revalidate caching */ getMany: async (): Promise<Category[]> => { try { // Get license information to filter styles appropriately const licenseValidation = storeService.get<"license-validation">("license-validation"); const licensePlan = licenseValidation?.plan || "free"; // Get cached data const cached = storeService.get<"categories">("categories"); const now = Date.now(); // Check if cache is for the same license plan and contains tier information const cachedForPlan = cached?.timestamp && cached?.licensePlan === licensePlan; const hasTierInfo = cached?.data?.length > 0 && cached.data[0]?.styles?.[0]?.tier !== undefined; // If we have valid cache for this plan with tier info that isn't stale, return data as is if ( cached?.data?.length > 0 && cachedForPlan && hasTierInfo && now - cached.timestamp <= CATEGORIES_CACHE_TTL ) { // console.log(`[ApiService] Returning categories from valid cache for ${licensePlan} plan`); return cached.data; } // If we have stale data for this plan or missing tier info, use it but revalidate in the background if (cached?.data?.length > 0 && cachedForPlan && hasTierInfo) { // console.log(`[ApiService] Returning categories from stale cache for ${licensePlan} plan, revalidating in background`); // Debounced revalidation to prevent multiple simultaneous requests const debouncedRevalidate = debounce(async () => { // Prevent multiple simultaneous background revalidations if (isBackgroundRevalidationInProgress) { console.warn("Background revalidation already in progress, skipping"); return; } try { isBackgroundRevalidationInProgress = true; // Quick health check before attempting revalidation const apiService = createApiService(storeService); const healthCheck = await apiService.checkHealth(); if (healthCheck.isHealthy) { await apiService.categories.revalidateCache(); } else { console.warn("Server unhealthy, skipping background categories revalidation"); } } catch (error) { // Only log background revalidation errors, don't throw them console.warn("Background revalidation failed (this is non-critical):", error); } finally { isBackgroundRevalidationInProgress = false; } }, CACHE_REVALIDATION_DEBOUNCE); debouncedRevalidate(); return cached.data; } // If we have no cache, cache for different plan, or missing tier info, fetch fresh data console.log(`[ApiService] No valid cache for categories (${licensePlan} plan), fetching fresh data`); const freshData = await createApiService(storeService).categories.fetchFresh(); return freshData; // Already has direct URLs and tier info } catch (error) { console.error("Error getting categories:", error); // If we have any cached data for current plan with tier info, return it as fallback const cached = storeService.get<"categories">("categories"); const licenseValidation = storeService.get<"license-validation">("license-validation"); const licensePlan = licenseValidation?.plan || "free"; const hasTierInfo = cached?.data?.length > 0 && cached.data[0]?.styles?.[0]?.tier !== undefined; if (cached?.data?.length > 0 && cached?.licensePlan === licensePlan && hasTierInfo) { // console.log(`[ApiService] Error fetching categories, returning from cache as fallback for ${licensePlan} plan`); return cached.data; // Already has direct URLs and tier info } // Only throw error if we have no cache to fall back on handleAxiosError(error as AxiosError, "getCategories"); } }, /** * Fetch fresh categories data and update cache with retry logic */ fetchFresh: async (retryCount = 0): Promise<Category[]> => { try { // Get license information to pass to API const licenseValidation = storeService.get<"license-validation">("license-validation"); const licensePlan = licenseValidation?.plan || "free"; console.log(`[ApiService] Fetching fresh categories for ${licensePlan} plan...`); const response = await axios.get<Category[]>("/categories", { params: { license_plan: licensePlan }, }); console.log( `[ApiService] Raw response from server for categories (${licensePlan} plan):`, JSON.stringify(response.data, null, 2), ); // Data from backend should now have direct preview_urls for categories and styles const data = response.data; // console.log("[ApiService] Processed categories with direct URLs:", JSON.stringify(data, null, 2)); // Update cache with license plan information storeService.set<"categories">("categories", { data, timestamp: Date.now(), licensePlan: licensePlan, }); return data; } catch (error) { console.error("Error fetching fresh categories:", error); // Implement retry logic if (retryCount < MAX_CACHE_RETRIES) { console.log(`Retrying fetch (attempt ${retryCount + 1}/${MAX_CACHE_RETRIES})...`); await new Promise((resolve) => setTimeout(resolve, 1000 * (retryCount + 1))); // Exponential backoff return createApiService(storeService).categories.fetchFresh(retryCount + 1); } throw error; } }, /** * Revalidate cache in the background without blocking */ revalidateCache: async (): Promise<void> => { try { // Get license information to pass to API const licenseValidation = storeService.get<"license-validation">("license-validation"); const licensePlan = licenseValidation?.plan || "free"; const response = await axios.get<Category[]>("/categories", { params: { license_plan: licensePlan }, }); // Data from backend should now have direct preview_urls const data = response.data; // Update cache with license plan information storeService.set<"categories">("categories", { data, timestamp: Date.now(), licensePlan: licensePlan, }); console.log(`Categories cache revalidated successfully for ${licensePlan} plan`); } catch (error) { // Background revalidation errors should be silent and non-blocking console.warn("Error revalidating categories cache (background operation):", error); // Just log the error but don't throw - this is a background operation // and shouldn't affect the user experience } }, /** * Fetch a specific category by ID */ get: async (categoryId: string): Promise<Category> => { try { // Get license information const licenseValidation = storeService.get<"license-validation">("license-validation"); const licensePlan = licenseValidation?.plan || "free"; // Check if we have this category in the cache for the current plan const cached = storeService.get<"categories">("categories"); if (cached?.data?.length > 0 && cached?.licensePlan === licensePlan) { const cachedCategory = cached.data.find((cat: Category) => cat.id === categoryId); if (cachedCategory) { return cachedCategory; } } // Otherwise fetch it from the API with license plan const response = await axios.get<Category>(`/categories/${categoryId}`, { params: { license_plan: licensePlan }, }); return response.data; } catch (error) { console.error(`Error fetching category ${categoryId}:`, error); throw error; } }, }, wallpapers: { /** * Fetch wallpapers with optional pagination */ getMany: async (nextToken?: string) => { try { const params = nextToken ? { max_items: 30, page_size: 30, continuation_token: nextToken } : { max_items: 30, page_size: 30 }; const response = await axios.get<WallpapersResponse>("/wallpapers", { params }); // Add imageUrl to each wallpaper const wallpapersWithUrls = response.data.wallpapers.map((wallpaper) => ({ ...wallpaper, imageUrl: getWallpaperImageUrl(wallpaper.key), })); return { ...response.data, wallpapers: wallpapersWithUrls, }; } catch (error) { console.error("Error fetching wallpapers:", error); throw error; } }, /** * Get metadata for a specific wallpaper */ getMetadata: async (objectKey: string) => { try { // Extract just the filename part if it includes a path const parts = objectKey.split("/"); const filename = parts[parts.length - 1]; console.log( `[ApiService] Fetching metadata for ${filename} from ${axios.defaults.baseURL}/wallpapers/metadata/${filename}`, ); const response = await axios.get(`/wallpapers/metadata/${filename}`); // Log the actual response data to see what we're getting console.log(`[ApiService] Metadata response for ${filename}:`, JSON.stringify(response.data, null, 2)); if (response.data && !response.data.style) { console.warn(`[ApiService] No style found in metadata response for ${filename}`); } // If response has a message about no metadata, return a better default if (response.data && response.data.message === "No metadata found for this image") { console.warn(`[ApiService] No metadata found for ${filename}, using default metadata`); // Try to extract style from object key if possible let inferredStyle = "Unknown"; // Check for dash-separated style (many generated images use {Style}-{type}-{uuid} format) if (filename.includes("-")) { const stylePart = filename.split("-")[0]; if (stylePart && stylePart.length > 0) { inferredStyle = stylePart; console.log(`[ApiService] Inferred style "${inferredStyle}" from filename using dash pattern`); } } // Fallback to underscore pattern if no dash pattern match else if (filename.includes("_")) { inferredStyle = filename.split("_")[0]; console.log(`[ApiService] Inferred style "${inferredStyle}" from filename using underscore pattern`); } return { style: inferredStyle, created_at: new Date().toISOString(), inferred: true, }; } return response.data; } catch (error) { console.error(`[ApiService] Error fetching metadata for ${objectKey}:`, error); if (error.response) { console.error(`[ApiService] Error status: ${error.response.status}, data:`, error.response.data); } // Try to extract style from object key as a fallback const parts = objectKey.split("/"); const filename = parts[parts.length - 1]; let inferredStyle = "Unknown"; // Check for dash-separated style (many generated images use {Style}-{type}-{uuid} format) if (filename.includes("-")) { const stylePart = filename.split("-")[0]; if (stylePart && stylePart.length > 0) { inferredStyle = stylePart; console.log( `[ApiService] Inferred style "${inferredStyle}" from filename using dash pattern (error fallback)`, ); } } // Fallback to underscore pattern if no dash pattern match else if (filename.includes("_")) { inferredStyle = filename.split("_")[0]; console.log( `[ApiService] Inferred style "${inferredStyle}" from filename using underscore pattern (error fallback)`, ); } return { style: inferredStyle, created_at: new Date().toISOString(), inferred: true, }; } }, /** * Generate a new wallpaper */ generate: async (requestData?: { style?: string; custom_style?: string; location_data?: WallpaperLocationData; mode?: string; }): Promise<WallpaperGenerationResponse> => { try { console.log(`[ApiService] Sending generate wallpaper request`, requestData); // Add a unique client ID to each request to track what images each client has seen // Get a device ID from store or generate a new one if it doesn't exist const deviceIdData = storeService.get<"device-id">("device-id"); const deviceId = deviceIdData?.data || crypto.randomUUID(); // Log detailed device ID information if (deviceIdData?.data) { console.log(`[ApiService] Using existing device ID: ${deviceId.substring(0, 8)}...`); } else { console.log(`[ApiService] Generated new device ID: ${deviceId.substring(0, 8)}...`); } // Store the device ID if it's new if (!deviceIdData) { storeService.set<"device-id">("device-id", { data: deviceId, timestamp: Date.now(), // Keep as numeric timestamp for compatibility }); console.log(`[ApiService] Saved new device ID to electron-store`); } // Get license information to include with the request const licenseKey = storeService.get<"license-key">("license-key"); const licenseValidation = storeService.get<"license-validation">("license-validation"); // Check if we need to refresh the license validation if ( licenseKey && (!licenseValidation?.last_checked || Date.now() - licenseValidation.last_checked > LICENSE_CHECK_INTERVAL) ) { // Don't await this - we'll validate asynchronously console.log(`[ApiService] Starting background license validation`); validateLicense(storeService).catch((err) => { console.error("[ApiService] Background license validation failed:", err); }); } // Process location_data to ensure timestamps are properly formatted for API let processedLocationData = requestData?.location_data; if (processedLocationData && typeof processedLocationData === "object") { // Ensure local_time_str is a valid ISO string if (processedLocationData.local_time_str) { // If it's not already a valid ISO string (might be a different string format), // try to convert it to one if (!isValidISOString(processedLocationData.local_time_str)) { try { // Try to parse and then format as ISO processedLocationData = { ...processedLocationData, local_time_str: new Date(processedLocationData.local_time_str).toISOString(), }; console.log( `[ApiService] Converted local_time_str to ISO format: ${processedLocationData.local_time_str}`, ); } catch (err) { console.error( `[ApiService] Failed to convert local_time_str to ISO format: ${processedLocationData.local_time_str}`, err, ); // If conversion fails, provide a fallback processedLocationData = { ...processedLocationData, local_time_str: new Date().toISOString(), }; } } } else { // If no local_time_str is provided, add current time processedLocationData = { ...processedLocationData, local_time_str: new Date().toISOString(), }; } } // Get custom API key and selected model from store const customApiKey = storeService.get<"custom-replicate-api-key">("custom-replicate-api-key"); const selectedModel = storeService.get<"selected-image-model">("selected-image-model"); // Include the client_id, license info, custom API key, and model selection in the request const requestWithClientData = { ...requestData, location_data: processedLocationData, client_id: deviceId, license_key: licenseKey || "", license_plan: licenseValidation?.plan || "free", model_provider: selectedModel || "recraft-v3", custom_replicate_api_key: customApiKey || "", }; console.log(`[ApiService] Request will include client_id: ${deviceId.substring(0, 8)}...`); if (processedLocationData && processedLocationData !== requestData?.location_data) { console.log(`[ApiService] Processed location data: ${JSON.stringify(processedLocationData)}`); } console.log( `[ApiService] Attempting POST to /wallpapers/generate with data:`, JSON.stringify(requestWithClientData, null, 2), ); const response = await axios.post<WallpaperGenerationRawResponse>( "/wallpapers/generate", requestWithClientData || null, ); // Return the full response data, including filename and object_key return { ...response.data, objectKey: response.data.object_key, }; } catch (error) { return handleAxiosError(error, "generateWallpaper"); } }, /** * Upscale an existing wallpaper */ upscale: async (params: { object_key: string; model_provider: string; license_key: string; license_plan: string; client_id: string; }) => { try { // Get custom API key if available const customApiKey = storeService.get<"custom-replicate-api-key">("custom-replicate-api-key"); console.log(`[ApiService] Sending upscale request for ${params.object_key}`); console.log(`[ApiService] Upscale request includes client_id: ${params.client_id.substring(0, 8)}...`); console.log(`[ApiService] Using upscale provider: ${params.model_provider}`); const response = await axios.post<WallpaperUpscaleResponse>("/wallpapers/upscale", null, { params: { object_key: params.object_key, license_key: params.license_key, license_plan: params.license_plan, client_id: params.client_id, model_provider: params.model_provider, custom_replicate_api_key: customApiKey || "", }, }); const data = response.data; const upscaledR2Key = data.object_key; const imageUrl = getWallpaperImageUrl(upscaledR2Key); // Ensure URL has a valid protocol let validImageUrl = imageUrl; if (imageUrl && !imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) { console.warn(`[ApiService] Upscaled image URL (${imageUrl}) from getWallpaperImageUrl lacks protocol.`); if (axios.defaults.baseURL) { try { const baseUrlObj = new URL(axios.defaults.baseURL); validImageUrl = `${baseUrlObj.origin}${imageUrl.startsWith("/") ? imageUrl : `/${imageUrl}`}`; console.log(`[ApiService] Fixed upscaled URL to: ${validImageUrl}`); } catch (urlError) { console.error(`[ApiService] Error parsing base URL for fallback: ${axios.defaults.baseURL}`, urlError); validImageUrl = imageUrl; } } else { validImageUrl = `https://${imageUrl}`; console.warn(`[ApiService] Blindly prepended https to: ${validImageUrl}`); } } console.log(`[ApiService] Created upscaled image URL: ${validImageUrl} for key: ${data.object_key}`); return { ...data, imageUrl: validImageUrl, }; } catch (error) { console.error("Error upscaling wallpaper:", error); throw error; } }, }, /** * Submit user feedback to the server */ feedback: { submit: async (content: string, userId?: string, rating?: number, category?: string) => { try { const response = await axios.post("/feedback", { user_id: userId, content, rating, category, }); return response.data; } catch (error) { console.error("Error submitting feedback:", error); return handleAxiosError(error, "submitFeedback"); } }, }, }); export default createApiService;