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