forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import { parseBlob, parseFromTokenizer, parseWebStream } from "music-metadata";
2import * as URI from "fast-uri";
3import { HttpClient } from "@tokenizer/http";
4import { tokenizer as rangeTokenizer } from "@tokenizer/range";
5
6import { removeUndefinedValuesFromRecord } from "~/common/utils.js";
7
8/**
9 * @import { TrackStats, TrackTags } from "~/definitions/types.d.ts";
10 * @import { Extraction, Urls } from "~/components/metadata/audio-file/types.d.ts";
11 */
12
13// 🛠️
14
15/**
16 * @param {{ includeArtwork?: boolean; mimeType?: string; stream?: ReadableStream; urls?: Urls; }} _
17 * @returns {Promise<Extraction>}
18 */
19export async function musicMetadataTags({
20 includeArtwork,
21 mimeType,
22 stream,
23 urls,
24}) {
25 const uri = urls ? URI.parse(urls.get) : undefined;
26 const pathParts = uri?.path?.split("/");
27 const filename = pathParts?.[pathParts.length - 1];
28
29 let meta;
30
31 if (urls?.get.startsWith("blob:")) {
32 const blob = await fetch(urls.get).then((r) => r.blob());
33 meta = await parseBlob(blob, { skipCovers: !includeArtwork });
34 } else if (urls) {
35 const httpClient = new HttpClient(urls.head, {
36 resolveUrl: false,
37 });
38 httpClient.resolvedUrl = urls.get;
39 const getHeadInfo = httpClient.getHeadInfo;
40
41 // FUCKAROUND: Not sure of the downsides of this
42 /** @type {any} */ (httpClient).getHeadInfo = async () => {
43 try {
44 const info = await getHeadInfo.call(httpClient);
45 return { ...info, acceptPartialRequests: true };
46 } catch {
47 // Some servers (e.g. Dropbox temporary links) don't return Content-Length.
48 // Fall back to downloading the full file without range requests.
49 return { size: undefined, acceptPartialRequests: false };
50 }
51 };
52
53 /** @type {any} */
54 const tokenizer = await rangeTokenizer(httpClient);
55 meta = await parseFromTokenizer(tokenizer, { skipCovers: !includeArtwork });
56 } else if (stream) {
57 meta = await parseWebStream(stream, { mimeType }, {
58 skipCovers: !includeArtwork,
59 });
60 } else {
61 throw new Error("Missing args, need either some urls or a stream.");
62 }
63
64 /** @type {TrackStats} */
65 const statsFull = {
66 albumGain: maybeRound(meta.format.albumGain),
67 bitrate: maybeRound(meta.format.bitrate),
68 bitsPerSample: maybeRound(meta.format.bitsPerSample),
69 codec: meta.format.codec,
70 container: meta.format.container,
71 duration: meta.format.duration != null
72 ? Math.round(meta.format.duration * 1000)
73 : undefined,
74 lossless: meta.format.lossless,
75 numberOfChannels: maybeRound(meta.format.numberOfChannels),
76 sampleRate: maybeRound(meta.format.sampleRate),
77 trackGain: maybeRound(meta.format.trackGain),
78 };
79
80 /** @type {TrackTags} */
81 const tagsFull = {
82 album: meta.common.album,
83 albumartist: meta.common.albumartist,
84 albumartists: Array.isArray(meta.common.albumartist)
85 ? meta.common.albumartist
86 : (meta.common.albumartist ? [meta.common.albumartist] : undefined),
87 albumartistsort: meta.common.albumartistsort,
88 albumsort: meta.common.albumsort,
89 arranger: meta.common.arranger,
90 artist: meta.common.artist,
91 artists: meta.common.artists ??
92 (meta.common.artist ? [meta.common.artist] : []),
93 artistsort: meta.common.artistsort,
94 asin: meta.common.asin,
95 averageLevel: meta.common.averageLevel,
96 barcode: meta.common.barcode,
97 bpm: meta.common.bpm,
98 catalognumbers: meta.common.catalognumber,
99 compilation: meta.common.compilation,
100 composers: meta.common.composer,
101 composersort: meta.common.composersort,
102 conductors: meta.common.conductor,
103 date: meta.common.date,
104 disc: {
105 no: meta.common.disk.no || 1,
106 ...(meta.common.disk.of && { of: meta.common.disk.of }),
107 },
108 djmixers: meta.common.djmixer,
109 engineers: meta.common.engineer,
110 gapless: meta.common.gapless,
111 genres: Array.isArray(meta.common.genre)
112 ? meta.common.genre
113 : meta.common.genre
114 ? [meta.common.genre]
115 : undefined,
116 isrc: meta.common.isrc,
117 labels: meta.common.label,
118 lyricists: meta.common.lyricist,
119 media: meta.common.media,
120 mixers: meta.common.mixer,
121 moods: Array.isArray(meta.common.mood)
122 ? meta.common.mood
123 : meta.common.mood
124 ? [meta.common.mood]
125 : undefined,
126 originaldate: meta.common.originaldate,
127 originalyear: meta.common.originalyear,
128 peakLevel: meta.common.peakLevel,
129 producers: meta.common.producer,
130 publishers: meta.common.publisher,
131 releasecountry: meta.common.releasecountry,
132 releasedate: meta.common.releasedate,
133 releasestatus: meta.common.releasestatus,
134 releasetypes: meta.common.releasetype,
135 remixers: meta.common.remixer,
136 technicians: meta.common.technician,
137 title: meta.common.title || filename || urls?.head || "Unknown",
138 titlesort: meta.common.titlesort,
139 track: {
140 no: meta.common.track.no || 1,
141 ...(meta.common.track.of && { of: meta.common.track.of }),
142 },
143 work: meta.common.work,
144 writers: meta.common.writer,
145 year: meta.common.year,
146 };
147
148 const stats = removeUndefinedValuesFromRecord(statsFull);
149 const tags = removeUndefinedValuesFromRecord(tagsFull);
150
151 return {
152 artwork: includeArtwork ? meta.common.picture : undefined,
153 stats,
154 tags,
155 };
156}
157
158/**
159 * @param {number | undefined} value
160 * @returns {number | undefined}
161 */
162function maybeRound(value) {
163 return typeof value === "number" ? Math.round(value) : value;
164}