1//
2// Processing
3// ♪(´ε` )
4//
5// Audio processing, getting metadata, etc.
6
7import type { IAudioMetadata } from "music-metadata"
8import type { GeneralTrack, MediaInfoResult } from "mediainfo.js"
9import type { ITokenizer } from "strtok3"
10
11import * as Uint8arrays from "uint8arrays"
12import { type App } from "./elm/types"
13import { transformUrl } from "../urls"
14
15
16// 🏔️
17
18
19const ENCODING_ISSUE_REPLACE_CHAR = '▩';
20
21let app: App
22
23
24
25// 🚀
26
27
28export function init(a: App) {
29 app = a
30
31 app.ports.requestTags.subscribe(requestTags)
32 app.ports.syncTags.subscribe(syncTags)
33}
34
35
36
37// Ports
38// -----
39
40
41function requestTags(context) {
42 processContext(context, app).then(newContext => {
43 app.ports.receiveTags.send(newContext)
44 })
45}
46
47
48function syncTags(context) {
49 processContext(context, app).then(newContext => {
50 app.ports.replaceTags.send(newContext)
51 })
52}
53
54
55
56// Contexts
57// --------
58
59
60export async function processContext(context, app) {
61 const initialPromise = Promise.resolve([]);
62
63 return context.urlsForTags
64 .reduce((accumulator, urls, idx) => {
65 return accumulator.then((col) => {
66 const filename = context.receivedFilePaths[idx].split("/").reverse()[0];
67
68 return Promise.all([transformUrl(urls.headUrl, app), transformUrl(urls.getUrl, app)])
69 .then(([headUrl, getUrl]) => {
70 return getTags(headUrl, getUrl, filename, { covers: false });
71 })
72 .then((r) => {
73 return col.concat(r);
74 })
75 .catch((e) => {
76 console.warn(e);
77 return col.concat(null);
78 });
79 });
80 }, initialPromise)
81 .then((col) => {
82 context.receivedTags = col;
83 return context;
84 });
85}
86
87
88
89// Tags - General
90// --------------
91
92
93type Tags = {
94 disc: number;
95 nr: number;
96 album: string | null;
97 artist: string | null;
98 title: string;
99 genre: string | null;
100 year: number | null;
101 picture: { data: Uint8Array; format: string } | null;
102};
103
104export async function getTags(
105 headUrl: string,
106 getUrl: string,
107 filename: string,
108 { covers }: { covers: boolean },
109) {
110 const musicMetadata = await import("music-metadata");
111 const httpTokenizer = await import("@tokenizer/http");
112 const rangeTokenizer = await import("@tokenizer/range");
113
114 let tokenizer: ITokenizer;
115 let mmResult;
116
117 try {
118 const httpClient = new httpTokenizer.HttpClient(headUrl, { resolveUrl: false });
119 httpClient.resolvedUrl = getUrl
120
121 tokenizer = await rangeTokenizer.tokenizer(httpClient);
122
123 mmResult = await musicMetadata
124 .parseFromTokenizer(tokenizer, { skipCovers: !covers })
125 .catch((err) => {
126 console.warn(err);
127 return null;
128 });
129 } catch (err) {
130 console.warn(err);
131 }
132
133 const mmTags = mmResult && pickTagsFromMusicMetadata(filename, mmResult);
134 if (mmTags) return mmTags;
135
136 const miResult = await (await mediaInfoClient(covers))
137 .analyzeData(getSize(headUrl), readChunk(getUrl))
138 .catch((err) => {
139 console.warn(err);
140 return null;
141 });
142
143 const miTags = miResult && pickTagsFromMediaInfo(filename, miResult);
144 if (miTags) return miTags;
145
146 return fallbackTags(filename);
147}
148
149function fallbackTags(filename: string): Tags {
150 const filenameWithoutExt = filename.replace(/\.\w+$/, "");
151
152 return {
153 disc: 1,
154 nr: 1,
155 album: null,
156 artist: null,
157 title: filenameWithoutExt,
158 genre: null,
159 year: null,
160 picture: null,
161 };
162}
163
164// Tags - Media Info
165// -----------------
166
167const getSize = (headUrl: string) => async (): Promise<number> => {
168 const response = await fetch(headUrl, { method: "HEAD" });
169
170 if (!response.ok) {
171 throw new Error(`HTTP error status=${response.status}: ${response.statusText}`);
172 }
173
174 const l = response.headers.get("Content-Length");
175
176 if (l) {
177 return parseInt(l, 10);
178 } else {
179 throw new Error("HTTP response doesn't have a Content-Length");
180 }
181};
182
183const readChunk =
184 (getUrl: string) =>
185 async (chunkSize: number, offset: number): Promise<Uint8Array> => {
186 if (chunkSize === 0) return new Uint8Array();
187
188 const from = offset;
189 const to = offset + chunkSize;
190
191 const start = to < from ? to : from;
192 const end = to < from ? from : to;
193
194 const response = await fetch(getUrl, {
195 method: "GET",
196 headers: {
197 Range: `bytes=${start}-${end}`,
198 },
199 });
200
201 if (!response.ok) {
202 throw new Error(`HTTP error status=${response.status}: ${response.statusText}`);
203 }
204
205 return new Uint8Array(await response.arrayBuffer());
206 };
207
208function pickTagsFromMediaInfo(filename: string, result: MediaInfoResult): Tags | null {
209 const tagsRaw = result?.media?.track?.filter((t) => t["@type"] === "General")[0];
210 const tags = tagsRaw === undefined ? undefined : tagsRaw as GeneralTrack;
211 if (tags === undefined) return null;
212
213 let artist = typeof tags.Performer == "string" ? tags.Performer : null;
214 let album = typeof tags.Album == "string" ? tags.Album : null;
215
216 let title =
217 typeof tags.Track == "string" ? tags.Track : typeof tags.Title == "string" ? tags.Title : null;
218
219 if (!artist && !title) return null;
220
221 // TODO: Encoding issues with mediainfo.js
222 // https://github.com/buzz/mediainfo.js/issues/150
223 if (artist?.includes("�")) artist = artist.replace("�", ENCODING_ISSUE_REPLACE_CHAR)
224 if (album?.includes("�")) album = album.replace("�", ENCODING_ISSUE_REPLACE_CHAR)
225 if (title?.includes("�")) title = title.replace("�", ENCODING_ISSUE_REPLACE_CHAR)
226
227 if (artist && artist.includes(" / ")) {
228 artist = artist
229 .split(" / ")
230 .filter((a) => a.trim() !== "")
231 .join(", ");
232 }
233
234 const year = tags.Recorded_Date ? new Date(Date.parse(tags.Recorded_Date)).getFullYear() : null;
235
236 return {
237 disc: tags.Part_Position || 1,
238 nr: tags.Track_Position || 1,
239 album: album,
240 artist: artist,
241 title: title || filename.replace(/\.\w+$/, ""),
242 genre: tags.Genre || null,
243 year: year !== null && isNaN(year) ? null : year,
244 picture: tags.Cover_Data
245 ? {
246 data: Uint8arrays.fromString(tags.Cover_Data.split(" / ")[0], "base64pad"),
247 format: tags.Cover_Mime || "image/jpeg",
248 }
249 : null,
250 };
251}
252
253
254// Tags - Music Metadata
255// ---------------------
256
257
258function pickTagsFromMusicMetadata(filename: string, result: IAudioMetadata): Tags | null {
259 const tags = result && result.common;
260 if (!tags) return null;
261
262 const artist = tags.artist && tags.artist.length ? tags.artist : null;
263 const title = tags.title && tags.title.length ? tags.title : null;
264
265 if (!artist && !title) return null;
266
267 return {
268 disc: tags.disk.no || 1,
269 nr: tags.track.no || 1,
270 album: tags.album && tags.album.length ? tags.album : null,
271 artist: artist,
272 title: title || filename.replace(/\.\w+$/, ""),
273 genre: (tags.genre && tags.genre[0]) || null,
274 year: tags.year || null,
275 picture:
276 tags.picture && tags.picture[0]
277 ? { data: tags.picture[0].data, format: tags.picture[0].format }
278 : null,
279 };
280}
281
282
283
284// 🛠️
285
286
287let client
288
289
290async function mediaInfoClient(covers: boolean) {
291 const MediaInfoFactory = await import("mediainfo.js").then((a) => a.default);
292
293 if (client) return client
294
295 client = await MediaInfoFactory({
296 coverData: covers,
297 locateFile: () => {
298 return "../../wasm/media-info.wasm";
299 },
300 });
301
302 return client
303}