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

Configure Feed

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

feat: add indexedDB based caching to api

only caches responses from all artists, single artist, and album
requests

Signed-off-by: oppiliappan <me@oppi.li>

authored by

oppiliappan and committed by
Tangled
1766b4af becba232

+178 -15
+3 -1
src/index.html
··· 322 322 <script src="js/strings/en.js"></script> 323 323 <script src="js/constants.js"></script> 324 324 <script src="js/validation.js"></script> 325 + <script src="js/db.js"></script> 326 + <script src="js/api-cache.js"></script> 327 + <script src="js/queue-storage.js"></script> 325 328 <script src="js/ui.js"></script> 326 329 <script src="js/state.js"></script> 327 330 <script src="js/api.js"></script> 328 - <script src="js/queue-storage.js"></script> 329 331 <script src="js/queue-virtualscroll.js"></script> 330 332 <script src="js/queue.js"></script> 331 333 <script src="js/library-selection.js"></script>
+85
src/js/api-cache.js
··· 1 + class APICache { 2 + constructor() { 3 + this.storeName = "apiCache"; 4 + } 5 + 6 + async ensureDB() { 7 + return await db.open(); 8 + } 9 + 10 + async get(key) { 11 + try { 12 + const db = await this.ensureDB(); 13 + if (!db) return null; 14 + 15 + return await new Promise((resolve, reject) => { 16 + const tx = db.transaction(this.storeName, "readonly"); 17 + const store = tx.objectStore(this.storeName); 18 + const req = store.get(key); 19 + 20 + req.onsuccess = () => resolve(req.result || null); 21 + req.onerror = () => reject(req.error); 22 + }); 23 + } catch (err) { 24 + console.warn( 25 + `[APICache] Failed to get cache entry for key "${key}":`, 26 + err, 27 + ); 28 + return null; 29 + } 30 + } 31 + 32 + async set(key, data, ttl) { 33 + try { 34 + const db = await this.ensureDB(); 35 + if (!db) return; 36 + 37 + const entry = { 38 + data, 39 + timestamp: Date.now(), 40 + version: 1, 41 + }; 42 + 43 + await new Promise((resolve, reject) => { 44 + const tx = db.transaction(this.storeName, "readwrite"); 45 + const store = tx.objectStore(this.storeName); 46 + const req = store.put(entry, key); 47 + 48 + req.onsuccess = () => resolve(); 49 + req.onerror = () => reject(req.error); 50 + }); 51 + } catch (err) { 52 + console.warn( 53 + `[APICache] Failed to set cache entry for key "${key}":`, 54 + err, 55 + ); 56 + } 57 + } 58 + 59 + isExpired(cacheEntry, ttl) { 60 + if (!cacheEntry || !cacheEntry.timestamp) { 61 + return true; 62 + } 63 + return Date.now() - cacheEntry.timestamp > ttl; 64 + } 65 + 66 + async clear() { 67 + try { 68 + const db = await this.ensureDB(); 69 + if (!db) return; 70 + 71 + await new Promise((resolve, reject) => { 72 + const tx = db.transaction(this.storeName, "readwrite"); 73 + const store = tx.objectStore(this.storeName); 74 + const req = store.clear(); 75 + 76 + req.onsuccess = () => resolve(); 77 + req.onerror = () => reject(req.error); 78 + }); 79 + } catch (err) { 80 + console.warn("[APICache] Failed to clear cache:", err); 81 + } 82 + } 83 + } 84 + 85 + const apiCache = new APICache();
+85 -14
src/js/api.js
··· 77 77 } 78 78 } 79 79 80 + // cached request wrapper - checks cache first, falls back to network 81 + async cachedRequest(cacheKey, ttl, method, params = {}) { 82 + // try cache first 83 + try { 84 + const cached = await apiCache.get(cacheKey); 85 + if (cached && !apiCache.isExpired(cached, ttl)) { 86 + return cached.data; 87 + } 88 + } catch (err) { 89 + console.warn(`[APICache] Cache read failed for ${cacheKey}:`, err); 90 + } 91 + 92 + // fetch from network 93 + const data = await this.request(method, params); 94 + 95 + // cache the result 96 + try { 97 + await apiCache.set(cacheKey, data, ttl); 98 + } catch (err) { 99 + console.warn(`[APICache] Cache write failed for ${cacheKey}:`, err); 100 + } 101 + 102 + return data; 103 + } 104 + 80 105 ping() { 81 106 return this.request("ping.view"); 82 107 } 83 108 84 109 getArtists() { 85 - return this.request("getArtists.view"); 110 + return this.cachedRequest( 111 + "artists:all", 112 + 60 * 60 * 1000, // 1 hour 113 + "getArtists.view", 114 + ); 86 115 } 87 116 88 117 _validateAndRequest(id, method, params, context) { ··· 98 127 } 99 128 100 129 getArtist(id) { 101 - return this._validateAndRequest(id, "getArtist.view", {}, "Artist ID"); 130 + const validation = validateId(id, "Artist ID"); 131 + if (!validation.valid) throw new Error(validation.error); 132 + 133 + return this.cachedRequest( 134 + `artist:${id}`, 135 + 30 * 60 * 1000, // 30 minutes 136 + "getArtist.view", 137 + { id }, 138 + ); 102 139 } 103 140 104 141 getAlbum(id) { 105 - return this._validateAndRequest(id, "getAlbum.view", {}, "Album ID"); 142 + const validation = validateId(id, "Album ID"); 143 + if (!validation.valid) throw new Error(validation.error); 144 + 145 + return this.cachedRequest( 146 + `album:${id}`, 147 + 60 * 60 * 1000, // 1 hour 148 + "getAlbum.view", 149 + { id }, 150 + ); 106 151 } 107 152 108 153 getPlaylists() { ··· 150 195 }); 151 196 } 152 197 153 - star(id) { 154 - return this._validateAndRequest(id, "star.view", {}, "Song ID"); 198 + async star(id) { 199 + const validation = validateId(id, "Song ID"); 200 + if (!validation.valid) throw new Error(validation.error); 201 + 202 + await this.request("star.view", { id }); 203 + 204 + // clear cache since favorites changed 205 + try { 206 + await apiCache.clear(); 207 + } catch (err) { 208 + console.warn("[APICache] Failed to clear cache after star:", err); 209 + } 155 210 } 156 211 157 - unstar(id) { 158 - return this._validateAndRequest(id, "unstar.view", {}, "Song ID"); 212 + async unstar(id) { 213 + const validation = validateId(id, "Song ID"); 214 + if (!validation.valid) throw new Error(validation.error); 215 + 216 + await this.request("unstar.view", { id }); 217 + 218 + // clear cache since favorites changed 219 + try { 220 + await apiCache.clear(); 221 + } catch (err) { 222 + console.warn("[APICache] Failed to clear cache after unstar:", err); 223 + } 159 224 } 160 225 161 - setRating(id, rating) { 226 + async setRating(id, rating) { 162 227 if (rating < 0 || rating > 5 || !Number.isInteger(rating)) { 163 228 throw new Error("Rating must be 0-5"); 164 229 } 165 - return this._validateAndRequest( 166 - id, 167 - "setRating.view", 168 - { rating }, 169 - "Song ID", 170 - ); 230 + 231 + const validation = validateId(id, "Song ID"); 232 + if (!validation.valid) throw new Error(validation.error); 233 + 234 + await this.request("setRating.view", { id, rating }); 235 + 236 + // clear cache since ratings changed 237 + try { 238 + await apiCache.clear(); 239 + } catch (err) { 240 + console.warn("[APICache] Failed to clear cache after setRating:", err); 241 + } 171 242 } 172 243 173 244 getLyricsBySongId(id) {
+5
src/js/auth.js
··· 74 74 // clear all storage and reload to logout 75 75 async function handleLogout() { 76 76 try { 77 + // Clear API cache before deleting databases 78 + await apiCache.clear().catch((err) => { 79 + console.warn("[APICache] Failed to clear cache on logout:", err); 80 + }); 81 + 77 82 if (indexedDB.databases) { 78 83 const dbs = await indexedDB.databases(); 79 84 for (const db of dbs) {