feat(downloads): prefetch data while downloading to improve performance and update taglib-wasm

This commit is contained in:
Daniel 2026-03-05 19:43:13 +00:00 committed by GitHub
parent 1173388ee3
commit 0f20106076
11 changed files with 131 additions and 99 deletions

View file

@ -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=="],

View file

@ -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

View file

@ -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 };
}

4
js/global.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*?url' {
const content: string;
export default content;
}

View file

@ -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<Blob>} - 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;
}
/**

View file

@ -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<typeof TagLib.initialize>}
*/
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;
}

19
js/taglib.ts Normal file
View file

@ -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<TagLib> | null = null;
export async function initTagLib(): Promise<TagLib> {
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;
}

View file

@ -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));
}

View file

@ -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"
}
}

21
tsconfig.json Normal file
View file

@ -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"]
}

View file

@ -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');
}
},
},
],
};
});