/** * IndeeHub Self-Hosted API Service * * Dedicated API client for our self-hosted NestJS backend. * Uses the /api/ proxy configured in nginx. * Auth tokens are managed by nip98.service.ts. */ import axios, { type AxiosInstance } from 'axios' import { indeehubApiConfig } from '../config/api.config' import { nip98Service } from './nip98.service' class IndeehubApiService { private client: AxiosInstance constructor() { this.client = axios.create({ baseURL: indeehubApiConfig.baseURL, timeout: indeehubApiConfig.timeout, headers: { 'Content-Type': 'application/json', }, }) // Attach JWT token from NIP-98 session if available. // Refresh failures are caught so public endpoints still work // without auth (e.g. GET /projects after switching users). this.client.interceptors.request.use(async (config) => { let token = nip98Service.accessToken if (!token && sessionStorage.getItem('indeehub_api_refresh')) { try { token = await nip98Service.refresh() } catch { // Refresh failed (stale token, wrong user, etc.) // Continue without auth — public endpoints don't need it } } if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // Auto-refresh on 401 (fallback if the proactive refresh above // didn't happen or the token expired mid-flight). // Refresh failures are caught so the original error propagates // cleanly instead of masking it with a refresh error. this.client.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true try { const newToken = await nip98Service.refresh() if (newToken) { originalRequest.headers.Authorization = `Bearer ${newToken}` return this.client(originalRequest) } } catch { // Refresh failed — fall through to reject the original error } } return Promise.reject(error) } ) } /** * Generic typed GET request */ async get(url: string): Promise { const response = await this.client.get(url) return response.data } /** * Generic typed POST request */ async post(url: string, data?: any): Promise { const response = await this.client.post(url, data) return response.data } /** * Generic typed PATCH request */ async patch(url: string, data?: any): Promise { const response = await this.client.patch(url, data) return response.data } /** * Generic typed DELETE request */ async delete(url: string): Promise { const response = await this.client.delete(url) return response.data } /** * Check if the API is reachable */ async healthCheck(): Promise { try { await this.client.get('/nostr-auth/health', { timeout: 5000 }) return true } catch { return false } } /** * Get all published projects. * Pass `fresh: true` to bypass the backend's 5-minute cache * (e.g. after login or content source switch) so newly published * backstage content appears immediately. */ async getProjects(filters?: { status?: string type?: string genre?: string limit?: number offset?: number fresh?: boolean }): Promise { const params = new URLSearchParams() if (filters?.status) params.append('status', filters.status) if (filters?.type) params.append('type', filters.type) if (filters?.genre) params.append('genre', filters.genre) if (filters?.limit) params.append('limit', String(filters.limit)) if (filters?.offset) params.append('offset', String(filters.offset)) if (filters?.fresh) params.append('_t', String(Date.now())) const url = `/projects${params.toString() ? `?${params.toString()}` : ''}` const response = await this.client.get(url) return response.data } /** * Get a single project by ID */ async getProject(id: string): Promise { const response = await this.client.get(`/projects/${id}`) return response.data } /** * Get streaming URL for a content item. * Returns different data based on deliveryMode (native vs partner). * * @param contentId - The content or project ID * @param format - Optional format override. Pass 'raw' to skip HLS and * get a presigned URL to the original file (useful as a * fallback when HLS decryption fails). */ async getStreamingUrl(contentId: string, format?: string): Promise<{ url: string deliveryMode: 'native' | 'partner' keyUrl?: string drmToken?: string }> { const params = format ? { format } : undefined const response = await this.client.get(`/contents/${contentId}/stream`, { params }) return response.data } /** * Get user's library items */ async getLibrary(): Promise { const response = await this.client.get('/library') return response.data } /** * Add a project to user's library */ async addToLibrary(projectId: string): Promise { const response = await this.client.post('/library', { projectId }) return response.data } /** * Remove a project from user's library */ async removeFromLibrary(projectId: string): Promise { await this.client.delete(`/library/${projectId}`) } /** * Get current user profile (requires auth) */ async getMe(): Promise { const response = await this.client.get('/auth/me') return response.data } /** * Get genres */ async getGenres(): Promise { const response = await this.client.get('/genres') return response.data } /** * Get zap stats for film cards (count, amount, recent zapper pubkeys) by project id. * Mock data only in dev so the UI can be tested; production shows only real backend data. */ async getZapStats(projectIds: string[]): Promise> { if (projectIds.length === 0) return {} const ids = projectIds.join(',') let data: Record = {} try { const response = await this.client.get>( '/zaps/stats', { params: { projectIds: ids } }, ) data = response.data ?? {} } catch (err) { if (import.meta.env.DEV) { console.warn('[getZapStats] API failed, using empty stats:', err) } data = {} } if (import.meta.env.DEV) { Object.assign(data, this.getMockZapStats(projectIds)) } return data } /** * Mock zap stats for dev so cards and modal show the zap UI. * Uses fake pubkeys so robohash avatars display. */ private getMockZapStats(projectIds: string[]): Record { const mockPubkeys = [ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'c7937c1d0f8d0a2e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a', ] const result: Record = {} const mockCounts = [3, 7, 1, 12, 2] const mockSats = [2100, 50000, 1000, 100000, 420] projectIds.slice(0, 5).forEach((id, i) => { result[id] = { zapCount: mockCounts[i % mockCounts.length], zapAmountSats: mockSats[i % mockSats.length], recentZapperPubkeys: mockPubkeys.slice(0, (i % 3) + 1), } }) return result } /** * Get the CDN URL for a storage path */ getCdnUrl(path: string): string { if (!path) return '' if (path.startsWith('http')) return path if (path.startsWith('/')) return path return `${indeehubApiConfig.cdnURL}/${path}` } } export const indeehubApiService = new IndeehubApiService()