Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144export 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<string, unknown> = {},) => { 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));