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}