feat: extract duplicated download utilities from api.js and downloads.js into download-utils.ts
Co-authored-by: DanTheMan827 <790119+DanTheMan827@users.noreply.github.com>
This commit is contained in:
parent
97bff01796
commit
c9a1f49f23
3 changed files with 91 additions and 289 deletions
192
js/api.js
192
js/api.js
|
|
@ -9,21 +9,15 @@ import {
|
||||||
getFullArtistString,
|
getFullArtistString,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { trackDateSettings, losslessContainerSettings } from './storage.js';
|
import { trackDateSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.js';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
import { MP3EncodingError } from './mp3-encoder.js';
|
import { MP3EncodingError } from './mp3-encoder.js';
|
||||||
import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js';
|
import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
|
||||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||||
import {
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||||
isCustomFormat,
|
|
||||||
getCustomFormat,
|
|
||||||
transcodeWithCustomFormat,
|
|
||||||
getContainerFormat,
|
|
||||||
transcodeWithContainerFormat,
|
|
||||||
} from './ffmpegFormats.ts';
|
|
||||||
|
|
||||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||||
|
|
@ -1426,170 +1420,7 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVideo) {
|
if (!isVideo) {
|
||||||
const coverBlobToEmbed = await prefetchPromises.coverFetch;
|
blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add metadata if track information is provided
|
// Add metadata if track information is provided
|
||||||
if (track) {
|
if (track) {
|
||||||
|
|
@ -1673,7 +1504,7 @@ export class LosslessAPI {
|
||||||
finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`);
|
finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.triggerDownload(blob, finalFilename);
|
triggerDownload(blob, finalFilename);
|
||||||
return blob;
|
return blob;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
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') {
|
getCoverUrl(id, size = '320') {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
||||||
|
|
|
||||||
79
js/download-utils.ts
Normal file
79
js/download-utils.ts
Normal file
|
|
@ -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<Blob> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
109
js/downloads.js
109
js/downloads.js
|
|
@ -14,19 +14,13 @@ import {
|
||||||
getFullArtistString,
|
getFullArtistString,
|
||||||
getMimeType,
|
getMimeType,
|
||||||
} from './utils.js';
|
} 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 { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.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 { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
import { loadFfmpeg } from './ffmpeg.js';
|
||||||
import {
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||||
isCustomFormat,
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||||
getCustomFormat,
|
|
||||||
transcodeWithCustomFormat,
|
|
||||||
getContainerFormat,
|
|
||||||
transcodeWithContainerFormat,
|
|
||||||
} from './ffmpegFormats.ts';
|
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
const bulkDownloadTasks = new Map();
|
const bulkDownloadTasks = new Map();
|
||||||
|
|
@ -464,88 +458,8 @@ async function downloadTrackBlob(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transcode to custom format if requested
|
// Apply audio post-processing (custom format transcoding + lossless container conversion)
|
||||||
if (isCustomFormat(quality)) {
|
blob = await applyAudioPostProcessing(blob, quality, onProgress, signal);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
@ -556,17 +470,6 @@ async function downloadTrackBlob(
|
||||||
return { blob, extension };
|
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) {
|
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) {
|
||||||
const { abortController } = bulkDownloadTasks.get(notification);
|
const { abortController } = bulkDownloadTasks.get(notification);
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue