diff --git a/js/api.js b/js/api.js index ce7434d..0b8e465 100644 --- a/js/api.js +++ b/js/api.js @@ -9,21 +9,15 @@ import { getFullArtistString, getMimeType, } from './utils.js'; -import { trackDateSettings, losslessContainerSettings } from './storage.js'; +import { trackDateSettings } from './storage.js'; import { APICache } from './cache.js'; 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 { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; -import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; -import { - isCustomFormat, - getCustomFormat, - transcodeWithCustomFormat, - getContainerFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats.ts'; +import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; +import { isCustomFormat } from './ffmpegFormats.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1426,170 +1420,7 @@ 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 { - 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({ - stage: 'error', - message: `Encoding failed: ${encodingError.message}`, - }); - } - throw encodingError; - } - } - } - - if (quality.endsWith('LOSSLESS')) { - try { - const containerType = losslessContainerSettings.getContainer(); - const containerFmt = getContainerFormat(containerType); - - if (containerFmt && containerType !== 'nochange') { - if (await containerFmt.needsTranscode(blob)) { - 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, - 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') { - throw error; - } - - console.error('Lossless container conversion failed:', error); - } - } + blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal); // Add metadata if track information is provided if (track) { @@ -1673,7 +1504,7 @@ export class LosslessAPI { finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`); } - this.triggerDownload(blob, finalFilename); + triggerDownload(blob, finalFilename); return blob; } catch (error) { if (error.name === 'AbortError') { @@ -1694,17 +1525,6 @@ export class LosslessAPI { } } - triggerDownload(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - getCoverUrl(id, size = '320') { if (!id) { return `https://picsum.photos/seed/${Math.random()}/${size}`; diff --git a/js/download-utils.ts b/js/download-utils.ts new file mode 100644 index 0000000..1bf62fc --- /dev/null +++ b/js/download-utils.ts @@ -0,0 +1,79 @@ +import { losslessContainerSettings } from './storage'; +import { rebuildFlacWithoutMetadata } from './metadata.flac'; +import { getExtensionFromBlob } from './utils'; +import { + type ProgressEvent, + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats'; + +/** + * Triggers a browser file download for the given blob. + */ +export function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Applies audio post-processing to a blob: + * 1. Transcodes to a custom ffmpeg format if `quality` identifies one. + * 2. Re-muxes to the user-selected lossless container when the quality is + * a lossless tier (quality ends with "LOSSLESS"). + * + * Returns the (possibly transformed) blob. + */ +export async function applyAudioPostProcessing( + blob: Blob, + quality: string, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + // Transcode to custom format if requested + if (isCustomFormat(quality)) { + const format = getCustomFormat(quality); + if (format) { + try { + blob = await transcodeWithCustomFormat(blob, format, onProgress, signal); + } catch (encodingError) { + if (onProgress) { + onProgress({ + stage: 'error', + message: `Encoding failed: ${(encodingError as Error).message}`, + }); + } + throw encodingError; + } + } + } + + if (quality.endsWith('LOSSLESS')) { + try { + const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); + if (containerFmt) { + if (await containerFmt.needsTranscode(blob)) { + blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); + } else if ((await getExtensionFromBlob(blob)) == 'flac') { + blob = await rebuildFlacWithoutMetadata(blob); + } + } + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + throw error; + } + + console.error('Lossless container conversion failed:', error); + } + } + + return blob; +} diff --git a/js/downloads.js b/js/downloads.js index e2dd754..8c77937 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -14,19 +14,13 @@ import { getFullArtistString, getMimeType, } from './utils.js'; -import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; +import { lyricsSettings, bulkDownloadSettings, 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 { ffmpeg, loadFfmpeg } from './ffmpeg.js'; -import { - isCustomFormat, - getCustomFormat, - transcodeWithCustomFormat, - getContainerFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats.ts'; +import { loadFfmpeg } from './ffmpeg.js'; +import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; +import { isCustomFormat } from './ffmpegFormats.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -464,88 +458,8 @@ async function downloadTrackBlob( } } - // Transcode to custom format if requested - if (isCustomFormat(quality)) { - const format = getCustomFormat(quality); - if (format) { - 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 containerType = losslessContainerSettings.getContainer(); - const containerFmt = getContainerFormat(containerType); - - if (containerFmt && containerType !== 'nochange') { - if (await containerFmt.needsTranscode(blob)) { - 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') { - throw error; - } - - console.error('Lossless container conversion failed:', error); - } - } + // Apply audio post-processing (custom format transcoding + lossless container conversion) + blob = await applyAudioPostProcessing(blob, quality, onProgress, signal); // Detect actual format from blob signature BEFORE adding metadata const extension = await getExtensionFromBlob(blob); @@ -556,17 +470,6 @@ async function downloadTrackBlob( return { blob, extension }; } -function triggerDownload(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) { const { abortController } = bulkDownloadTasks.get(notification); const signal = abortController.signal;