Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
2f7f0e61dc
4 changed files with 137 additions and 79 deletions
|
|
@ -15,6 +15,7 @@ import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storag
|
||||||
import { addMetadataToAudio } from './metadata.js';
|
import { addMetadataToAudio } from './metadata.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.js';
|
||||||
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||||
|
import { encodeToMp3 } from './mp3-encoder.js';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
const bulkDownloadTasks = new Map();
|
const bulkDownloadTasks = new Map();
|
||||||
|
|
@ -272,6 +273,9 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
||||||
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
||||||
|
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullTrack = await api.getTrackMetadata(track.id);
|
const fullTrack = await api.getTrackMetadata(track.id);
|
||||||
if (fullTrack) {
|
if (fullTrack) {
|
||||||
|
|
@ -306,7 +310,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = await api.getTrack(track.id, quality);
|
const lookup = await api.getTrack(track.id, downloadQuality);
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
|
|
||||||
if (lookup.originalTrackUrl) {
|
if (lookup.originalTrackUrl) {
|
||||||
|
|
@ -327,7 +331,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
||||||
} catch (dashError) {
|
} catch (dashError) {
|
||||||
console.error('DASH download failed:', dashError);
|
console.error('DASH download failed:', dashError);
|
||||||
// Fallback
|
// Fallback
|
||||||
if (quality !== 'LOSSLESS') {
|
if (downloadQuality !== 'LOSSLESS') {
|
||||||
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
||||||
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal);
|
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal);
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +345,11 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
||||||
blob = await response.blob();
|
blob = await response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert to MP3 320kbps if requested
|
||||||
|
if (quality === 'MP3_320') {
|
||||||
|
blob = await encodeToMp3(blob, () => undefined, signal);
|
||||||
|
}
|
||||||
|
|
||||||
// Detect actual format from blob signature BEFORE adding metadata
|
// Detect actual format from blob signature BEFORE adding metadata
|
||||||
const extension = await getExtensionFromBlob(blob);
|
const extension = await getExtensionFromBlob(blob);
|
||||||
|
|
||||||
|
|
|
||||||
96
js/ffmpeg.js
Normal file
96
js/ffmpeg.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
class FfmpegError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FfmpegError';
|
||||||
|
this.code = 'FFMPEG_FAILED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ffmpegWorker(
|
||||||
|
audioBlob,
|
||||||
|
args = {},
|
||||||
|
outputName = 'output',
|
||||||
|
outputMime = 'application/octet-stream',
|
||||||
|
onProgress = null,
|
||||||
|
signal = null
|
||||||
|
) {
|
||||||
|
const audioData = await audioBlob.arrayBuffer();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(new URL('./ffmpeg.worker.js', import.meta.url), { type: 'module' });
|
||||||
|
|
||||||
|
// Handle abort signal
|
||||||
|
const abortHandler = () => {
|
||||||
|
worker.terminate();
|
||||||
|
reject(new FfmpegError('FFMPEG aborted'));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
abortHandler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', abortHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const { type, blob, message, stage, progress } = e.data;
|
||||||
|
|
||||||
|
if (type === 'complete') {
|
||||||
|
if (signal) signal.removeEventListener('abort', abortHandler);
|
||||||
|
worker.terminate();
|
||||||
|
resolve(blob);
|
||||||
|
} else if (type === 'error') {
|
||||||
|
if (signal) signal.removeEventListener('abort', abortHandler);
|
||||||
|
worker.terminate();
|
||||||
|
reject(new FfmpegError(message));
|
||||||
|
} else if (type === 'progress' && onProgress) {
|
||||||
|
onProgress({ stage, message, progress });
|
||||||
|
} else if (type === 'log') {
|
||||||
|
console.log('[FFmpeg]', message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = (error) => {
|
||||||
|
if (signal) signal.removeEventListener('abort', abortHandler);
|
||||||
|
worker.terminate();
|
||||||
|
reject(new FfmpegError('Worker failed: ' + error.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transfer audio data to worker
|
||||||
|
worker.postMessage(
|
||||||
|
{
|
||||||
|
audioData,
|
||||||
|
...args,
|
||||||
|
output: {
|
||||||
|
name: outputName,
|
||||||
|
mime: outputMime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[audioData]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ffmpeg(
|
||||||
|
audioBlob,
|
||||||
|
args = {},
|
||||||
|
outputName = 'output',
|
||||||
|
outputMime = 'application/octet-stream',
|
||||||
|
onProgress = null,
|
||||||
|
signal = null
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Use Web Worker for non-blocking FFmpeg encoding
|
||||||
|
if (typeof Worker !== 'undefined') {
|
||||||
|
return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FfmpegError('Web Workers are required for FFMPEG');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('FFMPEG failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FfmpegError };
|
||||||
|
|
@ -36,39 +36,41 @@ async function loadFFmpeg() {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = async (e) => {
|
self.onmessage = async (e) => {
|
||||||
const { audioData } = e.data;
|
const {
|
||||||
|
audioData,
|
||||||
|
args = [],
|
||||||
|
output = {
|
||||||
|
name: 'output',
|
||||||
|
mime: 'application/octet-stream',
|
||||||
|
},
|
||||||
|
encodeStartMessage = 'Encoding...',
|
||||||
|
encodeEndMessage = 'Finalizing...',
|
||||||
|
} = e.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadFFmpeg();
|
await loadFFmpeg();
|
||||||
|
|
||||||
self.postMessage({ type: 'progress', stage: 'encoding', message: 'Encoding to MP3 320kbps...' });
|
self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Write input file to FFmpeg virtual filesystem
|
// Write input file to FFmpeg virtual filesystem
|
||||||
await ffmpeg.writeFile('input', new Uint8Array(audioData));
|
await ffmpeg.writeFile('input', new Uint8Array(audioData));
|
||||||
|
|
||||||
// Encode to MP3 with 320kbps CBR, strip source metadata to avoid duplicate ID3 tags
|
const ffmpegArgs = ['-i', 'input', ...args, output.name];
|
||||||
await ffmpeg.exec([
|
|
||||||
'-i',
|
|
||||||
'input',
|
|
||||||
'-map_metadata',
|
|
||||||
'-1',
|
|
||||||
'-c:a',
|
|
||||||
'libmp3lame',
|
|
||||||
'-b:a',
|
|
||||||
'320k',
|
|
||||||
'-ar',
|
|
||||||
'44100',
|
|
||||||
'output.mp3',
|
|
||||||
]);
|
|
||||||
|
|
||||||
self.postMessage({ type: 'progress', stage: 'finalizing', message: 'Finalizing MP3...' });
|
// Log the exact FFmpeg command being run for debugging.
|
||||||
|
self.postMessage({ type: 'log', message: `Running with args: ${ffmpegArgs.join(' ')}` });
|
||||||
|
|
||||||
|
// Run FFMPEG with the provided arguments.
|
||||||
|
await ffmpeg.exec(ffmpegArgs);
|
||||||
|
|
||||||
|
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage });
|
||||||
|
|
||||||
// Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer
|
// Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer
|
||||||
const data = await ffmpeg.readFile('output.mp3');
|
const data = await ffmpeg.readFile(output.name);
|
||||||
const mp3Blob = new Blob([data], { type: 'audio/mpeg' });
|
const outputBlob = new Blob([data], { type: output.mime });
|
||||||
|
|
||||||
self.postMessage({ type: 'complete', blob: mp3Blob });
|
self.postMessage({ type: 'complete', blob: outputBlob });
|
||||||
} finally {
|
} finally {
|
||||||
// Always cleanup virtual filesystem files
|
// Always cleanup virtual filesystem files
|
||||||
try {
|
try {
|
||||||
|
|
@ -77,7 +79,7 @@ self.onmessage = async (e) => {
|
||||||
// File may not exist if writeFile failed
|
// File may not exist if writeFile failed
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await ffmpeg.deleteFile('output.mp3');
|
await ffmpeg.deleteFile(output.name);
|
||||||
} catch {
|
} catch {
|
||||||
// File may not exist if exec failed
|
// File may not exist if exec failed
|
||||||
}
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { ffmpeg } from './ffmpeg';
|
||||||
|
|
||||||
class MP3EncodingError extends Error {
|
class MP3EncodingError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -6,71 +8,20 @@ class MP3EncodingError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encodeToMp3Worker(audioBlob, onProgress = null, signal = null) {
|
|
||||||
const audioData = await audioBlob.arrayBuffer();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const worker = new Worker(new URL('./mp3-encoder.worker.js', import.meta.url), { type: 'module' });
|
|
||||||
|
|
||||||
// Handle abort signal
|
|
||||||
const abortHandler = () => {
|
|
||||||
worker.terminate();
|
|
||||||
reject(new MP3EncodingError('MP3 encoding aborted'));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
abortHandler();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', abortHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
worker.onmessage = (e) => {
|
|
||||||
const { type, blob, message, stage, progress } = e.data;
|
|
||||||
|
|
||||||
if (type === 'complete') {
|
|
||||||
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
||||||
worker.terminate();
|
|
||||||
resolve(blob);
|
|
||||||
} else if (type === 'error') {
|
|
||||||
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
||||||
worker.terminate();
|
|
||||||
reject(new MP3EncodingError(message));
|
|
||||||
} else if (type === 'progress' && onProgress) {
|
|
||||||
onProgress({ stage, message, progress });
|
|
||||||
} else if (type === 'log') {
|
|
||||||
console.log('[FFmpeg]', message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
worker.onerror = (error) => {
|
|
||||||
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
||||||
worker.terminate();
|
|
||||||
reject(new MP3EncodingError('Worker failed: ' + error.message));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Transfer audio data to worker
|
|
||||||
worker.postMessage(
|
|
||||||
{
|
|
||||||
audioData,
|
|
||||||
},
|
|
||||||
[audioData]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
|
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
|
||||||
try {
|
try {
|
||||||
// Use Web Worker for non-blocking FFmpeg encoding
|
// Use Web Worker for non-blocking FFmpeg encoding
|
||||||
if (typeof Worker !== 'undefined') {
|
if (typeof Worker !== 'undefined') {
|
||||||
return await encodeToMp3Worker(audioBlob, onProgress, signal);
|
const args = ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'];
|
||||||
|
|
||||||
|
return await ffmpeg(audioBlob, { args }, 'output.mp3', 'audio/mpeg', onProgress, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new MP3EncodingError('Web Workers are required for MP3 encoding');
|
throw new MP3EncodingError('Web Workers are required for MP3 encoding');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('MP3 encoding failed:', error);
|
console.error('MP3 encoding failed:', error);
|
||||||
throw error;
|
|
||||||
|
throw new MP3EncodingError(error?.message ?? error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue