// subsonic api client class SubsonicAPI { constructor(serverUrl, username, token, salt) { this.serverUrl = serverUrl.replace(/\/$/, ""); this.username = username; this.token = token; this.salt = salt; this.clientName = "tinysub"; this.apiVersion = "1.16.1"; this.requestTimeout = 30000; } // build auth params from stored token+salt getAuthParams() { return { u: this.username, t: this.token, s: this.salt, c: this.clientName, v: this.apiVersion, }; } // build authenticated REST API URL with params _buildAuthUrl(method, params = {}) { return `${this.serverUrl}/rest/${method}?${new URLSearchParams({ ...this.getAuthParams(), ...params, })}`; } // make request to subsonic rest api with error handling async request(method, params = {}) { if (!method || typeof method !== "string" || method.trim().length === 0) { throw new Error("API method is required"); } const queryParams = new URLSearchParams({ ...this.getAuthParams(), f: "json", ...params, }); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); try { const response = await fetch( `${this.serverUrl}/rest/${method}?${queryParams}`, { signal: controller.signal }, ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data["subsonic-response"]?.status === "ok") { return data["subsonic-response"]; } throw new Error(data["subsonic-response"]?.error?.message || "API error"); } catch (error) { if (error.name === "AbortError") { const timeoutError = new Error( `Request timeout after ${this.requestTimeout / 1000}s`, ); console.error(`[API] ${method}:`, timeoutError.message); throw timeoutError; } const err = new Error(`Request failed: ${error.message}`); console.error(`[API] ${method}:`, error); throw err; } finally { clearTimeout(timeoutId); } } ping() { return this.request("ping.view"); } getArtists() { return this.request("getArtists.view"); } _validateAndRequest(id, method, params, context) { const validation = validateId(id, context); if (!validation.valid) throw new Error(validation.error); return this.request(method, { ...params, id }); } _validateAndBuildUrl(id, method, params, context) { const validation = validateId(id, context); if (!validation.valid) throw new Error(validation.error); return this._buildAuthUrl(method, { ...params, id }); } getArtist(id) { return this._validateAndRequest(id, "getArtist.view", {}, "Artist ID"); } getAlbum(id) { return this._validateAndRequest(id, "getAlbum.view", {}, "Album ID"); } getPlaylists() { return this.request("getPlaylists.view"); } getPlaylist(id) { return this._validateAndRequest(id, "getPlaylist.view", {}, "Playlist ID"); } getStreamUrl(id) { return this._validateAndBuildUrl(id, "stream.view", {}, "Stream ID"); } getCoverArtUrl(id, size = 64) { return this._validateAndBuildUrl( id, "getCoverArt.view", { size }, "Cover Art ID", ); } scrobble(id) { return this._validateAndRequest(id, "scrobble.view", {}, "Scrobble ID"); } nowPlaying(id) { return this._validateAndRequest( id, "scrobble.view", { submission: false }, "Song ID", ); } getStarred2() { return this.request("getStarred2.view"); } star(id) { return this._validateAndRequest(id, "star.view", {}, "Song ID"); } unstar(id) { return this._validateAndRequest(id, "unstar.view", {}, "Song ID"); } getLyricsBySongId(id) { return this._validateAndRequest( id, "getLyricsBySongId.view", {}, "Song ID", ); } getLyrics(artist, title) { if (!artist || !title) { throw new Error("Artist and title are required for getLyrics"); } return this.request("getLyrics.view", { artist, title }); } }