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