diff --git a/bun.lock b/bun.lock index 5ed2841..fdf2fb4 100644 --- a/bun.lock +++ b/bun.lock @@ -16,18 +16,18 @@ "cookie-session": "^2.1.1", "dashjs": "^5.1.1", "fuse.js": "^7.1.0", - "jose": "^6.1.3", + "jose": "^6.2.0", "npm": "^11.11.0", "pocketbase": "^0.26.8", - "taglib-wasm": "^0.9.0", + "taglib-wasm": "^1.0.5", }, "devDependencies": { "@neutralinojs/neu": "^11.7.0", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "globals": "^17.4.0", - "htmlhint": "^1.9.1", + "htmlhint": "^1.9.2", "miniflare": "^4.20260301.1", "prettier": "^3.8.1", "stylelint": "^16.26.1", @@ -1279,7 +1279,7 @@ "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], - "taglib-wasm": ["taglib-wasm@0.9.0", "", { "dependencies": { "@msgpack/msgpack": "^3.1.3" }, "peerDependencies": { "typescript": ">=4.5.0" } }, "sha512-E6Z/rGT6vE+9HuRnklSJNvEBdq+VyVVrXvMJ3o7/4oY3tsBwLYp949SgmkTUSegTgDzBjguTN74XVeEKtPVSOA=="], + "taglib-wasm": ["taglib-wasm@1.0.5", "", { "dependencies": { "@msgpack/msgpack": "^3.1.3" }, "peerDependencies": { "typescript": ">=4.5.0" }, "optionalPeers": ["typescript"] }, "sha512-kuDHX78FbjLOqldWxBBkEgjyyDagYRGcYqr4g6ObkmEMO203Sp1R0KxkELoH9VkZxsOVR8yoSWaSaG9f5oTqyQ=="], "tcp-port-used": ["tcp-port-used@1.0.2", "", { "dependencies": { "debug": "4.3.1", "is2": "^2.0.6" } }, "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA=="], diff --git a/js/api.js b/js/api.js index 1e6cd40..4bcf764 100644 --- a/js/api.js +++ b/js/api.js @@ -8,11 +8,10 @@ import { } from './utils.js'; import { trackDateSettings, losslessContainerSettings } from './storage.js'; import { APICache } from './cache.js'; -import { addMetadataToAudio } from './metadata.js'; +import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js'; import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; -import { initTagLib } from './taglib.js'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1110,12 +1109,11 @@ export class LosslessAPI { } async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) { - // Initialize taglib in the background. - initTagLib().catch(console.error); - // Load ffmpeg in the background. loadFfmpeg().catch(console.error); + const { onProgress, track } = options; + const prefetchPromises = prefetchMetadataObjects(track, this); try { // MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert @@ -1271,7 +1269,7 @@ export class LosslessAPI { }; } - blob = await addMetadataToAudio(blob, enrichedTrack, this, quality); + blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises); } // Detect actual format and fix filename extension if needed diff --git a/js/downloads.js b/js/downloads.js index 9a9fffb..011aa39 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -12,12 +12,11 @@ import { escapeHtml, } from './utils.js'; import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; -import { addMetadataToAudio } from './metadata.js'; +import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; import { encodeToMp3 } from './mp3-encoder.js'; import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; -import { initTagLib } from './taglib.js'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -270,12 +269,11 @@ function removeBulkDownloadTask(notifEl) { } async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null) { - // Initialize taglib in the background. - initTagLib().catch(console.error); - // Load ffmpeg in the background. loadFfmpeg().catch(console.error); + const prefetchPromises = prefetchMetadataObjects(track, api); + let enrichedTrack = { ...track, artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null), @@ -408,7 +406,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign const extension = await getExtensionFromBlob(blob); // Add metadata to the blob - blob = await addMetadataToAudio(blob, enrichedTrack, api, quality); + blob = await addMetadataToAudio(blob, enrichedTrack, api, quality, prefetchPromises); return { blob, extension }; } diff --git a/js/global.d.ts b/js/global.d.ts new file mode 100644 index 0000000..ed623f9 --- /dev/null +++ b/js/global.d.ts @@ -0,0 +1,4 @@ +declare module '*?url' { + const content: string; + export default content; +} diff --git a/js/metadata.js b/js/metadata.js index 7939788..42200a9 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -42,6 +42,16 @@ function getFullArtistString(track) { return knownArtists.join('; ') || null; } +export function prefetchMetadataObjects(track, api) { + const _tagLib = initTagLib().catch(console.error); + const coverFetch = track?.album?.cover + ? getCoverBlob(api, track.album.cover).catch(console.error) + : Promise.resolve(null); + const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error); + + return { _tagLib, coverFetch, lyricsFetch }; +} + /** * Adds metadata tags to audio files (FLAC, M4A or MP3) * @param {Blob} audioBlob - The audio file blob @@ -50,32 +60,42 @@ function getFullArtistString(track) { * @param {string} quality - Audio quality * @returns {Promise} - Audio blob with embedded metadata */ -export async function addMetadataToAudio(audioBlob, track, api, _quality) { - const tagLib = await initTagLib(); - const file = await tagLib.open(await audioBlob.arrayBuffer()); +export async function addMetadataToAudio(audioBlob, track, api, _quality, prefetchPromises) { + const { _tagLib, coverFetch, lyricsFetch } = prefetchPromises; + console.time('Get audio array buffer'); + const audioBuffer = await audioBlob.arrayBuffer(); + console.timeEnd('Get audio array buffer'); + + console.time('Open file with taglib'); + const tagLib = await _tagLib; + const file = await tagLib.open(audioBuffer); + console.timeEnd('Open file with taglib'); + + console.time('Tagging file'); try { const isMp4 = file.isMP4(); - const discNumber = track.volumeNumber ?? track.discNumber; - const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track); - const coverFetch = getCoverBlob(api, track.album.cover); // Add standard tags if (track.title) { file.setProperty('TITLE', getTrackTitle(track)); } + const artistStr = getFullArtistString(track); if (artistStr) { file.setProperty('ARTIST', artistStr); } + if (track.album?.title) { file.setProperty('ALBUM', track.album.title); } + const albumArtist = track.album?.artist?.name || track.artist?.name; if (albumArtist) { file.setProperty('ALBUMARTIST', albumArtist); } + if (track.trackNumber) { let trackString = String(track.trackNumber); @@ -89,6 +109,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { file.setProperty('TRACKNUMBER', String(track.trackNumber)); } } + if (!isMp4 && track.album?.numberOfTracks) { file.setProperty('TRACKTOTAL', String(track.album.numberOfTracks)); } @@ -103,6 +124,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { file.setProperty('BPM', String(Math.round(bpm))); } } + if (track.replayGain) { const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain; if (albumReplayGain) file.setProperty('REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain)); @@ -113,6 +135,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : ''); + if (releaseDateStr) { try { const year = new Date(releaseDateStr).getFullYear(); @@ -127,6 +150,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { if (track.copyright) { file.setProperty('COPYRIGHT', track.copyright); } + if (track.isrc) { file.setProperty('ISRC', track.isrc); @@ -134,6 +158,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { file.setMP4Item('xid ', `:isrc:${track.isrc}`); } } + if (track.explicit) { if (isMp4) { file.setMP4Item('rtng', '1'); @@ -142,20 +167,24 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { } } - if (track.album?.cover) { - const coverBlob = await coverFetch; - const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer()); + try { + if (track.album?.cover) { + const coverBlob = await coverFetch; + const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer()); - if (coverBlob) { - file.setPictures([ - { - mimeType: coverBlob.type, - data: coverBuffer, - type: PICTURE_TYPE_VALUES.FrontCover, - description: 'Cover Art', - }, - ]); + if (coverBlob) { + file.setPictures([ + { + mimeType: coverBlob.type, + data: coverBuffer, + type: PICTURE_TYPE_VALUES.FrontCover, + description: 'Cover Art', + }, + ]); + } } + } catch (e) { + console.warn('Error setting cover metadata.', track, e); } try { @@ -170,16 +199,28 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality) { //} } } catch (e) { - console.warn('Error fetching lyrics', track, e); + console.warn('Error setting lyrics metadata', track, e); } - await file.save(); + console.timeEnd('Tagging file'); - return new Blob([file.getFileBuffer()], { type: audioBlob.type, name: audioBlob.name }); + console.time('Saving in-memory buffer'); + await file.save(); + console.timeEnd('Saving in-memory buffer'); + + console.time('Saving blob'); + const blob = new Blob([file.getFileBuffer()], { type: audioBlob.type, name: audioBlob.name }); + console.timeEnd('Saving blob'); + + return blob; + } catch (err) { + console.error(err); } finally { // Always dispose, even if there was an error. file.dispose(); } + + return audioBlob; } /** diff --git a/js/taglib.js b/js/taglib.js deleted file mode 100644 index 3baf106..0000000 --- a/js/taglib.js +++ /dev/null @@ -1,29 +0,0 @@ -import { TagLib as _TagLib } from 'taglib-wasm'; - -/** - * @type {typeof import('taglib-wasm').TagLib} - */ -export const TagLib = _TagLib; -import TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url'; - -export { TagLibWasm }; - -let tagLib = null; -const wasmBinary = fetch(TagLibWasm).then((r) => r.arrayBuffer()); - -/** - * - * @returns {ReturnType} - */ -export async function initTagLib() { - if (tagLib) return await tagLib; - - tagLib = TagLib.initialize({ - wasmBinary: await wasmBinary, - legacyMode: true, - }); - - console.log('TagLib initialized', { tagLib: await tagLib, TagLibWasm }); - - return await tagLib; -} diff --git a/js/taglib.ts b/js/taglib.ts new file mode 100644 index 0000000..fea89f8 --- /dev/null +++ b/js/taglib.ts @@ -0,0 +1,19 @@ +import { TagLib } from 'taglib-wasm'; +import { fetchBlobURL } from './utils'; +import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url'; + +let tagLib: Promise | null = null; + +export async function initTagLib(): Promise { + if (tagLib) return await tagLib; + + const TagLibWasm = await fetchBlobURL(_TagLibWasm); + + tagLib = TagLib.initialize({ + wasmUrl: TagLibWasm, + }); + + console.log('TagLib initialized', { tagLib: await tagLib, TagLibWasm }); + + return await tagLib; +} diff --git a/js/utils.js b/js/utils.js index 33e0bf0..c7281ac 100644 --- a/js/utils.js +++ b/js/utils.js @@ -533,3 +533,11 @@ export const getShareUrl = (path) => { const safePath = path.startsWith('/') ? path : `/${path}`; return `${baseUrl}${safePath}`; }; + +export function fetchBlob(url) { + return fetch(url).then((d) => d.blob()); +} + +export async function fetchBlobURL(url) { + return await URL.createObjectURL(await fetchBlob(url)); +} diff --git a/package.json b/package.json index af789e9..9fe206f 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "homepage": "https://github.com/SamidyFR/monochrome#readme", "devDependencies": { "@neutralinojs/neu": "^11.7.0", - "@types/node": "^25.3.3", + "@types/node": "^25.3.5", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "globals": "^17.4.0", - "htmlhint": "^1.9.1", + "htmlhint": "^1.9.2", "miniflare": "^4.20260301.1", "prettier": "^3.8.1", "stylelint": "^16.26.1", @@ -61,9 +61,9 @@ "cookie-session": "^2.1.1", "dashjs": "^5.1.1", "fuse.js": "^7.1.0", - "jose": "^6.1.3", + "jose": "^6.2.0", "npm": "^11.11.0", "pocketbase": "^0.26.8", - "taglib-wasm": "^0.9.0" + "taglib-wasm": "^1.0.5" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..71dc5e1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "!/*": ["node_modules/*"] + }, + "allowJs": true, + "checkJs": false, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["js/**/*.ts", "js/**/*.d.ts"] +} diff --git a/vite.config.js b/vite.config.js index 375098b..3f29d56 100644 --- a/vite.config.js +++ b/vite.config.js @@ -74,34 +74,6 @@ export default defineConfig(({ mode }) => { includeAssets: ['discord.html'], manifest: false, // Use existing public/manifest.json }), - { - name: 'ignore-taglib', - resolveId(id) { - if ( - id == './dist/taglib-wrapper.js' || - id == '../../build/taglib-wrapper.js' || - id == '../../dist/taglib-wrapper.js' - ) { - return path.resolve('node_modules/taglib-wasm/dist/taglib-wrapper.js'); - } - - return id; - }, - load(id) { - if (id.endsWith('taglib-wasm/dist/src/worker-pool.js')) { - return 'export const getGlobalWorkerPool = () => { throw new Error("Worker pool is not supported in this environment"); }; export class TagLibWorkerPool { constructor() { throw new Error("Worker pool is not supported in this environment"); } } export function createWorkerPool() { throw new Error("Worker pool is not supported in this environment"); } export function terminateGlobalWorkerPool() { throw new Error("Worker pool is not supported in this environment"); }'; - } - - if (id.endsWith('taglib-wasm/dist/src/runtime/wasmer-sdk-loader.js')) { - return [ - 'export const initializeWasmer = null;', - 'export const loadWasmerWasi = null', - 'export const isWasmerAvailable = null;', - 'export const WasmerExecutionError = null;', - ].join('\n'); - } - }, - }, ], }; });