import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js'; import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { doTimed, doTimedAsync } from './doTimed.ts'; import { LyricsManager } from './lyrics.js'; export function prefetchMetadataObjects(track, api, coverBlob = null) { const coverId = getTrackCoverId(track); const coverFetch = coverBlob ? Promise.resolve(coverBlob) : coverId ? getCoverBlob(api, coverId).catch(console.error) : Promise.resolve(null); const lyricsFetch = LyricsManager.instance.fetchLyrics?.(track.id, track)?.catch(console.error); return { 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} - 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 = {}; 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); } return await addMetadataWithTagLib( audioBlob, { ...data, }, undefined, true, true ); } catch (err) { console.error(err); } return audioBlob; } /** * Reads metadata from a file * @param {Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry} file * @returns {Promise} Track metadata */ export async function readTrackMetadata(file, { filename = file?.name || 'Unknown Title', siblings } = {}) { const metadata = { title: filename?.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-${filename}-${file.lastModified}`, }; try { const data = await getMetadataWithTagLib(file, filename, true); if (data) { metadata.title = data.title || metadata.title; const artistNames = (data.artist || '') .split(';') .map((a) => a.trim()) .filter((a) => a); if (artistNames.length > 0) { metadata.artists = artistNames.map((name) => ({ name })); metadata.artist = metadata.artists[0]; } metadata.album.title = data.albumTitle || metadata.album.title; metadata.album.releaseDate = data.releaseDate || metadata.album.releaseDate; if (data.albumArtist) { metadata.album.artist = { name: data.albumArtist }; } else if (metadata.artist.name !== 'Unknown Artist') { metadata.album.artist = { name: metadata.artist.name }; } 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', filename, e); } if (metadata.album.cover === 'assets/appicon.png' && siblings.length > 0) { const baseName = filename.substring(0, filename.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; }