a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: api compatibility with all subsonic servers

works great in gonic now! havent tested other servers but they should be fine

also secured auth properly, and improved some other things like image cache

intergrav 8638fd91 9bc886b7

+149 -79
+35 -20
src/js/api.js
··· 1 1 // subsonic api client 2 2 3 3 class SubsonicAPI { 4 - constructor(serverUrl, username, password) { 4 + constructor(serverUrl, username, token, salt) { 5 5 this.serverUrl = serverUrl.replace(/\/$/, ""); 6 6 this.username = username; 7 - this.password = password; 7 + this.token = token; 8 + this.salt = salt; 8 9 this.clientName = "tinysub"; 9 - this.apiVersion = "1.12.0"; 10 + this.apiVersion = "1.16.1"; 10 11 this.requestTimeout = 30000; 11 12 } 12 13 13 - // generate random salt and hashed password for auth 14 + // build auth params from stored token+salt 14 15 getAuthParams() { 15 - const salt = Math.random().toString(36).substring(7); 16 - const token = SparkMD5.hash(this.password + salt); 17 16 return { 18 17 u: this.username, 19 - t: token, 20 - s: salt, 18 + t: this.token, 19 + s: this.salt, 21 20 c: this.clientName, 22 21 v: this.apiVersion, 23 22 }; ··· 51 50 `${this.serverUrl}/rest/${method}?${queryParams}`, 52 51 { signal: controller.signal }, 53 52 ); 53 + 54 + if (!response.ok) { 55 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 56 + } 57 + 54 58 const data = await response.json(); 55 59 56 - if (data["subsonic-response"].status === "ok") { 60 + if (data["subsonic-response"]?.status === "ok") { 57 61 return data["subsonic-response"]; 58 62 } 59 - throw new Error(data["subsonic-response"].error?.message || "API error"); 63 + throw new Error(data["subsonic-response"]?.error?.message || "API error"); 60 64 } catch (error) { 61 65 if (error.name === "AbortError") { 62 66 const timeoutError = new Error( ··· 77 81 return this.request("ping.view"); 78 82 } 79 83 80 - getIndexes() { 81 - return this.request("getIndexes.view"); 84 + getArtists() { 85 + return this.request("getArtists.view"); 82 86 } 83 87 84 88 _validateAndRequest(id, method, params, context) { ··· 87 91 return this.request(method, { ...params, id }); 88 92 } 89 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 + 90 100 getArtist(id) { 91 101 return this._validateAndRequest(id, "getArtist.view", {}, "Artist ID"); 92 102 } ··· 104 114 } 105 115 106 116 getStreamUrl(id) { 107 - const validation = validateId(id, "Stream ID"); 108 - if (!validation.valid) throw new Error(validation.error); 109 - return this._buildAuthUrl("stream.view", { id }); 117 + return this._validateAndBuildUrl(id, "stream.view", {}, "Stream ID"); 110 118 } 111 119 112 120 getCoverArtUrl(id, size = 64) { 113 - const validation = validateId(id, "Cover Art ID"); 114 - if (!validation.valid) throw new Error(validation.error); 115 - return this._buildAuthUrl("getCoverArt.view", { id, size }); 121 + return this._validateAndBuildUrl( 122 + id, 123 + "getCoverArt.view", 124 + { size }, 125 + "Cover Art ID", 126 + ); 116 127 } 117 128 118 129 scrobble(id) { 130 + return this._validateAndRequest(id, "scrobble.view", {}, "Scrobble ID"); 131 + } 132 + 133 + nowPlaying(id) { 119 134 return this._validateAndRequest( 120 135 id, 121 136 "scrobble.view", 122 - { submission: true }, 123 - "Scrobble ID", 137 + { submission: false }, 138 + "Song ID", 124 139 ); 125 140 } 126 141
+67 -10
src/js/auth.js
··· 1 1 // authorization 2 2 3 3 const CredentialManager = { 4 - save: (server, username, password) => { 5 - localStorage.setItem( 6 - "tinysub_credentials", 7 - JSON.stringify({ server, username, password }), 8 - ); 4 + save: (server, username, token, salt) => { 5 + const creds = { server, username, token, salt }; 6 + localStorage.setItem("tinysub_credentials", JSON.stringify(creds)); 9 7 }, 10 8 load: () => { 11 9 const saved = localStorage.getItem("tinysub_credentials"); 12 10 return saved 13 11 ? JSON.parse(saved) 14 - : { server: "", username: "", password: "" }; 12 + : { server: "", username: "", token: "", salt: "" }; 13 + }, 14 + clear: () => { 15 + localStorage.removeItem("tinysub_credentials"); 15 16 }, 16 17 }; 17 18 ··· 32 33 // load library, playlists, and favorites after successful login 33 34 async function initializeApp() { 34 35 toggleAuthModal(false); 36 + await loadQueue().catch(() => {}); 37 + updateQueueDisplay(); 35 38 await loadLibrary(); 36 39 await loadPlaylists(); 37 40 await loadFavorites(); 41 + // restore song display and pause playback on startup 42 + if (hasValidTrack()) { 43 + playTrack(state.queue[state.queueIndex]); 44 + ui.player.pause(); 45 + } 38 46 } 39 47 40 48 // handle login form submission with validation ··· 65 73 const validServerUrl = urlValidation.value; 66 74 67 75 try { 68 - state.api = new SubsonicAPI(validServerUrl, validUsername, validPassword); 69 - // test connection and initialize app 76 + // Generate salt+token from password 77 + const salt = Math.random().toString(36).substring(2, 8); 78 + const token = SparkMD5.hash(validPassword + salt); 79 + 80 + // Test with token mode API 81 + state.api = new SubsonicAPI(validServerUrl, validUsername, token, salt); 70 82 await state.api.ping(); 71 - CredentialManager.save(validServerUrl, validUsername, validPassword); 83 + 84 + // Save credentials for auto-login 85 + CredentialManager.save(validServerUrl, validUsername, token, salt); 86 + 72 87 await initializeApp(); 73 88 } catch (error) { 74 89 console.error("[Auth] Login failed:", error); ··· 88 103 indexedDB.deleteDatabase(db.name); 89 104 } 90 105 } 91 - localStorage.clear(); 106 + CredentialManager.clear(); 92 107 } catch (error) { 93 108 console.error("[Auth] Logout cleanup failed:", error); 94 109 } 95 110 location.reload(); 96 111 } 112 + 113 + // attempt auto-login with stored credentials on page load 114 + async function attemptAutoLogin() { 115 + const creds = CredentialManager.load(); 116 + 117 + // no stored credentials 118 + if (!creds.server || !creds.username || !creds.token || !creds.salt) { 119 + toggleAuthModal(true); 120 + return; 121 + } 122 + 123 + try { 124 + // try to authenticate with stored salt+token 125 + state.api = new SubsonicAPI( 126 + creds.server, 127 + creds.username, 128 + creds.token, 129 + creds.salt, 130 + ); 131 + // validate credentials are still valid by calling ping() 132 + await state.api.ping(); 133 + await initializeApp(); 134 + } catch (error) { 135 + console.warn( 136 + "[Auth] Stored credentials invalid, showing login form:", 137 + error.message, 138 + ); 139 + state.api = null; 140 + // pre-populate form with stored server and username for convenience 141 + ui.serverInput.value = creds.server; 142 + ui.usernameInput.value = creds.username; 143 + ui.passwordInput.value = ""; // never prefill password 144 + toggleAuthModal(true); 145 + } 146 + } 147 + 148 + // run auto-login when scripts are ready 149 + if (document.readyState === "loading") { 150 + document.addEventListener("DOMContentLoaded", attemptAutoLogin); 151 + } else { 152 + attemptAutoLogin(); 153 + }
-20
src/js/events.js
··· 190 190 selectionManager.virtualScroller = virtualScroller; 191 191 setupKeyboardShortcuts(); 192 192 setupMediaSessionHandlers(); 193 - 194 - // restore auto-login if credentials saved 195 - const credentials = CredentialManager.load(); 196 - if (credentials.server && credentials.username && credentials.password) { 197 - state.api = new SubsonicAPI( 198 - credentials.server, 199 - credentials.username, 200 - credentials.password, 201 - ); 202 - await loadQueue().catch(() => {}); 203 - updateQueueDisplay(); 204 - await initializeApp(); 205 - // restore song display and pause playback on startup 206 - if (hasValidTrack()) { 207 - playTrack(state.queue[state.queueIndex]); 208 - ui.player.pause(); 209 - } 210 - } else { 211 - toggleAuthModal(true); 212 - } 213 193 });
+13 -10
src/js/image-cache.js
··· 58 58 req.onsuccess = () => { 59 59 const result = req.result; 60 60 if (result && result.blob) { 61 - const blobUrl = URL.createObjectURL(result.blob); 62 - const oldUrl = this.cache.get(key); 63 - if (oldUrl) URL.revokeObjectURL(oldUrl); 64 - this.cache.set(key, blobUrl); 61 + const cached = this.cache.get(key); 62 + const blobUrl = cached || URL.createObjectURL(result.blob); 63 + if (!cached) this.cache.set(key, blobUrl); 65 64 resolve(blobUrl); 66 65 } else { 67 66 resolve(null); ··· 115 114 if (this.failed.has(key)) return url; 116 115 if (this.pending.has(key)) return this.pending.get(key); 117 116 118 - const fromDB = await this.getFromDB(key); 119 - if (fromDB) return fromDB; 117 + // create pending promise to prevent concurrent DB fetches for same key 118 + const promise = (async () => { 119 + const fromDB = await this.getFromDB(key); 120 + if (fromDB) return fromDB; 120 121 121 - const promise = new Promise((resolve) => 122 - this.queue.push({ url, key, resolve }), 123 - ); 122 + return new Promise((resolve) => { 123 + this.queue.push({ url, key, resolve }); 124 + this.processQueue(); 125 + }); 126 + })(); 127 + 124 128 this.pending.set(key, promise); 125 129 promise.finally(() => this.pending.delete(key)); 126 - this.processQueue(); 127 130 return promise; 128 131 } 129 132
+6 -6
src/js/library.js
··· 314 314 315 315 async function loadLibrary() { 316 316 return loadData({ 317 - fetcher: () => state.api.getIndexes(), 317 + fetcher: () => state.api.getArtists(), 318 318 transformer: (data) => 319 - data.indexes?.index?.flatMap((idx) => idx.artist || []) || [], 319 + data.artists?.index?.flatMap((idx) => toArray(idx.artist)) || [], 320 320 stateKey: "library", 321 321 renderFn: renderLibraryTree, 322 322 }); ··· 325 325 async function loadPlaylists() { 326 326 return loadData({ 327 327 fetcher: () => state.api.getPlaylists(), 328 - transformer: (data) => data.playlist || data.playlists?.playlist || [], 328 + transformer: (data) => toArray(data.playlist || data.playlists?.playlist), 329 329 stateKey: "playlists", 330 330 renderFn: renderPlaylistsTree, 331 331 }); ··· 333 333 334 334 async function loadFavorites() { 335 335 const data = await state.api.getStarred2(); 336 - const songs = data.starred2?.song || data.song || []; 336 + const songs = toArray(data.starred2?.song || data.song); 337 337 state.favorites.clear(); 338 338 songs.forEach((song) => state.favorites.add(song.id)); 339 339 updateQueueDisplay(); ··· 394 394 async function loadAndRenderAlbums(artistId, parentLi) { 395 395 return loadAndRenderItems({ 396 396 fetcher: () => state.api.getArtist(artistId), 397 - itemExtractor: (data) => data.artist?.album || [], 397 + itemExtractor: (data) => toArray(data.artist?.album), 398 398 parentLi, 399 399 ulClassName: CLASSES.NESTED, 400 400 artType: "artAlbum", ··· 421 421 async function loadAndRenderSongs(albumId, parentLi) { 422 422 return loadAndRenderItems({ 423 423 fetcher: () => state.api.getAlbum(albumId), 424 - itemExtractor: (data) => data.album?.song || [], 424 + itemExtractor: (data) => toArray(data.album?.song), 425 425 parentLi, 426 426 ulClassName: CLASSES.NESTED_SONGS, 427 427 artType: "artSong",
+16 -7
src/js/player.js
··· 12 12 document.title = `${song.artist || STRINGS.UNKNOWN_ARTIST} - ${song.title || STRINGS.UNKNOWN_TRACK}`; 13 13 { 14 14 const faviconLink = document.querySelector('link[rel="icon"]'); 15 - if (song?.coverArt && faviconLink) { 15 + // Prefer album art (albumId) over individual song art (coverArt) 16 + const artId = song?.albumId || song?.coverArt; 17 + if (artId && faviconLink) { 16 18 faviconLink.href = state.api.getCoverArtUrl( 17 - song.coverArt, 19 + artId, 18 20 state.settings.artNowPlaying, 19 21 ); 20 22 faviconLink.type = "image/jpeg"; ··· 23 25 faviconLink.type = "image/svg+xml"; 24 26 } 25 27 } 26 - if (song.coverArt) { 27 - loadCachedImage(ui.coverArt, song.coverArt, "artNowPlaying"); 28 + // Prefer album art (albumId) over individual song art (coverArt) 29 + const artId = song.albumId || song.coverArt; 30 + if (artId) { 31 + loadCachedImage(ui.coverArt, artId, "artNowPlaying"); 28 32 } else { 29 33 ui.coverArt.src = ""; 30 34 ui.coverArt.srcset = ""; 31 35 } 32 36 ui.player.src = state.api.getStreamUrl(song.id); 33 37 ui.player.play(); 38 + if (state.settings.scrobbling) { 39 + state.api.nowPlaying(song.id).catch(() => {}); // notify server track is now playing 40 + } 34 41 loadLyricsForSong(song); 35 42 highlightCurrentTrack(); 36 43 updateMediaSession(song); ··· 160 167 isValidQueueIndex(state.queueIndex, state.queue.length) && 161 168 state.settings.scrobbling 162 169 ) { 163 - state.api?.scrobble(state.queue[state.queueIndex].id).catch(() => {}); 170 + state.api.scrobble(state.queue[state.queueIndex].id).catch(() => {}); 164 171 } 165 172 state.queueIndex++; 166 173 if (state.queueIndex < state.queue.length) { ··· 190 197 const updateMediaSession = (song) => { 191 198 if (!song) return; 192 199 withMediaSession((ms) => { 200 + // Prefer album art (albumId) over individual song art (coverArt) 201 + const artId = song.albumId || song.coverArt; 193 202 ms.metadata = new MediaMetadata({ 194 203 title: song.title || STRINGS.UNKNOWN_TRACK, 195 204 artist: song.artist || STRINGS.UNKNOWN_ARTIST, 196 205 album: song.album || "", 197 - artwork: song.coverArt 206 + artwork: artId 198 207 ? [ 199 208 { 200 - src: state.api.getCoverArtUrl(song.coverArt, 256), 209 + src: state.api.getCoverArtUrl(artId, 256), 201 210 sizes: "512x512", 202 211 type: "image/jpeg", 203 212 },
+8 -6
src/js/queue.js
··· 198 198 199 199 const SONG_EXTRACTORS = { 200 200 artist: async (data) => { 201 - const albums = data.artist?.album || []; 201 + const albums = toArray(data.artist?.album); 202 202 const songArrays = await Promise.all( 203 203 albums.map((album) => 204 204 state.api 205 205 .getAlbum(album.id) 206 - .then((result) => result.album?.song || []) 206 + .then((result) => toArray(result.album?.song)) 207 207 .catch(() => []), 208 208 ), 209 209 ); 210 210 return songArrays.flat(); 211 211 }, 212 - album: (data) => data.album?.song || [], 213 - playlist: (data) => data.entry || data.playlist?.entry || [], 212 + album: (data) => toArray(data.album?.song), 213 + playlist: (data) => toArray(data.entry || data.playlist?.entry), 214 214 song: (s) => [s], 215 215 }; 216 216 ··· 393 393 394 394 // cover art cell 395 395 const coverCell = document.createElement("td"); 396 - if (song.coverArt && state.settings.artSong > 0) { 396 + // Prefer album art (albumId) over individual song art (coverArt) for consistency 397 + const artId = song.albumId || song.coverArt; 398 + if (artId && state.settings.artSong > 0) { 397 399 const img = document.createElement("img"); 398 400 img.className = CLASSES.QUEUE_COVER; 399 401 coverCell.appendChild(img); 400 - loadCachedImage(img, song.coverArt, "artSong"); 402 + loadCachedImage(img, artId, "artSong"); 401 403 } 402 404 cells.appendChild(coverCell); 403 405
+4
src/js/validation.js
··· 1 1 // input validation for server URLs and credentials 2 2 3 + // ensure value is always an array 4 + const toArray = (value) => 5 + Array.isArray(value) ? value : value ? [value] : []; 6 + 3 7 // build validation response object 4 8 const buildValidation = (valid, value, error) => ({ 5 9 valid,