a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
at main 169 lines 3.9 kB view raw
1// subsonic api client 2 3class SubsonicAPI { 4 constructor(serverUrl, username, token, salt) { 5 this.serverUrl = serverUrl.replace(/\/$/, ""); 6 this.username = username; 7 this.token = token; 8 this.salt = salt; 9 this.clientName = "tinysub"; 10 this.apiVersion = "1.16.1"; 11 this.requestTimeout = 30000; 12 } 13 14 // build auth params from stored token+salt 15 getAuthParams() { 16 return { 17 u: this.username, 18 t: this.token, 19 s: this.salt, 20 c: this.clientName, 21 v: this.apiVersion, 22 }; 23 } 24 25 // build authenticated REST API URL with params 26 _buildAuthUrl(method, params = {}) { 27 return `${this.serverUrl}/rest/${method}?${new URLSearchParams({ 28 ...this.getAuthParams(), 29 ...params, 30 })}`; 31 } 32 33 // make request to subsonic rest api with error handling 34 async request(method, params = {}) { 35 if (!method || typeof method !== "string" || method.trim().length === 0) { 36 throw new Error("API method is required"); 37 } 38 39 const queryParams = new URLSearchParams({ 40 ...this.getAuthParams(), 41 f: "json", 42 ...params, 43 }); 44 45 const controller = new AbortController(); 46 const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); 47 48 try { 49 const response = await fetch( 50 `${this.serverUrl}/rest/${method}?${queryParams}`, 51 { signal: controller.signal }, 52 ); 53 54 if (!response.ok) { 55 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 56 } 57 58 const data = await response.json(); 59 60 if (data["subsonic-response"]?.status === "ok") { 61 return data["subsonic-response"]; 62 } 63 throw new Error(data["subsonic-response"]?.error?.message || "API error"); 64 } catch (error) { 65 if (error.name === "AbortError") { 66 const timeoutError = new Error( 67 `Request timeout after ${this.requestTimeout / 1000}s`, 68 ); 69 console.error(`[API] ${method}:`, timeoutError.message); 70 throw timeoutError; 71 } 72 const err = new Error(`Request failed: ${error.message}`); 73 console.error(`[API] ${method}:`, error); 74 throw err; 75 } finally { 76 clearTimeout(timeoutId); 77 } 78 } 79 80 ping() { 81 return this.request("ping.view"); 82 } 83 84 getArtists() { 85 return this.request("getArtists.view"); 86 } 87 88 _validateAndRequest(id, method, params, context) { 89 const validation = validateId(id, context); 90 if (!validation.valid) throw new Error(validation.error); 91 return this.request(method, { ...params, id }); 92 } 93 94 _validateAndBuildUrl(id, method, params, context) { 95 const validation = validateId(id, context); 96 if (!validation.valid) throw new Error(validation.error); 97 return this._buildAuthUrl(method, { ...params, id }); 98 } 99 100 getArtist(id) { 101 return this._validateAndRequest(id, "getArtist.view", {}, "Artist ID"); 102 } 103 104 getAlbum(id) { 105 return this._validateAndRequest(id, "getAlbum.view", {}, "Album ID"); 106 } 107 108 getPlaylists() { 109 return this.request("getPlaylists.view"); 110 } 111 112 getPlaylist(id) { 113 return this._validateAndRequest(id, "getPlaylist.view", {}, "Playlist ID"); 114 } 115 116 getStreamUrl(id) { 117 return this._validateAndBuildUrl(id, "stream.view", {}, "Stream ID"); 118 } 119 120 getCoverArtUrl(id, size = 64) { 121 return this._validateAndBuildUrl( 122 id, 123 "getCoverArt.view", 124 { size }, 125 "Cover Art ID", 126 ); 127 } 128 129 scrobble(id) { 130 return this._validateAndRequest(id, "scrobble.view", {}, "Scrobble ID"); 131 } 132 133 nowPlaying(id) { 134 return this._validateAndRequest( 135 id, 136 "scrobble.view", 137 { submission: false }, 138 "Song ID", 139 ); 140 } 141 142 getStarred2() { 143 return this.request("getStarred2.view"); 144 } 145 146 star(id) { 147 return this._validateAndRequest(id, "star.view", {}, "Song ID"); 148 } 149 150 unstar(id) { 151 return this._validateAndRequest(id, "unstar.view", {}, "Song ID"); 152 } 153 154 getLyricsBySongId(id) { 155 return this._validateAndRequest( 156 id, 157 "getLyricsBySongId.view", 158 {}, 159 "Song ID", 160 ); 161 } 162 163 getLyrics(artist, title) { 164 if (!artist || !title) { 165 throw new Error("Artist and title are required for getLyrics"); 166 } 167 return this.request("getLyrics.view", { artist, title }); 168 } 169}