kv-music/js/metadata.js
Daniel b04019f282 fix(downloads): mp4 files with flac audio are now tagged
This is resolved by using ffmpeg to copy the audio data into a new mp4 container file before passing it to taglib.
2026-03-12 06:43:45 +00:00

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