A music player that connects to your cloud/distributed storage.
at v4 6.3 kB view raw
1import * as IDB from "idb-keyval"; 2 3import { IDB_ARTWORK_PREFIX } from "./constants.js"; 4import { musicMetadataTags } from "../metadata/common.js"; 5import { ostiary, rpc } from "@common/worker.js"; 6 7/** 8 * @import {IPicture} from "music-metadata" 9 * @import {Actions, Artwork, ArtworkRequest} from "./types.d.ts" 10 * @import {Extraction} from "../metadata/types.d.ts" 11 */ 12 13/** 14 * @type {ArtworkRequest[]} 15 */ 16let queue = []; 17 18//////////////////////////////////////////// 19// ACTIONS 20//////////////////////////////////////////// 21 22/** 23 * @type {Actions['artwork']} 24 */ 25export async function artwork(request) { 26 const art = await processRequest(request); 27 return art; 28} 29 30/** 31 * @type {Actions['supply']} 32 */ 33export function supply(items) { 34 const exe = !queue[0]; 35 queue = [...queue, ...items]; 36 if (exe) shiftQueue(); 37} 38 39//////////////////////////////////////////// 40// ⚡️ 41//////////////////////////////////////////// 42 43ostiary((context) => { 44 rpc(context, { 45 artwork, 46 supply, 47 }); 48}); 49 50//////////////////////////////////////////// 51// 🛠️ 52//////////////////////////////////////////// 53 54/** 55 * @param {string} str 56 */ 57function escapeLucene(str) { 58 return [].map 59 .call(str, (char) => { 60 if ( 61 char === "+" || 62 char === "-" || 63 char === "&" || 64 char === "|" || 65 char === "!" || 66 char === "(" || 67 char === ")" || 68 char === "{" || 69 char === "}" || 70 char === "[" || 71 char === "]" || 72 char === "^" || 73 char === '"' || 74 char === "~" || 75 char === "*" || 76 char === "?" || 77 char === ":" || 78 char === "\\" || 79 char === "/" 80 ) { 81 return "\\" + char; 82 } else return char; 83 }) 84 .join(""); 85} 86 87/** 88 * @param {ArtworkRequest} req 89 * @returns {Promise<Artwork[]>} 90 */ 91async function lastFm(req) { 92 if (!navigator.onLine) return []; 93 94 const query = req.tags?.artist; 95 96 return await fetch( 97 `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, 98 ) 99 .then((r) => r.json()) 100 .then((r) => lastFmCover(r.results.albummatches.album)) 101 .catch((err) => { 102 console.error(err); 103 return []; 104 }); 105} 106 107/** 108 * @param {any[]} remainingMatches 109 * @returns {Promise<Artwork[]>} 110 */ 111async function lastFmCover(remainingMatches) { 112 const album = remainingMatches[0]; 113 const url = album ? album.image[album.image.length - 1]["#text"] : null; 114 115 return url && url !== "" 116 ? await fetch(url) 117 .then((r) => r.blob()) 118 .then(async (b) => [ 119 { 120 bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), 121 mime: b.type, 122 }, 123 ]) 124 .catch((err) => { 125 // console.error(err); 126 return lastFmCover(remainingMatches.slice(1)); 127 }) 128 : album 129 ? lastFmCover(remainingMatches.slice(1)) 130 : []; 131} 132 133/** 134 * @param {ArtworkRequest} req 135 * @returns {Promise<Artwork[]>} 136 */ 137async function musicBrainz(req) { 138 const artist = req.tags?.artist; 139 const album = req.tags?.album; 140 141 if (!navigator.onLine) return []; 142 if (!album && !artist) return []; 143 144 const query = `release:"${escapeLucene(album || "")}"` + 145 (req.variousArtists 146 ? `` 147 : ` AND artistname:"${escapeLucene(artist || "")}"`); 148 const encodedQuery = encodeURIComponent(query); 149 150 return await fetch( 151 `https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`, 152 ) 153 .then((r) => r.json()) 154 .then((r) => { 155 if (r.releases.length === 0 && !req.variousArtists) { 156 return musicBrainz({ ...req, variousArtists: true }); 157 } else { 158 return musicBrainzCover(r.releases, req); 159 } 160 }) 161 .catch((err) => { 162 // console.error(err); 163 return []; 164 }); 165} 166 167/** 168 * @param {any[]} remainingReleases 169 * @param {ArtworkRequest} req 170 * @returns {Promise<Artwork[]>} 171 */ 172async function musicBrainzCover(remainingReleases, req) { 173 const release = remainingReleases[0]; 174 if (!release) return []; 175 176 const credit = release?.["artist-credit"]?.[0]?.name; 177 if ( 178 req.variousArtists && credit !== "Various Artists" && 179 credit !== req.tags?.artist 180 ) return []; 181 182 return await fetch( 183 `https://coverartarchive.org/release/${release.id}/front-1200`, 184 ) 185 .then((r) => r.blob()) 186 .then(async (b) => { 187 if (b.type.startsWith("image/")) { 188 return [{ 189 bytes: await b.arrayBuffer().then((buf) => new Uint8Array(buf)), 190 mime: b.type, 191 }]; 192 } else { 193 return musicBrainzCover(remainingReleases.slice(1), req); 194 } 195 }) 196 .catch((err) => { 197 console.error(err); 198 return musicBrainzCover(remainingReleases.slice(1), req); 199 }); 200} 201 202/** 203 * @param {ArtworkRequest} req 204 * @returns {Promise<Artwork[]>} 205 */ 206async function processRequest(req) { 207 // Check if already processed 208 // TODO: Retry if none was found? 209 const cache = await IDB.get(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`); 210 if (cache && Array.isArray(cache) && cache.length) return cache; 211 212 // Request override 213 if (req.tags?.artist?.toUpperCase() === "VA") { 214 req.variousArtists = true; 215 } 216 217 // 🚀 218 219 /** @type {Artwork[]} */ 220 let art = []; 221 222 // Get metadata + possible artwork from file metadata 223 const meta = await musicMetadataTags({ ...req, includeArtwork: true }).catch( 224 /** @param {Error} err */ (err) => { 225 console.error("music-metadata error", err); 226 /** @type {Extraction} */ 227 const extraction = {}; 228 return extraction; 229 }, 230 ); 231 232 if (!req.tags && meta.tags) req.tags = meta.tags; 233 234 // Add artwork from metadata 235 const fromMeta = meta.artwork?.map( 236 /** 237 * @param {IPicture} a 238 */ 239 (a) => { 240 return { bytes: a.data, mime: a.format }; 241 }, 242 ) || []; 243 244 art.push(...fromMeta); 245 246 // If no artwork, try finding it on other sources 247 if (art.length === 0) { 248 const fromMusicBrainz = await musicBrainz(req); 249 art.push(...fromMusicBrainz); 250 } 251 252 if (art.length === 0) { 253 const fromLastFm = await lastFm(req); 254 art.push(...fromLastFm); 255 } 256 257 // Save artwork to IDB 258 await IDB.set(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`, art); 259 260 // Fin 261 return art; 262} 263 264async function shiftQueue() { 265 const next = queue.shift(); 266 if (!next) return; 267 268 await processRequest(next); 269 await shiftQueue(); 270}