Experiment to rebuild Diffuse using web applets.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: artwork + metadata processor

+323 -360
+1 -1
deno.lock
··· 27 27 "npm:@js-temporal/polyfill@~0.5.1", 28 28 "npm:@jsr/bradenmacdonald__s3-lite-client@0.9", 29 29 "npm:@jsr/std__media-types@^1.1.0", 30 + "npm:@okikio/sharedworker@^1.1.0", 30 31 "npm:@orama/orama@^3.1.7", 31 32 "npm:@orama/plugin-qps@^3.1.7", 32 33 "npm:@phosphor-icons/web@^2.1.2", ··· 43 44 "npm:iconoir@^7.11.0", 44 45 "npm:idb-keyval@^6.2.1", 45 46 "npm:music-metadata@^11.2.3", 46 - "npm:native-file-system-adapter@^3.0.1", 47 47 "npm:netlify@^22.1.0", 48 48 "npm:purgecss@^7.0.2", 49 49 "npm:query-string@^9.1.2",
+7 -78
package-lock.json
··· 9 9 "@automerge/automerge": "^3.0.0-beta.0", 10 10 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 11 11 "@js-temporal/polyfill": "^0.5.1", 12 + "@okikio/sharedworker": "^1.1.0", 12 13 "@orama/orama": "^3.1.7", 13 14 "@orama/plugin-qps": "^3.1.7", 14 15 "@phosphor-icons/web": "^2.1.2", ··· 23 24 "iconoir": "^7.11.0", 24 25 "idb-keyval": "^6.2.1", 25 26 "music-metadata": "^11.2.3", 26 - "native-file-system-adapter": "^3.0.1", 27 27 "query-string": "^9.1.2", 28 28 "spellcaster": "^6.0.0", 29 29 "subsonic-api": "^3.1.2", ··· 1114 1114 "engines": { 1115 1115 "node": ">=12" 1116 1116 } 1117 + }, 1118 + "node_modules/@okikio/sharedworker": { 1119 + "version": "1.1.0", 1120 + "resolved": "https://registry.npmjs.org/@okikio/sharedworker/-/sharedworker-1.1.0.tgz", 1121 + "integrity": "sha512-Xj9TUWll9mhARsKu5DtlQCjRekfJfQ2E291ow6gmXIz+WuF6uJMH8ZmGhdRTx/ndOippHnm1j/vxXNjmR6JuXw==", 1122 + "license": "MIT" 1117 1123 }, 1118 1124 "node_modules/@orama/orama": { 1119 1125 "version": "3.1.7", ··· 3352 3358 } 3353 3359 } 3354 3360 }, 3355 - "node_modules/fetch-blob": { 3356 - "version": "3.2.0", 3357 - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", 3358 - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 3359 - "funding": [ 3360 - { 3361 - "type": "github", 3362 - "url": "https://github.com/sponsors/jimmywarting" 3363 - }, 3364 - { 3365 - "type": "paypal", 3366 - "url": "https://paypal.me/jimmywarting" 3367 - } 3368 - ], 3369 - "license": "MIT", 3370 - "optional": true, 3371 - "dependencies": { 3372 - "node-domexception": "^1.0.0", 3373 - "web-streams-polyfill": "^3.0.3" 3374 - }, 3375 - "engines": { 3376 - "node": "^12.20 || >= 14.13" 3377 - } 3378 - }, 3379 3361 "node_modules/fflate": { 3380 3362 "version": "0.8.2", 3381 3363 "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", ··· 5391 5373 }, 5392 5374 "engines": { 5393 5375 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 5394 - } 5395 - }, 5396 - "node_modules/native-file-system-adapter": { 5397 - "version": "3.0.1", 5398 - "resolved": "https://registry.npmjs.org/native-file-system-adapter/-/native-file-system-adapter-3.0.1.tgz", 5399 - "integrity": "sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==", 5400 - "funding": [ 5401 - { 5402 - "type": "github", 5403 - "url": "https://github.com/sponsors/jimmywarting" 5404 - }, 5405 - { 5406 - "type": "paypal", 5407 - "url": "https://paypal.me/jimmywarting" 5408 - } 5409 - ], 5410 - "license": "MIT", 5411 - "engines": { 5412 - "node": ">=14.8.0" 5413 - }, 5414 - "optionalDependencies": { 5415 - "fetch-blob": "^3.2.0" 5416 5376 } 5417 5377 }, 5418 5378 "node_modules/neotraverse": { ··· 20434 20394 "license": "MIT", 20435 20395 "optional": true 20436 20396 }, 20437 - "node_modules/node-domexception": { 20438 - "version": "1.0.0", 20439 - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 20440 - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 20441 - "deprecated": "Use your platform's native DOMException instead", 20442 - "funding": [ 20443 - { 20444 - "type": "github", 20445 - "url": "https://github.com/sponsors/jimmywarting" 20446 - }, 20447 - { 20448 - "type": "github", 20449 - "url": "https://paypal.me/jimmywarting" 20450 - } 20451 - ], 20452 - "license": "MIT", 20453 - "optional": true, 20454 - "engines": { 20455 - "node": ">=10.5.0" 20456 - } 20457 - }, 20458 20397 "node_modules/node-fetch": { 20459 20398 "version": "2.7.0", 20460 20399 "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", ··· 22479 22418 "funding": { 22480 22419 "type": "github", 22481 22420 "url": "https://github.com/sponsors/wooorm" 22482 - } 22483 - }, 22484 - "node_modules/web-streams-polyfill": { 22485 - "version": "3.3.3", 22486 - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", 22487 - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", 22488 - "license": "MIT", 22489 - "optional": true, 22490 - "engines": { 22491 - "node": ">= 8" 22492 22421 } 22493 22422 }, 22494 22423 "node_modules/webamp": {
+1
package.json
··· 4 4 "@automerge/automerge": "^3.0.0-beta.0", 5 5 "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client@^0.9.0", 6 6 "@js-temporal/polyfill": "^0.5.1", 7 + "@okikio/sharedworker": "^1.1.0", 7 8 "@orama/orama": "^3.1.7", 8 9 "@orama/plugin-qps": "^3.1.7", 9 10 "@phosphor-icons/web": "^2.1.2",
+7 -2
src/pages/configurator/input/_applet.astro
··· 105 105 const resolve = async (args: { method: string; uri: string }) => { 106 106 const scheme = args.uri.split(":", 1)[0]; 107 107 if (!isSupportedScheme(scheme)) return undefined; 108 - const conn = await connection(scheme); 109 - return await conn.sendAction("resolve", args); 108 + 109 + try { 110 + const conn = await connection(scheme); 111 + return await conn.sendAction("resolve", args); 112 + } catch (err) { 113 + console.error(`[configuration/input] Resolve error for scheme '${scheme}'.`, err); 114 + } 110 115 }; 111 116 112 117 context.setActionHandler("contextualize", contextualize);
+1 -1
src/pages/output/native-fs/_applet.astro
··· 1 1 <script> 2 2 import type { Actions } from "@scripts/output/native-fs/worker"; 3 3 import * as IDB from "idb-keyval"; 4 - import "wicg-file-system-access"; 4 + import type * as FSA from "wicg-file-system-access"; 5 5 6 6 import type { ManagedOutput, Track } from "@applets/core/types"; 7 7 import { register } from "@scripts/applet/common";
+11 -157
src/pages/processor/artwork/_applet.astro
··· 1 1 <script> 2 - import type { IPicture } from "music-metadata"; 3 - import * as IDB from "idb-keyval"; 4 - 5 - import { applet, register } from "@scripts/applet/common"; 6 - import type { ArtworkRequest, State, Artwork } from "./types.d.ts"; 7 - import type { Extraction } from "../metadata/types.d.ts"; 2 + import type { Actions } from "@scripts/processor/artwork/worker"; 3 + import type { ArtworkRequest } from "./types.d.ts"; 4 + import { register } from "@scripts/applet/common"; 5 + import { endpoint } from "@scripts/common"; 8 6 9 7 //////////////////////////////////////////// 10 8 // SETUP 11 9 //////////////////////////////////////////// 12 - const IDB_PREFIX = "@applets/processor/artwork"; 13 - const IDB_ARTWORK_PREFIX = `${IDB_PREFIX}/artwork`; 14 - 15 - const context = register<State>(); 16 - let queue: ArtworkRequest[] = []; 17 - 18 - // Initial data 19 - context.data = { 20 - artwork: {}, 21 - }; 22 - 23 - // Applet connections 24 - // TODO: Ideally only configurator, orchestrator and UI applets have nested applets. 25 - // Can we find a way to remove this dependency? 26 - const processor = { 27 - metadata: applet("../../processor/metadata"), 28 - }; 10 + const worker = endpoint<Actions>( 11 + new Worker("../../../scripts/processor/artwork/worker", { type: "module" }), 12 + ); 29 13 30 - // Load already-downloaded artwork 31 - IDB.keys().then(async (keys) => { 32 - const artworkKeys = keys.filter((k) => k.toString().startsWith(`${IDB_ARTWORK_PREFIX}/`)); 33 - 34 - artworkKeys.forEach(async (key) => { 35 - if (typeof key !== "string") return; 36 - 37 - const artwork = await IDB.get(key); 38 - const cacheId = key.split("/").reverse()[0]; 39 - 40 - context.data.artwork[cacheId] = artwork; 41 - }); 42 - }); 14 + // Register 15 + const context = register(); 43 16 44 17 //////////////////////////////////////////// 45 18 // ACTIONS 46 19 //////////////////////////////////////////// 47 20 function artwork(request: ArtworkRequest) { 48 - return processRequest(request); 21 + return worker.call.artwork(request); 49 22 } 50 23 51 24 function supply(items: ArtworkRequest[]) { 52 - const exe = !queue[0]; 53 - queue = [...queue, ...items]; 54 - if (exe) shiftQueue(); 25 + return worker.call.supply(items); 55 26 } 56 27 57 28 context.setActionHandler("artwork", artwork); 58 29 context.setActionHandler("supply", supply); 59 - 60 - //////////////////////////////////////////// 61 - // 🛠️ 62 - //////////////////////////////////////////// 63 - async function lastFm(req: ArtworkRequest): Promise<Artwork[]> { 64 - if (!navigator.onLine) return []; 65 - 66 - const query = req.tags?.artist; 67 - 68 - return await fetch( 69 - `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, 70 - ) 71 - .then((r) => r.json()) 72 - .then((r) => lastFmCover(r.results.albummatches.album)); 73 - } 74 - 75 - function lastFmCover(remainingMatches: any[]): Promise<Artwork[]> { 76 - const album = remainingMatches[0]; 77 - const url = album ? album.image[album.image.length - 1]["#text"] : null; 78 - 79 - return url && url !== "" 80 - ? fetch(url) 81 - .then((r) => r.blob()) 82 - .then(async (b) => [{ bytes: await b.bytes(), mime: b.type }]) 83 - .catch((_) => lastFmCover(remainingMatches.slice(1))) 84 - : album && lastFmCover(remainingMatches.slice(1)); 85 - } 86 - 87 - async function musicBrainz(req: ArtworkRequest): Promise<Artwork[]> { 88 - const artist = req.tags?.artist; 89 - const album = req.tags?.album; 90 - 91 - if (!navigator.onLine) return []; 92 - if (!album && !artist) return []; 93 - 94 - // TODO 95 - const variousArtists = false; 96 - 97 - const query = `release:"${album}"` + (variousArtists ? `` : ` AND artist:"${artist}"`); 98 - const encodedQuery = encodeURIComponent(query); 99 - 100 - return await fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 101 - .then((r) => r.json()) 102 - .then((r) => musicBrainzCover(r.releases)); 103 - } 104 - 105 - async function musicBrainzCover(remainingReleases: any[]): Promise<Artwork[]> { 106 - const release = remainingReleases[0]; 107 - if (!release) return []; 108 - 109 - return await fetch(`https://coverartarchive.org/release/${release.id}/front-500`) 110 - .then((r) => r.blob()) 111 - .then(async (b) => { 112 - if (b && b.type.startsWith("image/")) { 113 - return [{ bytes: await b.bytes(), mime: b.type }]; 114 - } else { 115 - return musicBrainzCover(remainingReleases.slice(1)); 116 - } 117 - }) 118 - .catch(() => musicBrainzCover(remainingReleases.slice(1))); 119 - } 120 - 121 - async function processRequest(req: ArtworkRequest): Promise<Artwork[]> { 122 - // Check if already processed 123 - // TODO: Retry if none was found? 124 - const cache = await IDB.get(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`); 125 - if (cache) return cache; 126 - 127 - // 🚀 128 - let art: Artwork[] = []; 129 - 130 - // Get metadata + possible artwork from file metadata 131 - const proc = await processor.metadata; 132 - const meta = await proc.sendAction<Extraction>( 133 - "supply", 134 - { ...req, includeArtwork: true }, 135 - { 136 - timeoutDuration: 60000 * 5, 137 - }, 138 - ); 139 - 140 - if (!req.tags) req.tags = meta.tags; 141 - 142 - // Add artwork from metadata 143 - const fromMeta = 144 - meta.artwork?.map((a: IPicture) => { 145 - return { bytes: a.data, mime: a.format }; 146 - }) || []; 147 - 148 - art.push(...fromMeta); 149 - 150 - // If no artwork, try finding it on other sources 151 - if (art.length === 0) { 152 - const fromMusicBrainz = await musicBrainz(req); 153 - art.push(...fromMusicBrainz); 154 - } 155 - 156 - if (art.length === 0) { 157 - const fromLastFm = await lastFm(req); 158 - art.push(...fromLastFm); 159 - } 160 - 161 - // Save artwork to IDB 162 - await IDB.set(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`, art); 163 - context.data.artwork[req.cacheId] = art; 164 - 165 - // Fin 166 - return art; 167 - } 168 - 169 - async function shiftQueue() { 170 - const next = queue.shift(); 171 - if (!next) return; 172 - 173 - await processRequest(next); 174 - await shiftQueue(); 175 - } 176 30 </script>
+1 -20
src/pages/processor/artwork/types.d.ts
··· 1 - import type { TrackTags } from "@applets/core/types"; 2 - 3 - export type Artwork = { 4 - bytes: Uint8Array; 5 - mime: string; 6 - }; 7 - 8 - export type ArtworkRequest<Tags = TrackTags> = { 9 - cacheId: string; 10 - mimeType?: string; 11 - stream?: ReadableStream; 12 - tags?: Tags; 13 - urls?: Urls; 14 - }; 15 - 16 - export type State = { 17 - artwork: Record<string, Artwork[]>; 18 - }; 19 - 20 - export type Urls = { get: string; head: string }; 1 + export * from "../../../scripts/processor/artwork/types.d.ts";
+11 -95
src/pages/processor/metadata/_applet.astro
··· 1 1 <script> 2 - import { parseFromTokenizer, parseWebStream } from "music-metadata"; 3 - import { contentType } from "@std/media-types"; 4 - import * as URI from "uri-js"; 5 - import * as HTTP_TOKENIZER from "@tokenizer/http"; 6 - import * as RANGE_TOKENIZER from "@tokenizer/range"; 7 - 8 - import type { TrackStats, TrackTags } from "@applets/core/types"; 9 - import type { Extraction, Urls } from "./types.d.ts"; 2 + import type { Actions } from "@scripts/processor/metadata/worker"; 10 3 import { register } from "@scripts/applet/common"; 4 + import { endpoint } from "@scripts/common"; 11 5 12 6 //////////////////////////////////////////// 13 7 // SETUP 14 8 //////////////////////////////////////////// 9 + const worker = endpoint<Actions>( 10 + new Worker("../../../scripts/processor/metadata/worker", { type: "module" }), 11 + ); 12 + 13 + // Register applet 15 14 const context = register(); 16 15 17 16 //////////////////////////////////////////// 18 17 // ACTIONS 19 18 //////////////////////////////////////////// 20 - context.setActionHandler("supply", supply); 21 - 22 - async function supply(args: { 23 - includeArtwork?: boolean; 24 - mimeType?: string; 25 - stream?: ReadableStream; 26 - urls?: Urls; 27 - }): Promise<Extraction> { 28 - // Construct records 29 - // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js 30 - const response = await musicMetadataTags(args).catch((err): Extraction => { 31 - console.warn("Metadata processor error:", err); 32 - console.log(args); 33 - 34 - return {}; 35 - }); 36 - 37 - // Fin 38 - return response; 39 - } 40 - 41 - //////////////////////////////////////////// 42 - // 🛠️ 43 - //////////////////////////////////////////// 44 - async function musicMetadataTags({ 45 - includeArtwork, 46 - mimeType, 47 - stream, 48 - urls, 49 - }: { 50 - includeArtwork?: boolean; 51 - mimeType?: string; 52 - stream?: ReadableStream; 53 - urls?: Urls; 54 - }): Promise<Extraction> { 55 - const uri = urls ? URI.parse(urls.get) : undefined; 56 - const pathParts = uri?.path?.split("/"); 57 - const filename = pathParts?.[pathParts.length - 1]; 58 - 59 - let meta; 60 - 61 - if (urls?.get.startsWith("blob:")) { 62 - const mimeFallback = filename?.includes(".") 63 - ? contentType(filename.split(".").reverse()[0]) 64 - : undefined; 19 + const supply: Actions["supply"] = (...args: Parameters<Actions["supply"]>) => { 20 + return worker.call.supply(...args); 21 + }; 65 22 66 - const resp = await fetch(urls.get); 67 - const stream = resp.body; 68 - 69 - if (!stream) return {}; 70 - meta = await parseWebStream( 71 - stream, 72 - { mimeType: mimeType || mimeFallback }, 73 - { skipCovers: !includeArtwork }, 74 - ); 75 - } else if (urls) { 76 - const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); 77 - httpClient.resolvedUrl = urls.get; 78 - 79 - const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); 80 - 81 - meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); 82 - } else if (stream) { 83 - meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); 84 - } else { 85 - throw new Error("Missing args, need either some urls or a stream."); 86 - } 87 - 88 - const stats: TrackStats = { 89 - duration: meta.format.duration, 90 - }; 91 - 92 - const tags: TrackTags = { 93 - album: meta.common.album, 94 - artist: meta.common.artist, 95 - disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, 96 - genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, 97 - title: meta.common.title || filename || urls?.head || "Unknown", 98 - track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, 99 - year: meta.common.year, 100 - }; 101 - 102 - return { 103 - artwork: includeArtwork ? meta.common.picture : undefined, 104 - stats, 105 - tags, 106 - }; 107 - } 23 + context.setActionHandler("supply", supply); 108 24 </script>
+1 -5
src/pages/processor/metadata/types.d.ts
··· 1 - import type { IPicture } from "music-metadata"; 2 - import type { TrackStats, TrackTags } from "@applets/core/types"; 3 - 4 - export type Extraction = { artwork?: IPicture[]; stats?: TrackStats; tags?: TrackTags }; 5 - export type Urls = { get: string; head: string }; 1 + export * from "../../../scripts/processor/metadata/types.d.ts";
-1
src/scripts/input/opensubsonic/worker.ts
··· 95 95 } 96 96 97 97 async function resolve({ uri }: { method: string; uri: string }) { 98 - console.log("RESOLVE", uri); 99 98 const server = parseURI(uri); 100 99 if (!server) return undefined; 101 100
+2
src/scripts/processor/artwork/constants.ts
··· 1 + export const IDB_PREFIX = "@applets/processor/artwork"; 2 + export const IDB_ARTWORK_PREFIX = `${IDB_PREFIX}/artwork`;
+20
src/scripts/processor/artwork/types.d.ts
··· 1 + import type { TrackTags } from "@applets/core/types"; 2 + 3 + export type Artwork = { 4 + bytes: Uint8Array; 5 + mime: string; 6 + }; 7 + 8 + export type ArtworkRequest<Tags = TrackTags> = { 9 + cacheId: string; 10 + mimeType?: string; 11 + stream?: ReadableStream; 12 + tags?: Tags; 13 + urls?: Urls; 14 + }; 15 + 16 + // export type State = { 17 + // artwork: Record<string, Artwork[]>; 18 + // }; 19 + 20 + export type Urls = { get: string; head: string };
+148
src/scripts/processor/artwork/worker.ts
··· 1 + import type { IPicture } from "music-metadata"; 2 + import { SharedWorkerPolyfill as SharedWorker } from "@okikio/sharedworker"; 3 + import * as IDB from "idb-keyval"; 4 + 5 + import type { Actions as MetadataActions } from "../metadata/worker"; 6 + import type { Artwork, ArtworkRequest } from "./types"; 7 + import { endpoint, expose } from "@scripts/common"; 8 + import { IDB_ARTWORK_PREFIX } from "./constants"; 9 + 10 + // State 11 + let queue: ArtworkRequest[] = []; 12 + 13 + // Metadata worker 14 + const metadataWorker = endpoint<MetadataActions>( 15 + new SharedWorker("../metadata/worker", { 16 + type: "module", 17 + }).port, 18 + ); 19 + 20 + //////////////////////////////////////////// 21 + // ACTIONS 22 + //////////////////////////////////////////// 23 + const actions = expose({ 24 + artwork, 25 + supply, 26 + }); 27 + 28 + export type Actions = typeof actions; 29 + 30 + // Actions 31 + 32 + function artwork(request: ArtworkRequest) { 33 + return processRequest(request); 34 + } 35 + 36 + function supply(items: ArtworkRequest[]) { 37 + const exe = !queue[0]; 38 + queue = [...queue, ...items]; 39 + if (exe) shiftQueue(); 40 + } 41 + 42 + //////////////////////////////////////////// 43 + // 🛠️ 44 + //////////////////////////////////////////// 45 + async function lastFm(req: ArtworkRequest): Promise<Artwork[]> { 46 + if (!navigator.onLine) return []; 47 + 48 + const query = req.tags?.artist; 49 + 50 + return await fetch( 51 + `https://ws.audioscrobbler.com/2.0/?method=album.search&album=${query}&api_key=4f0fe85b67baef8bb7d008a8754a95e5&format=json`, 52 + ) 53 + .then((r) => r.json()) 54 + .then((r) => lastFmCover(r.results.albummatches.album)); 55 + } 56 + 57 + function lastFmCover(remainingMatches: any[]): Promise<Artwork[]> { 58 + const album = remainingMatches[0]; 59 + const url = album ? album.image[album.image.length - 1]["#text"] : null; 60 + 61 + return url && url !== "" 62 + ? fetch(url) 63 + .then((r) => r.blob()) 64 + .then(async (b) => [{ bytes: await b.bytes(), mime: b.type }]) 65 + .catch((_) => lastFmCover(remainingMatches.slice(1))) 66 + : album && lastFmCover(remainingMatches.slice(1)); 67 + } 68 + 69 + async function musicBrainz(req: ArtworkRequest): Promise<Artwork[]> { 70 + const artist = req.tags?.artist; 71 + const album = req.tags?.album; 72 + 73 + if (!navigator.onLine) return []; 74 + if (!album && !artist) return []; 75 + 76 + // TODO 77 + const variousArtists = false; 78 + 79 + const query = `release:"${album}"` + (variousArtists ? `` : ` AND artist:"${artist}"`); 80 + const encodedQuery = encodeURIComponent(query); 81 + 82 + return await fetch(`https://musicbrainz.org/ws/2/release/?query=${encodedQuery}&fmt=json`) 83 + .then((r) => r.json()) 84 + .then((r) => musicBrainzCover(r.releases)); 85 + } 86 + 87 + async function musicBrainzCover(remainingReleases: any[]): Promise<Artwork[]> { 88 + const release = remainingReleases[0]; 89 + if (!release) return []; 90 + 91 + return await fetch(`https://coverartarchive.org/release/${release.id}/front-500`) 92 + .then((r) => r.blob()) 93 + .then(async (b) => { 94 + if (b && b.type.startsWith("image/")) { 95 + return [{ bytes: await b.bytes(), mime: b.type }]; 96 + } else { 97 + return musicBrainzCover(remainingReleases.slice(1)); 98 + } 99 + }) 100 + .catch(() => musicBrainzCover(remainingReleases.slice(1))); 101 + } 102 + 103 + async function processRequest(req: ArtworkRequest): Promise<Artwork[]> { 104 + // Check if already processed 105 + // TODO: Retry if none was found? 106 + const cache = await IDB.get(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`); 107 + if (cache) return cache; 108 + 109 + // 🚀 110 + let art: Artwork[] = []; 111 + 112 + // Get metadata + possible artwork from file metadata 113 + const meta = await metadataWorker.call.supply({ ...req, includeArtwork: true }); 114 + if (!req.tags) req.tags = meta.tags; 115 + 116 + // Add artwork from metadata 117 + const fromMeta = 118 + meta.artwork?.map((a: IPicture) => { 119 + return { bytes: a.data, mime: a.format }; 120 + }) || []; 121 + 122 + art.push(...fromMeta); 123 + 124 + // If no artwork, try finding it on other sources 125 + if (art.length === 0) { 126 + const fromMusicBrainz = await musicBrainz(req); 127 + art.push(...fromMusicBrainz); 128 + } 129 + 130 + if (art.length === 0) { 131 + const fromLastFm = await lastFm(req); 132 + art.push(...fromLastFm); 133 + } 134 + 135 + // Save artwork to IDB 136 + await IDB.set(`${IDB_ARTWORK_PREFIX}/${req.cacheId}`, art); 137 + 138 + // Fin 139 + return art; 140 + } 141 + 142 + async function shiftQueue() { 143 + const next = queue.shift(); 144 + if (!next) return; 145 + 146 + await processRequest(next); 147 + await shiftQueue(); 148 + }
+5
src/scripts/processor/metadata/types.d.ts
··· 1 + import type { IPicture } from "music-metadata"; 2 + import type { TrackStats, TrackTags } from "@applets/core/types"; 3 + 4 + export type Extraction = { artwork?: IPicture[]; stats?: TrackStats; tags?: TrackTags }; 5 + export type Urls = { get: string; head: string };
+107
src/scripts/processor/metadata/worker.ts
··· 1 + import { parseFromTokenizer, parseWebStream } from "music-metadata"; 2 + import { contentType } from "@std/media-types"; 3 + import * as URI from "uri-js"; 4 + import * as HTTP_TOKENIZER from "@tokenizer/http"; 5 + import * as RANGE_TOKENIZER from "@tokenizer/range"; 6 + 7 + import type { TrackStats, TrackTags } from "@applets/core/types"; 8 + import type { Extraction, Urls } from "./types.d.ts"; 9 + import { expose } from "@scripts/common"; 10 + 11 + //////////////////////////////////////////// 12 + // ACTIONS 13 + //////////////////////////////////////////// 14 + const actions = expose({ 15 + supply, 16 + }); 17 + 18 + export type Actions = typeof actions; 19 + 20 + // Actions 21 + 22 + async function supply(args: { 23 + includeArtwork?: boolean; 24 + mimeType?: string; 25 + stream?: ReadableStream; 26 + urls?: Urls; 27 + }): Promise<Extraction> { 28 + // Construct records 29 + // TODO: Use other metadata lib as fallback: https://github.com/buzz/mediainfo.js 30 + const response = await musicMetadataTags(args).catch((err): Extraction => { 31 + console.warn("Metadata processor error:", err); 32 + console.log(args); 33 + 34 + return {}; 35 + }); 36 + 37 + // Fin 38 + return response; 39 + } 40 + 41 + //////////////////////////////////////////// 42 + // 🛠️ 43 + //////////////////////////////////////////// 44 + async function musicMetadataTags({ 45 + includeArtwork, 46 + mimeType, 47 + stream, 48 + urls, 49 + }: { 50 + includeArtwork?: boolean; 51 + mimeType?: string; 52 + stream?: ReadableStream; 53 + urls?: Urls; 54 + }): Promise<Extraction> { 55 + const uri = urls ? URI.parse(urls.get) : undefined; 56 + const pathParts = uri?.path?.split("/"); 57 + const filename = pathParts?.[pathParts.length - 1]; 58 + 59 + let meta; 60 + 61 + if (urls?.get.startsWith("blob:")) { 62 + const mimeFallback = filename?.includes(".") 63 + ? contentType(filename.split(".").reverse()[0]) 64 + : undefined; 65 + 66 + const resp = await fetch(urls.get); 67 + const stream = resp.body; 68 + 69 + if (!stream) return {}; 70 + meta = await parseWebStream( 71 + stream, 72 + { mimeType: mimeType || mimeFallback }, 73 + { skipCovers: !includeArtwork }, 74 + ); 75 + } else if (urls) { 76 + const httpClient = new HTTP_TOKENIZER.HttpClient(urls.head, { resolveUrl: false }); 77 + httpClient.resolvedUrl = urls.get; 78 + 79 + const tokenizer = await RANGE_TOKENIZER.tokenizer(httpClient); 80 + 81 + meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork }); 82 + } else if (stream) { 83 + meta = await parseWebStream(stream, { mimeType }, { skipCovers: !includeArtwork }); 84 + } else { 85 + throw new Error("Missing args, need either some urls or a stream."); 86 + } 87 + 88 + const stats: TrackStats = { 89 + duration: meta.format.duration, 90 + }; 91 + 92 + const tags: TrackTags = { 93 + album: meta.common.album, 94 + artist: meta.common.artist, 95 + disc: { no: meta.common.disk.no || 1, of: meta.common.disk.of ?? undefined }, 96 + genre: Array.isArray(meta.common.genre) ? meta.common.genre[0] : meta.common.genre, 97 + title: meta.common.title || filename || urls?.head || "Unknown", 98 + track: { no: meta.common.track.no || 1, of: meta.common.track.of ?? undefined }, 99 + year: meta.common.year, 100 + }; 101 + 102 + return { 103 + artwork: includeArtwork ? meta.common.picture : undefined, 104 + stats, 105 + tags, 106 + }; 107 + }