A music player that connects to your cloud/distributed storage.
at main 7.4 kB view raw
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}