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