This is resolved by using ffmpeg to copy the audio data into a new mp4 container file before passing it to taglib.
209 lines
7.1 KiB
JavaScript
209 lines
7.1 KiB
JavaScript
import {
|
|
getCoverBlob,
|
|
getTrackTitle,
|
|
getFullArtistString,
|
|
getMimeType,
|
|
getTrackCoverId,
|
|
getTrackDiscNumber,
|
|
getExtensionFromBlob,
|
|
} from './utils.js';
|
|
import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
|
import { doTimed, doTimedAsync } from './doTimed.ts';
|
|
import { managers } from './app.js';
|
|
|
|
export const METADATA_STRINGS = {
|
|
VENDOR_STRING: 'Monochrome',
|
|
DEFAULT_TITLE: 'Unknown Title',
|
|
DEFAULT_ARTIST: 'Unknown Artist',
|
|
DEFAULT_ALBUM: 'Unknown Album',
|
|
};
|
|
|
|
export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
|
const _tagLib = fetchTagLib().catch(console.error);
|
|
const coverId = getTrackCoverId(track);
|
|
const coverFetch = coverBlob
|
|
? Promise.resolve(coverBlob)
|
|
: coverId
|
|
? getCoverBlob(api, coverId).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
|
|
* @param {Object} track - Track metadata
|
|
* @param {Object} api - API instance for fetching album art
|
|
* @param {string} quality - Audio quality
|
|
* @returns {Promise<Blob>} - Audio blob with embedded metadata
|
|
*/
|
|
export async function addMetadataToAudio(audioBlob, track, api, _quality, prefetchPromises) {
|
|
const { coverFetch, lyricsFetch } = prefetchPromises;
|
|
|
|
/**
|
|
* @type {import("./taglib.worker.ts").TagLibMetadata}
|
|
*/
|
|
const data = {};
|
|
|
|
const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer());
|
|
|
|
try {
|
|
data.title = getTrackTitle(track);
|
|
data.artist = getFullArtistString(track);
|
|
data.albumTitle = track.album.title;
|
|
data.albumArtist = track.album?.artist?.name || track.artist?.name;
|
|
data.trackNumber = track.trackNumber;
|
|
data.discNumber = track.volumeNumber ?? track.discNumber;
|
|
data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks;
|
|
data.totalDiscs = track.album.totalDiscs;
|
|
data.copyright = track.copyright;
|
|
data.isrc = track.isrc;
|
|
data.explicit = Boolean(track.explicit);
|
|
|
|
if (track.bpm != null) {
|
|
const bpm = Number(track.bpm);
|
|
if (Number.isFinite(bpm)) {
|
|
data.bpm = Math.round(bpm);
|
|
}
|
|
}
|
|
|
|
if (track.replayGain) {
|
|
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
|
|
data.replayGain = {
|
|
albumReplayGain: `${Number(albumReplayGain)} dB`,
|
|
trackReplayGain: `${Number(trackReplayGain)} dB`,
|
|
albumPeakAmplitude: albumPeakAmplitude ? Number(albumPeakAmplitude) : undefined,
|
|
trackPeakAmplitude: trackPeakAmplitude ? Number(trackPeakAmplitude) : undefined,
|
|
};
|
|
}
|
|
|
|
const releaseDateStr =
|
|
track.album?.releaseDate?.trim() || track?.streamStartDate?.split('T')?.[0]?.trim() || undefined;
|
|
|
|
if (releaseDateStr) {
|
|
try {
|
|
const year = Number(releaseDateStr.split('-')[0]);
|
|
if (!isNaN(year)) {
|
|
data.releaseDate = String(releaseDateStr);
|
|
}
|
|
} catch {
|
|
// Invalid date, skip
|
|
console.warn('Invalid date', releaseDateStr);
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (track.album?.cover) {
|
|
const coverBlob = await coverFetch;
|
|
|
|
if (coverBlob) {
|
|
const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer());
|
|
data.cover = {
|
|
data: coverBuffer,
|
|
type: getMimeType(coverBuffer),
|
|
};
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Error setting cover metadata.', track, e);
|
|
}
|
|
|
|
try {
|
|
const lyrics = await lyricsFetch;
|
|
data.lyrics = lyrics?.subtitles || lyrics?.plainLyrics;
|
|
} catch (e) {
|
|
console.warn('Error setting lyrics metadata', track, e);
|
|
}
|
|
|
|
const newAudioBuffer = await addMetadataWithTagLib(audioBuffer, {
|
|
...data,
|
|
});
|
|
|
|
return doTimed(
|
|
'Create new audio blob',
|
|
() =>
|
|
new Blob([newAudioBuffer], {
|
|
type: audioBlob.type,
|
|
})
|
|
);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
|
|
return audioBlob;
|
|
}
|
|
|
|
/**
|
|
* Reads metadata from a file
|
|
* @param {File} file
|
|
* @returns {Promise<Object>} Track metadata
|
|
*/
|
|
export async function readTrackMetadata(file, siblings = []) {
|
|
const metadata = {
|
|
title: file.name.replace(/\.[^/.]+$/, ''),
|
|
artists: [],
|
|
artist: { name: 'Unknown Artist' }, // For fallback/compatibility
|
|
album: { title: 'Unknown Album', cover: 'assets/appicon.png', releaseDate: null },
|
|
duration: 0,
|
|
isrc: null,
|
|
copyright: null,
|
|
explicit: false,
|
|
isLocal: true,
|
|
file: file,
|
|
id: `local-${file.name}-${file.lastModified}`,
|
|
};
|
|
|
|
try {
|
|
const data = await getMetadataWithTagLib(await file.arrayBuffer());
|
|
|
|
if (data) {
|
|
metadata.title = data.title || metadata.title;
|
|
metadata.artists.push(
|
|
...(data.artist || '')
|
|
.split(';')
|
|
.map((a) => a.trim())
|
|
.filter((a) => a)
|
|
);
|
|
metadata.artist = data.artist || metadata.artist;
|
|
metadata.album.title = data.albumTitle || metadata.album.title;
|
|
metadata.album.releaseDate = data.releaseDate || metadata.album.releaseDate;
|
|
|
|
if (data.cover) {
|
|
const blob = new Blob([data.cover.data], { type: data.cover.type });
|
|
metadata.album.cover = URL.createObjectURL(blob);
|
|
}
|
|
|
|
metadata.duration = data.duration;
|
|
metadata.isrc = data.isrc || metadata.isrc;
|
|
metadata.copyright = data.copyright || metadata.copyright;
|
|
metadata.explicit = !!data.explicit;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Error reading metadata for', file.name, e);
|
|
}
|
|
|
|
if (metadata.artists.length > 0) {
|
|
metadata.artist = metadata.artists[0];
|
|
}
|
|
|
|
if (metadata.album.cover === 'assets/appicon.png' && siblings.length > 0) {
|
|
const baseName = file.name.substring(0, file.name.lastIndexOf('.'));
|
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
|
|
const coverFile = siblings.find((f) => {
|
|
const fName = f.name;
|
|
const lastDot = fName.lastIndexOf('.');
|
|
if (lastDot === -1) return false;
|
|
const fBase = fName.substring(0, lastDot);
|
|
const fExt = fName.substring(lastDot).toLowerCase();
|
|
return fBase === baseName && imageExtensions.includes(fExt);
|
|
});
|
|
|
|
if (coverFile) {
|
|
metadata.album.cover = URL.createObjectURL(coverFile);
|
|
}
|
|
}
|
|
|
|
return metadata;
|
|
}
|