import type { AdminReportDTO, AdminStatsDTO, CommentDTO, CommentThreadDTO, ConnectionDTO, FollowListDTO, ReactionDTO, FeedItemDTO, MeDTO, ProfileDTO, TrackSearchResultDTO, UserSummaryDTO, } from './dto'; import type { DeviceDTO } from './devices'; import type { LyricsDTO } from './lyrics'; import type { NotificationListDTO } from './notifications'; import type { AppUpdateDTO } from './updates'; import type { ChangeEmailInput, ChangePasswordInput, CommentInput, LoginInput, RegisterInput, ReportReason, ResetPasswordInput, SetFeaturedInput, UpdateMeInput, PushSubscriptionInput, SendSongInput, } from './schemas'; export class ApiError extends Error { constructor( public status: number, message: string, ) { super(message); } } export interface ApiClientOptions { baseUrl: string; /** Bearer token (mobile). Web relies on the session cookie instead. */ getToken?: () => string | null | undefined; /** 'include' for browsers talking to the API cross-origin. */ credentials?: 'include' | 'same-origin' | 'omit'; } export type AuthResponse = { user: MeDTO; token: string }; /** Typed client for the musicshare API, shared by web and mobile. */ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOptions) { async function request(path: string, init: { method?: string; body?: unknown } = {}): Promise { const headers: Record = {}; const token = getToken?.(); if (token) headers.Authorization = `Bearer ${token}`; if (init.body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(baseUrl + path, { method: init.method ?? (init.body !== undefined ? 'POST' : 'GET'), headers, credentials, body: init.body !== undefined ? JSON.stringify(init.body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Request failed (${res.status})`); return data as T; } /** Multipart upload (the browser/React Native sets the boundary itself). */ async function upload(path: string, form: FormData): Promise { const headers: Record = {}; const token = getToken?.(); if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(baseUrl + path, { method: 'POST', headers, credentials, body: form }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Upload failed (${res.status})`); return data as T; } const q = encodeURIComponent; return { request, /** For SWR-style fetchers keyed by path. */ get: (path: string) => request(path), register: (input: RegisterInput) => request('/auth/register', { body: input }), login: (input: LoginInput) => request('/auth/login', { body: input }), /** Always succeeds, so it doesn't reveal which emails have accounts. */ forgotPassword: (email: string) => request<{ ok: true }>('/auth/forgot-password', { body: { email } }), resetPassword: (input: ResetPasswordInput) => request('/auth/reset-password', { body: input }), verifyEmail: (token: string) => request<{ ok: true; email: string }>('/auth/verify-email', { body: { token } }), resendVerification: () => request<{ ok: true }>('/me/verify-email/resend', { body: {} }), /** Signs out your other devices. */ changePassword: (input: ChangePasswordInput) => request<{ ok: true }>('/me/password', { body: input }), /** Sends a confirmation link to the new address; the email changes once it's clicked. */ changeEmail: (input: ChangeEmailInput) => request('/me/email', { body: input }), cancelEmailChange: () => request('/me/email/pending', { method: 'DELETE' }), logout: () => request('/auth/logout', { method: 'POST', body: {} }), me: () => request('/me'), updateMe: (input: UpdateMeInput) => request('/me', { method: 'PATCH', body: input }), deleteAccount: () => request('/me', { method: 'DELETE' }), /** `form` holds one file field (any name): a File on web, `{ uri, name, type }` on React Native. */ uploadAvatar: (form: FormData) => upload('/me/avatar', form), removeAvatar: () => request('/me/avatar', { method: 'DELETE' }), setSharing: (pause: '1h' | '8h' | 'indefinitely' | null) => request('/me/sharing', { method: 'PUT', body: { pause } }), setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }), clearFeatured: () => request('/me/featured', { method: 'DELETE' }), feed: () => request('/feed'), profile: (username: string) => request(`/users/${q(username)}`), searchUsers: (query: string) => request(`/users/search?q=${q(query)}`), follow: (username: string) => request(`/users/${q(username)}/follow`, { method: 'POST', body: {} }), unfollow: (username: string) => request(`/users/${q(username)}/follow`, { method: 'DELETE' }), pin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'PUT', body: {} }), unpin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'DELETE' }), followers: (username: string, cursor?: string | null) => request(`/users/${q(username)}/followers${cursor ? `?cursor=${q(cursor)}` : ''}`), following: (username: string, cursor?: string | null) => request(`/users/${q(username)}/following${cursor ? `?cursor=${q(cursor)}` : ''}`), /** Stop someone following you (they aren't told). */ removeFollower: (username: string) => request(`/me/followers/${q(username)}`, { method: 'DELETE' }), block: (username: string) => request(`/users/${q(username)}/block`, { method: 'POST', body: {} }), unblock: (username: string) => request(`/users/${q(username)}/block`, { method: 'DELETE' }), blocks: () => request('/me/blocks'), report: (username: string, input: { reason: ReportReason; note?: string | null }) => request(`/users/${q(username)}/report`, { body: input }), adminStats: () => request('/admin/stats'), adminReports: (status: 'open' | 'resolved' = 'open') => request(`/admin/reports?status=${status}`), resolveReport: (id: string) => request(`/admin/reports/${q(id)}/resolve`, { method: 'POST', body: {} }), suspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'POST', body: {} }), unsuspend: (username: string) => request(`/admin/users/${q(username)}/suspend`, { method: 'DELETE' }), /** React to a play with one emoji (replaces your earlier one). Returns that play's reactions. */ react: (listenId: string, emoji: string) => request(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }), unreact: (listenId: string) => request(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }), /** A play with its comments. */ comments: (listenId: string) => request(`/listens/${q(listenId)}/comments`), addComment: (listenId: string, input: CommentInput) => request(`/listens/${q(listenId)}/comments`, { method: 'POST', body: input }), deleteComment: (id: string) => request<{ ok: true }>(`/comments/${q(id)}`, { method: 'DELETE' }), reportComment: (id: string, input: { reason: ReportReason; note?: string | null }) => request<{ ok: true }>(`/comments/${q(id)}/report`, { body: input }), searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), /** Null when no lyrics were found for the song. */ lyrics: (trackId: string) => request(`/tracks/${q(trackId)}/lyrics`), /** Send a song to people who follow you; returns who it went to. */ sendSong: (input: SendSongInput) => request<{ sentTo: string[] }>('/shares', { method: 'POST', body: input }), notifications: (cursor?: string | null) => request(`/notifications${cursor ? `?cursor=${q(cursor)}` : ''}`), markNotificationsRead: () => request<{ ok: true }>('/notifications/read', { method: 'POST', body: {} }), /** The server's public key for push subscriptions (VAPID). */ pushKey: () => request<{ publicKey: string }>('/push/key'), addPushSubscription: (input: PushSubscriptionInput) => request<{ ok: true }>('/me/push', { method: 'PUT', body: input }), removePushSubscription: (endpoint: string) => request<{ ok: true }>('/me/push', { method: 'DELETE', body: { endpoint } }), connections: () => request('/connections'), authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => request<{ url: string }>(`/connections/${provider}/authorize`, { body }), connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) => request(`/connections/${provider}`, { body: { username } }), appleMusicDeveloperToken: () => request<{ token: string }>('/connections/apple-music/developer-token'), connectAppleMusic: (musicUserToken: string) => request('/connections/apple-music', { body: { musicUserToken } }), connectJellyfin: (input: { serverUrl: string; username: string; password: string }) => request('/connections/jellyfin', { body: input }), disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }), /** The newest Android release, or null when the server doesn't offer updates. */ androidUpdate: () => request('/app/android/latest'), devices: () => request('/me/devices'), /** The token is returned once; it can only report playback for this user. */ registerDevice: (input: { platform: 'android' | 'ios'; name: string }) => request<{ device: DeviceDTO; token: string }>('/me/devices', { body: input }), removeDevice: (id: string) => request(`/me/devices/${q(id)}`, { method: 'DELETE' }), }; } export type ApiClient = ReturnType;