Experiment to rebuild Diffuse using web applets.
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}