diff --git a/js/api.js b/js/api.js index cf71f83..429c80b 100644 --- a/js/api.js +++ b/js/api.js @@ -5,6 +5,9 @@ import { delay, isTrackUnavailable, getExtensionFromBlob, + getTrackTitle, + getFullArtistString, + getMimeType, } from './utils.js'; import { trackDateSettings, losslessContainerSettings } from './storage.js'; import { APICache } from './cache.js'; @@ -12,7 +15,7 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { HlsDownloader } from './hls-downloader.js'; import { MP3EncodingError } from './mp3-encoder.js'; -import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { isCustomFormat, @@ -1423,12 +1426,65 @@ export class LosslessAPI { } if (!isVideo) { + const coverBlobToEmbed = await prefetchPromises.coverFetch; + const extraFiles = []; + const ffmpegMetadataArgs = []; + + if (coverBlobToEmbed) { + const coverBuffer = await coverBlobToEmbed.arrayBuffer(); + const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; + const coverName = `cover.${coverExt}`; + extraFiles.push({ + name: coverName, + data: coverBuffer + }); + ffmpegMetadataArgs.push('-i', coverName); + } + + if (track) { + ffmpegMetadataArgs.push( + '-metadata', `title=${getTrackTitle(track)}`, + '-metadata', `artist=${getFullArtistString(track)}`, + '-metadata', `album=${track.album?.title || ''}`, + '-metadata', `album_artist=${track.album?.artist?.name || track.artist?.name || ''}` + ); + + const trackNum = track.trackNumber; + if (trackNum) { + const totalTracks = track.album?.numberOfTracks; + ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`); + } + + const discNum = track.volumeNumber || track.discNumber; + if (discNum) { + ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); + } + + const releaseDate = track.album?.releaseDate || track?.streamStartDate; + if (releaseDate) { + ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`); + } + } + // Transcode to custom format if requested if (isCustomFormat(quality)) { const format = getCustomFormat(quality); if (format) { try { - blob = await transcodeWithCustomFormat(blob, format, onProgress, options.signal); + const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( + blob, + { args }, + format.outputFilename, + format.outputMime, + onProgress, + options.signal, + extraFiles + ); } catch (encodingError) { if (onProgress) { onProgress({ @@ -1443,18 +1499,56 @@ export class LosslessAPI { if (quality.endsWith('LOSSLESS')) { try { - const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); - if (containerFmt) { + const containerType = losslessContainerSettings.getContainer(); + const containerFmt = getContainerFormat(containerType); + + if (containerFmt && containerType !== 'nochange') { if (await containerFmt.needsTranscode(blob)) { - blob = await transcodeWithContainerFormat( + const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( blob, - containerFmt, + { args }, + containerFmt.outputFilename, + containerFmt.outputMime, onProgress, - options.signal + options.signal, + extraFiles ); } else if ((await getExtensionFromBlob(blob)) == 'flac') { blob = await rebuildFlacWithoutMetadata(blob); } + } else { + const actualExtension = await getExtensionFromBlob(blob); + if (actualExtension === 'm4a' || actualExtension === 'mp4') { + try { + const ffmpegArgs = [...ffmpegMetadataArgs]; + + ffmpegArgs.push('-map', '0:a'); + if (coverBlobToEmbed) { + ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); + + const remuxedBlob = await ffmpeg( + blob, + { args: ffmpegArgs }, + 'output.mp4', + 'audio/mp4', + onProgress, + options.signal, + extraFiles + ); + if (remuxedBlob) { + blob = remuxedBlob; + } + } catch (e) { + console.warn('Failed to remux hi-res M4A, proceeding with original:', e); + } + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/downloads.js b/js/downloads.js index e730025..cc3adea 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -11,13 +11,15 @@ import { getExtensionFromBlob, escapeHtml, getTrackDiscNumber, + getFullArtistString, + getMimeType, } from './utils.js'; import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { DashDownloader } from './dash-downloader.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; -import { loadFfmpeg } from './ffmpeg.js'; +import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; import { isCustomFormat, getCustomFormat, @@ -418,23 +420,119 @@ async function downloadTrackBlob( blob = await response.blob(); } + const coverBlobToEmbed = await prefetchPromises.coverFetch; + const extraFiles = []; + const ffmpegMetadataArgs = []; + + if (coverBlobToEmbed) { + const coverBuffer = await coverBlobToEmbed.arrayBuffer(); + const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; + const coverName = `cover.${coverExt}`; + extraFiles.push({ + name: coverName, + data: coverBuffer + }); + ffmpegMetadataArgs.push('-i', coverName); + } + + if (enrichedTrack) { + ffmpegMetadataArgs.push( + '-metadata', `title=${getTrackTitle(enrichedTrack)}`, + '-metadata', `artist=${getFullArtistString(enrichedTrack)}`, + '-metadata', `album=${enrichedTrack.album?.title || ''}`, + '-metadata', `album_artist=${enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name || ''}` + ); + + const trackNum = enrichedTrack.trackNumber; + if (trackNum) { + const totalTracks = enrichedTrack.album?.numberOfTracks; + ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`); + } + + const discNum = enrichedTrack.volumeNumber || enrichedTrack.discNumber; + if (discNum) { + ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); + } + + const releaseDate = enrichedTrack.album?.releaseDate || enrichedTrack?.streamStartDate; + if (releaseDate) { + ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`); + } + } + // Transcode to custom format if requested if (isCustomFormat(quality)) { const format = getCustomFormat(quality); if (format) { - blob = await transcodeWithCustomFormat(blob, format, onProgress || (() => undefined), signal); + const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( + blob, + { args }, + format.outputFilename, + format.outputMime, + onProgress, + signal, + extraFiles + ); } } if (quality.endsWith('LOSSLESS')) { try { - const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); - if (containerFmt) { + const containerType = losslessContainerSettings.getContainer(); + const containerFmt = getContainerFormat(containerType); + + if (containerFmt && containerType !== 'nochange') { if (await containerFmt.needsTranscode(blob)) { - blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); + const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( + blob, + { args }, + containerFmt.outputFilename, + containerFmt.outputMime, + onProgress, + signal, + extraFiles + ); } else if ((await getExtensionFromBlob(blob)) == 'flac') { blob = await rebuildFlacWithoutMetadata(blob); } + } else { + const actualExtension = await getExtensionFromBlob(blob); + if (actualExtension === 'm4a' || actualExtension === 'mp4') { + try { + const ffmpegArgs = [...ffmpegMetadataArgs]; + + ffmpegArgs.push('-map', '0:a'); + if (coverBlobToEmbed) { + ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); + + const remuxedBlob = await ffmpeg( + blob, + { args: ffmpegArgs }, + 'output.mp4', + 'audio/mp4', + onProgress, + signal, + extraFiles + ); + if (remuxedBlob) { + blob = remuxedBlob; + } + } catch (e) { + console.warn('Failed to remux hi-res M4A, proceeding with original:', e); + } + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/ffmpeg.js b/js/ffmpeg.js index 0ea51d8..93cd1ab 100644 --- a/js/ffmpeg.js +++ b/js/ffmpeg.js @@ -32,9 +32,10 @@ async function ffmpegWorker( outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, - signal = null + signal = null, + extraFiles = [] ) { - const audioData = await audioBlob.arrayBuffer(); + const audioData = audioBlob ? await audioBlob.arrayBuffer() : null; const assets = loadFfmpeg(); return new Promise((resolve, reject) => { @@ -79,9 +80,20 @@ async function ffmpegWorker( }; (async () => { + const transferables = []; + if (audioData) transferables.push(audioData); + for (const f of extraFiles) { + if (f.data instanceof ArrayBuffer) { + transferables.push(f.data); + } else if (f.data.buffer instanceof ArrayBuffer) { + transferables.push(f.data.buffer); + } + } + worker.postMessage( { audioData, + extraFiles, ...args, output: { name: outputName, @@ -89,7 +101,7 @@ async function ffmpegWorker( }, loadOptions: await assets, }, - [audioData] + transferables ); })(); }); @@ -101,12 +113,13 @@ export async function ffmpeg( outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, - signal = null + signal = null, + extraFiles = [] ) { try { // Use Web Worker for non-blocking FFmpeg encoding if (typeof Worker !== 'undefined') { - return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal); + return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal, extraFiles); } throw new FfmpegError('Web Workers are required for FFMPEG'); diff --git a/js/ffmpeg.worker.js b/js/ffmpeg.worker.js index 969a126..f4ad105 100644 --- a/js/ffmpeg.worker.js +++ b/js/ffmpeg.worker.js @@ -98,6 +98,7 @@ async function loadFFmpeg(loadOptions = {}) { self.onmessage = async (e) => { const { audioData, + extraFiles = [], args = [], output = { name: 'output', @@ -109,42 +110,40 @@ self.onmessage = async (e) => { } = e.data; try { - console.log(loadOptions); await loadFFmpeg(loadOptions); self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage, progress: 0.0 }); try { - // Write input file to FFmpeg virtual filesystem - await ffmpeg.writeFile('input', new Uint8Array(audioData)); + if (audioData) { + await ffmpeg.writeFile('input', new Uint8Array(audioData)); + } + + for (const file of extraFiles) { + await ffmpeg.writeFile(file.name, new Uint8Array(file.data)); + } const ffmpegArgs = ['-i', 'input', ...args, output.name]; + self.postMessage({ type: 'log', message: `FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}` }); - // Log the exact FFmpeg command being run for debugging. - self.postMessage({ type: 'log', message: `Running with args: ${ffmpegArgs.join(' ')}` }); + const exitCode = await ffmpeg.exec(ffmpegArgs); - // Run FFMPEG with the provided arguments. - await ffmpeg.exec(ffmpegArgs); + if (exitCode !== 0) { + throw new Error(`FFmpeg failed with exit code ${exitCode}.`); + } self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 }); - // Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer const data = await ffmpeg.readFile(output.name); const outputBlob = new Blob([data], { type: output.mime }); self.postMessage({ type: 'complete', blob: outputBlob }); } finally { - // Always cleanup virtual filesystem files - try { - await ffmpeg.deleteFile('input'); - } catch { - // File may not exist if writeFile failed - } - try { - await ffmpeg.deleteFile(output.name); - } catch { - // File may not exist if exec failed + try { if (audioData) await ffmpeg.deleteFile('input'); } catch {} + for (const file of extraFiles) { + try { await ffmpeg.deleteFile(file.name); } catch {} } + try { await ffmpeg.deleteFile(output.name); } catch {} } } catch (error) { self.postMessage({ type: 'error', message: error.message }); diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts index 5e5a9cb..5baa79c 100644 --- a/js/ffmpegFormats.ts +++ b/js/ffmpegFormats.ts @@ -210,9 +210,18 @@ export async function transcodeWithCustomFormat( audioBlob: Blob, format: CustomFormat, onProgress: ((progress: ProgressEvent) => void) | null = null, - signal: AbortSignal | null = null + signal: AbortSignal | null = null, + extraFiles: any[] = [] ): Promise { - return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); + return ffmpeg( + audioBlob, + { args: format.ffmpegArgs }, + format.outputFilename, + format.outputMime, + onProgress, + signal, + extraFiles + ); } /** @@ -223,7 +232,16 @@ export async function transcodeWithContainerFormat( audioBlob: Blob, format: ContainerFormat, onProgress: ((progress: ProgressEvent) => void) | null = null, - signal: AbortSignal | null = null + signal: AbortSignal | null = null, + extraFiles: any[] = [] ): Promise { - return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); + return ffmpeg( + audioBlob, + { args: format.ffmpegArgs }, + format.outputFilename, + format.outputMime, + onProgress, + signal, + extraFiles + ); } diff --git a/js/metadata.js b/js/metadata.js index 0440bb5..29d6d46 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -5,6 +5,7 @@ import { getMimeType, getTrackCoverId, getTrackDiscNumber, + getExtensionFromBlob, } from './utils.js'; import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { doTimed, doTimedAsync } from './doTimed.ts'; @@ -42,10 +43,18 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet const { coverFetch, lyricsFetch } = prefetchPromises; /** - * @type {import("./taglib.types.ts").TagLibMetadata} + * @type {import("./taglib.worker.ts").TagLibMetadata} */ const data = {}; + const detectedExt = await getExtensionFromBlob(audioBlob); + const isM4A = detectedExt === 'm4a' || detectedExt === 'mp4'; + + if (isM4A) { + console.log('Skipping TagLib for M4A (handled by FFmpeg)'); + return audioBlob; + } + const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer()); try { @@ -55,8 +64,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet 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.totalTracks = track.album.numberOfTracks; data.copyright = track.copyright; data.isrc = track.isrc; data.explicit = Boolean(track.explicit); @@ -96,9 +104,9 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet try { if (track.album?.cover) { const coverBlob = await coverFetch; - const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer()); if (coverBlob) { + const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer()); data.cover = { data: coverBuffer, type: getMimeType(coverBuffer), diff --git a/js/settings.js b/js/settings.js index d562de4..22a48ba 100644 --- a/js/settings.js +++ b/js/settings.js @@ -41,7 +41,6 @@ import { getButterchurnPresets } from './visualizers/butterchurn.js'; import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; -import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js'; import { containerFormats, customFormats } from './ffmpegFormats.ts'; export function initializeSettings(scrobbler, player, api, ui) { diff --git a/js/taglib.ts b/js/taglib.ts index c70ef53..d4dbad8 100644 --- a/js/taglib.ts +++ b/js/taglib.ts @@ -47,7 +47,13 @@ export async function addMetadataWithTagLib( }; worker.onerror = reject; worker.onmessageerror = reject; - worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, [audioData.buffer]); + + const transferables: Transferable[] = [audioData.buffer]; + if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) { + transferables.push((data as any).cover.data.buffer); + } + + worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, transferables); }); }