fix(downloading): hi-res M4A's having no embedded covers (FUCK YOU TAGLIB)

This commit is contained in:
Samidy 2026-03-12 07:12:02 +03:00
parent 0ed82f586c
commit 30b2e7d445
8 changed files with 280 additions and 45 deletions

108
js/api.js
View file

@ -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') {

View file

@ -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') {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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