export interface Credentials { server: string; authMode?: "token" | "password" | "apikey"; username?: string; token?: string; salt?: string; password?: string; apiKey?: string; } export const credentialsStorageKey = "tinysub_credentials"; export const normalizeServer = (server: string) => server.trim().replace(/\/+$/, ""); const clientIdentity = { v: "1.16.1", c: "tinysub", f: "json", } as const; export const getStoredCredentials = (): Credentials | null => { if (typeof localStorage === "undefined") return null; const raw = localStorage.getItem(credentialsStorageKey); if (!raw) return null; try { const parsed: unknown = JSON.parse(raw); return parsed && typeof parsed === "object" && "server" in parsed && typeof parsed.server === "string" && parsed.server ? (parsed as Credentials) : null; } catch (err) { console.error("failed to parse stored credentials:", err); return null; } }; const env = import.meta.env; const server = normalizeServer(env.VITE_TINYSUB_SERVER || ""); export const deploymentConfig = { server, name: env.VITE_TINYSUB_NAME?.trim() || "", serverLock: !!server && env.VITE_TINYSUB_SERVER_LOCK?.toLowerCase() === "true", username: env.VITE_TINYSUB_USERNAME?.trim() || "", password: env.VITE_TINYSUB_PASSWORD || "", }; export const buildApiParams = ( credentials: Credentials, params: Record = {}, ) => { const authMode = credentials.authMode || (credentials.apiKey ? "apikey" : credentials.password ? "password" : "token"); const search = new URLSearchParams({ ...clientIdentity }); if (authMode === "apikey") { search.set("apiKey", credentials.apiKey || ""); } else { search.set("u", credentials.username || ""); if (authMode === "password") search.set("p", credentials.password || ""); else { search.set("t", credentials.token || ""); search.set("s", credentials.salt || ""); } } for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { for (const item of value) search.append(key, String(item)); } else if (value !== undefined) { search.append(key, String(value)); } } return search; }; export const createToken = async (password: string) => { const salt = Math.random().toString(36).substring(2, 8); const [{ md5 }, { bytesToHex, utf8ToBytes }] = await Promise.all([ import("@noble/hashes/legacy.js"), import("@noble/hashes/utils.js"), ]); return { token: bytesToHex(md5(utf8ToBytes(password + salt))), salt }; }; const anonymousRequest = async (server: string, method: string) => { server = normalizeServer(server); const response = await fetch( `${server}/rest/${method}?${new URLSearchParams({ ...clientIdentity })}`, ); return (await response.json())["subsonic-response"]; }; export const anonymousPing = (server: string) => anonymousRequest(server, "ping"); export const anonymousGetExtensions = (server: string) => anonymousRequest(server, "getOpenSubsonicExtensions"); export class ApiError extends Error { constructor( message: string, readonly code?: number, readonly httpStatus?: number, ) { super(message); this.name = "ApiError"; } } export const isTimeoutError = (err: unknown) => err instanceof DOMException && (err.name === "TimeoutError" || err.name === "AbortError"); export const isNetworkError = (err: unknown) => { if (isTimeoutError(err)) return true; if (err instanceof ApiError && err.httpStatus !== undefined) return err.httpStatus >= 500 && err.httpStatus < 600; const message = String(err); return ( message.includes("Failed to fetch") || message.includes("Load failed") || message.includes("NetworkError") || message.includes("ERR_INTERNET_DISCONNECTED") ); }; export const isAuthenticationError = (err: unknown) => err instanceof ApiError && (err.httpStatus === 401 || [40, 41, 42, 43, 44].includes(err.code ?? -1));