Merge pull request #303 from DanTheMan827/copilot/extract-duplicated-code-to-ts
Extract shared download utilities and abstract bulk download handlers + new folder picker support
This commit is contained in:
commit
a776e24aee
16 changed files with 544 additions and 846 deletions
3
bun.lock
3
bun.lock
|
|
@ -14,6 +14,7 @@
|
|||
"appwrite": "^23.0.0",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"client-zip": "^2.5.0",
|
||||
"cookie-session": "^2.1.1",
|
||||
"dashjs": "^5.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
|
|
@ -630,6 +631,8 @@
|
|||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"client-zip": ["client-zip@2.5.0", "", {}, "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ=="],
|
||||
|
||||
"codem-isoboxer": ["codem-isoboxer@0.3.10", "", {}, "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
|
|
|||
37
index.html
37
index.html
|
|
@ -5054,14 +5054,27 @@
|
|||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Zipped Bulk Downloads</span>
|
||||
<span class="label">Bulk Download Method</span>
|
||||
<span class="description"
|
||||
>Download multiple tracks as a single ZIP file (requires browser
|
||||
support)</span
|
||||
>Choose how multiple tracks are downloaded together</span
|
||||
>
|
||||
</div>
|
||||
<select id="bulk-download-method">
|
||||
<option value="zip">ZIP Archive</option>
|
||||
<option value="folder">Folder Picker</option>
|
||||
<option value="individual">Individual Files</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Force ZIP as Blob</span>
|
||||
<span class="description"
|
||||
>Download ZIP in memory instead of streaming to disk (use if ZIP streaming
|
||||
causes issues)</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="zipped-bulk-downloads-toggle" checked />
|
||||
<input type="checkbox" id="force-zip-blob-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -5106,7 +5119,9 @@
|
|||
<span class="label">Lossless Container</span>
|
||||
<span class="description">Container format for lossless downloads</span>
|
||||
</div>
|
||||
<select id="lossless-container-setting"></select>
|
||||
<select id="lossless-container-setting">
|
||||
<option value="nochange">Don't change</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
|
|
@ -5225,7 +5240,7 @@
|
|||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Separate Discs in ZIP</span>
|
||||
<span class="label">Separate Discs</span>
|
||||
<span class="description"
|
||||
>Put tracks in Disc folders when a release has multiple discs</span
|
||||
>
|
||||
|
|
@ -5235,6 +5250,16 @@
|
|||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Include Cover File</span>
|
||||
<span class="description">Include cover.jpg in downloads</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="include-cover-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
192
js/api.js
192
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';
|
||||
|
|
@ -1425,170 +1419,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) {
|
||||
|
|
@ -1672,7 +1503,7 @@ export class LosslessAPI {
|
|||
finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`);
|
||||
}
|
||||
|
||||
this.triggerDownload(blob, finalFilename);
|
||||
triggerDownload(blob, finalFilename);
|
||||
return blob;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
|
|
@ -1693,17 +1524,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}`;
|
||||
|
|
|
|||
180
js/bulk-download-writer.ts
Normal file
180
js/bulk-download-writer.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { triggerDownload } from './download-utils';
|
||||
|
||||
/**
|
||||
* A single entry to be included in a ZIP archive or written directly to a folder.
|
||||
*/
|
||||
export interface WriterEntry {
|
||||
name: string;
|
||||
lastModified: Date;
|
||||
input: Blob | string | ArrayBuffer | Uint8Array;
|
||||
}
|
||||
|
||||
/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */
|
||||
interface NeutralinoBridge {
|
||||
os: {
|
||||
showSaveDialog(
|
||||
title: string,
|
||||
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
|
||||
): Promise<string | null>;
|
||||
};
|
||||
filesystem: {
|
||||
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
||||
appendBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadClientZip() {
|
||||
try {
|
||||
return await import('client-zip');
|
||||
} catch (error) {
|
||||
console.error('Failed to load client-zip:', error);
|
||||
throw new Error('Failed to load ZIP library');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for writing a collection of file entries to an output destination.
|
||||
* Each implementation handles its own output selection (save dialog, directory picker, etc.)
|
||||
* and throws a DOMException with name 'AbortError' if the user cancels.
|
||||
*/
|
||||
export interface IBulkDownloadWriter {
|
||||
write(files: AsyncIterable<WriterEntry>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a ZIP archive to a file via the File System Access API.
|
||||
* Prompts the user to choose a save location with showSaveFilePicker.
|
||||
*/
|
||||
export class ZipStreamWriter implements IBulkDownloadWriter {
|
||||
constructor(private readonly suggestedFilename: string) {}
|
||||
|
||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||
// showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fileHandle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: this.suggestedFilename,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
const { downloadZip } = await loadClientZip();
|
||||
const writable = await fileHandle.createWritable();
|
||||
const response = downloadZip(files);
|
||||
if (!response.body) throw new Error('ZIP response body is null');
|
||||
await response.body.pipeTo(writable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects a ZIP archive into a Blob and triggers a browser download.
|
||||
* Works on all browsers without requiring the File System Access API.
|
||||
*/
|
||||
export class ZipBlobWriter implements IBulkDownloadWriter {
|
||||
constructor(private readonly filename: string) {}
|
||||
|
||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||
const { downloadZip } = await loadClientZip();
|
||||
const response = downloadZip(files);
|
||||
const blob = await response.blob();
|
||||
triggerDownload(blob, this.filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a ZIP archive to the filesystem via the Neutralino desktop bridge,
|
||||
* showing a native save dialog first.
|
||||
*/
|
||||
export class ZipNeutralinoWriter implements IBulkDownloadWriter {
|
||||
constructor(private readonly folderName: string) {}
|
||||
|
||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||
const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge;
|
||||
|
||||
const savePath = await bridge.os.showSaveDialog(`Select save location for ${this.folderName}.zip`, {
|
||||
defaultPath: `${this.folderName}.zip`,
|
||||
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }],
|
||||
});
|
||||
|
||||
if (!savePath) {
|
||||
throw new DOMException('User cancelled save dialog', 'AbortError');
|
||||
}
|
||||
|
||||
const { downloadZip } = await loadClientZip();
|
||||
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
|
||||
|
||||
const response = downloadZip(files);
|
||||
if (!response.body) throw new Error('ZIP response body is null');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||
await bridge.filesystem.appendBinaryFile(savePath, chunk);
|
||||
receivedLength += value.length;
|
||||
}
|
||||
|
||||
console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes files directly into a user-chosen folder using the standard browser
|
||||
* File System Access API (showDirectoryPicker). Subdirectories embedded in
|
||||
* file entry names are created automatically.
|
||||
*
|
||||
* Use the static {@link FolderPickerWriter.create} method to obtain an instance;
|
||||
* the constructor is private so the directory handle is always set before use.
|
||||
*/
|
||||
export class FolderPickerWriter implements IBulkDownloadWriter {
|
||||
private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {}
|
||||
|
||||
/**
|
||||
* Prompts the user to pick a writable directory.
|
||||
* Returns a new {@link FolderPickerWriter} bound to the chosen directory.
|
||||
* If the user dismisses the picker, the promise rejects with a DOMException
|
||||
* whose name is "AbortError".
|
||||
*/
|
||||
static async create(): Promise<FolderPickerWriter> {
|
||||
// showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({
|
||||
mode: 'readwrite',
|
||||
});
|
||||
return new FolderPickerWriter(dirHandle);
|
||||
}
|
||||
|
||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||
for await (const file of files) {
|
||||
const parts = file.name.split('/').filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
let currentDir: FileSystemDirectoryHandle = this.dirHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentDir = await currentDir.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
const filename = parts[parts.length - 1];
|
||||
const fileHandle = await currentDir.getFileHandle(filename, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
const { input } = file;
|
||||
if (input instanceof Blob) {
|
||||
await writable.write(input);
|
||||
} else if (typeof input === 'string') {
|
||||
await writable.write(new Blob([input], { type: 'text/plain' }));
|
||||
} else {
|
||||
// ArrayBuffer or Uint8Array – wrap in a Blob to guarantee strict typing.
|
||||
// Use byteOffset/byteLength so only the view's range is included, not the
|
||||
// whole backing ArrayBuffer (which may be larger due to pooling).
|
||||
const buf =
|
||||
input instanceof Uint8Array
|
||||
? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)
|
||||
: input;
|
||||
await writable.write(new Blob([buf as ArrayBuffer]));
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts
|
||||
export {
|
||||
type ProgressEvent,
|
||||
type CustomFormat,
|
||||
type ContainerFormat,
|
||||
customFormats,
|
||||
containerFormats,
|
||||
isCustomFormat,
|
||||
getCustomFormat,
|
||||
getContainerFormat,
|
||||
transcodeWithCustomFormat,
|
||||
transcodeWithContainerFormat,
|
||||
} from './ffmpegFormats';
|
||||
88
js/download-utils.ts
Normal file
88
js/download-utils.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { losslessContainerSettings } from './storage';
|
||||
import { getExtensionFromBlob } from './utils';
|
||||
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
||||
import {
|
||||
type ProgressEvent,
|
||||
isCustomFormat,
|
||||
getCustomFormat,
|
||||
transcodeWithCustomFormat,
|
||||
getContainerFormat,
|
||||
transcodeWithContainerFormat,
|
||||
} from './ffmpegFormats';
|
||||
import { ffmpegNewContainer } from './ffmpeg';
|
||||
|
||||
/**
|
||||
* 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());
|
||||
const extension = await getExtensionFromBlob(blob);
|
||||
|
||||
if (await containerFmt?.needsTranscode(blob)) {
|
||||
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
|
||||
} else if (extension == 'flac') {
|
||||
blob = await rebuildFlacWithoutMetadata(blob);
|
||||
} else {
|
||||
blob = await ffmpegNewContainer(
|
||||
blob,
|
||||
extension == 'm4a' ? 'mp4' : extension,
|
||||
blob.type,
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Lossless container conversion failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
621
js/downloads.js
621
js/downloads.js
|
|
@ -14,35 +14,20 @@ 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';
|
||||
import { ZipStreamWriter, ZipBlobWriter, ZipNeutralinoWriter, FolderPickerWriter } from './bulk-download-writer.ts';
|
||||
|
||||
const downloadTasks = new Map();
|
||||
const bulkDownloadTasks = new Map();
|
||||
const ongoingDownloads = new Set();
|
||||
let downloadNotificationContainer = null;
|
||||
|
||||
async function loadClientZip() {
|
||||
try {
|
||||
const module = await import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm');
|
||||
return module;
|
||||
} catch (error) {
|
||||
console.error('Failed to load client-zip:', error);
|
||||
throw new Error('Failed to load ZIP library');
|
||||
}
|
||||
}
|
||||
|
||||
async function createDiscLayoutContext(tracks, api) {
|
||||
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||
|
|
@ -464,88 +449,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 +461,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;
|
||||
|
|
@ -605,27 +499,24 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
|
|||
}
|
||||
}
|
||||
|
||||
async function bulkDownloadToZipStream(
|
||||
async function bulkDownloadToZip(
|
||||
tracks,
|
||||
folderName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
fileHandle,
|
||||
writer,
|
||||
coverBlob = null,
|
||||
type = 'playlist',
|
||||
metadata = null
|
||||
) {
|
||||
const { abortController } = bulkDownloadTasks.get(notification);
|
||||
const signal = abortController.signal;
|
||||
const { downloadZip } = await loadClientZip();
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
async function* yieldFiles() {
|
||||
// Add cover if available
|
||||
if (coverBlob) {
|
||||
// Add cover if available and enabled
|
||||
if (coverBlob && playlistSettings.shouldIncludeCover()) {
|
||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||
}
|
||||
|
||||
|
|
@ -658,7 +549,6 @@ async function bulkDownloadToZipStream(
|
|||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||
|
||||
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||
trackPaths.push(discPath);
|
||||
|
||||
yield {
|
||||
|
|
@ -710,9 +600,8 @@ async function bulkDownloadToZipStream(
|
|||
};
|
||||
}
|
||||
|
||||
// For albums, generate CUE file
|
||||
// For albums, generate CUE file (one per disc if multi-disc)
|
||||
if (type === 'album' && playlistSettings.shouldGenerateCUE()) {
|
||||
// Split tracks by volumeNumber and iterate those groups.
|
||||
const tracksByVolume = Object.groupBy(
|
||||
tracks.map((track, index) => ({
|
||||
...track,
|
||||
|
|
@ -722,9 +611,14 @@ async function bulkDownloadToZipStream(
|
|||
);
|
||||
const multiDisc = Object.keys(tracksByVolume).length > 1;
|
||||
|
||||
for (const [volumeNumber, tracks] of Object.entries(tracksByVolume)) {
|
||||
const trackPaths = tracks.map((track) => track.trackPath);
|
||||
const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths);
|
||||
for (const [volumeNumber, volumeTracks] of Object.entries(tracksByVolume)) {
|
||||
const volumeTrackPaths = volumeTracks.map((track) => track.trackPath);
|
||||
const cueContent = generateCUE(
|
||||
metadata,
|
||||
volumeTracks,
|
||||
sanitizeForFilename(folderName),
|
||||
volumeTrackPaths
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}${multiDisc ? ` - Disc ${volumeNumber}` : ''}.cue`,
|
||||
lastModified: new Date(),
|
||||
|
|
@ -767,370 +661,35 @@ async function bulkDownloadToZipStream(
|
|||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = downloadZip(yieldFiles());
|
||||
await response.body.pipeTo(writable);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
throw error;
|
||||
}
|
||||
await writer.write(yieldFiles());
|
||||
}
|
||||
|
||||
// Generate ZIP as blob for browsers without File System Access API (iOS, etc.)
|
||||
async function bulkDownloadToZipBlob(
|
||||
tracks,
|
||||
folderName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
coverBlob = null,
|
||||
type = 'playlist',
|
||||
metadata = null
|
||||
) {
|
||||
const { abortController } = bulkDownloadTasks.get(notification);
|
||||
const signal = abortController.signal;
|
||||
const { downloadZip } = await loadClientZip();
|
||||
/**
|
||||
* Returns the appropriate bulk download writer for the current settings and environment,
|
||||
* or null when individual sequential downloads should be used.
|
||||
*/
|
||||
async function createBulkWriter(folderName) {
|
||||
const isNeutralino = window.NL_MODE === true;
|
||||
const method = bulkDownloadSettings.getMethod();
|
||||
const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob();
|
||||
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||
|
||||
async function* yieldFiles() {
|
||||
// Add cover if available
|
||||
if (coverBlob) {
|
||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||
}
|
||||
|
||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||
const separateByDisc = discLayout.separateByDisc;
|
||||
|
||||
// Download tracks, yielding each immediately and collecting actual paths for playlist generation
|
||||
const trackPaths = [];
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (signal.aborted) break;
|
||||
const track = tracks[i];
|
||||
const trackTitle = getTrackTitle(track);
|
||||
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(
|
||||
track,
|
||||
quality,
|
||||
api,
|
||||
null,
|
||||
signal,
|
||||
(p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
},
|
||||
coverBlob
|
||||
);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||
|
||||
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||
trackPaths.push(discPath);
|
||||
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: blob,
|
||||
};
|
||||
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
||||
if (lyricsData) {
|
||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: lrcContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw err;
|
||||
console.error(`Failed to download track ${trackTitle}:`, err);
|
||||
trackPaths.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateNFO()) {
|
||||
const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`,
|
||||
lastModified: new Date(),
|
||||
input: nfoContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateJSON()) {
|
||||
const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.json`,
|
||||
lastModified: new Date(),
|
||||
input: jsonContent,
|
||||
};
|
||||
}
|
||||
|
||||
// For albums, generate CUE file
|
||||
if (type === 'album' && playlistSettings.shouldGenerateCUE()) {
|
||||
const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.cue`,
|
||||
lastModified: new Date(),
|
||||
input: cueContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate m3u/m3u8 last, using actual track paths collected during download
|
||||
if (playlistSettings.shouldGenerateM3U()) {
|
||||
const m3uContent = generateM3U(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
null,
|
||||
'flac',
|
||||
trackPaths
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
|
||||
lastModified: new Date(),
|
||||
input: m3uContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U8()) {
|
||||
const m3u8Content = generateM3U8(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
null,
|
||||
'flac',
|
||||
trackPaths
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
|
||||
lastModified: new Date(),
|
||||
input: m3u8Content,
|
||||
};
|
||||
}
|
||||
if (isNeutralino) {
|
||||
return new ZipNeutralinoWriter(folderName);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = downloadZip(yieldFiles());
|
||||
const blob = await response.blob();
|
||||
triggerDownload(blob, `${folderName}.zip`);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
throw error;
|
||||
if (method === 'folder' && hasFolderPicker) {
|
||||
// FolderPickerWriter.create() throws AbortError if the user cancels
|
||||
return await FolderPickerWriter.create();
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDownloadToZipNeutralino(
|
||||
tracks,
|
||||
folderName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
coverBlob = null,
|
||||
type = 'playlist',
|
||||
metadata = null
|
||||
) {
|
||||
const { abortController } = bulkDownloadTasks.get(notification);
|
||||
const signal = abortController.signal;
|
||||
const { downloadZip } = await loadClientZip();
|
||||
|
||||
// Re-use logic for generating file entries
|
||||
async function* yieldFiles() {
|
||||
// Add cover if available
|
||||
if (coverBlob) {
|
||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||
}
|
||||
|
||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||
const separateByDisc = discLayout.separateByDisc;
|
||||
|
||||
// Download tracks, yielding each immediately and collecting actual paths for playlist generation
|
||||
const trackPaths = [];
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (signal.aborted) break;
|
||||
const track = tracks[i];
|
||||
const trackTitle = getTrackTitle(track);
|
||||
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(
|
||||
track,
|
||||
quality,
|
||||
api,
|
||||
null,
|
||||
signal,
|
||||
(p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
},
|
||||
coverBlob
|
||||
);
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||
|
||||
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||
trackPaths.push(discPath);
|
||||
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: blob,
|
||||
};
|
||||
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
||||
if (lyricsData) {
|
||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
yield {
|
||||
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
||||
lastModified: new Date(),
|
||||
input: lrcContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw err;
|
||||
console.error(`Failed to download track ${trackTitle}:`, err);
|
||||
trackPaths.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateNFO()) {
|
||||
const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`,
|
||||
lastModified: new Date(),
|
||||
input: nfoContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateJSON()) {
|
||||
const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.json`,
|
||||
lastModified: new Date(),
|
||||
input: jsonContent,
|
||||
};
|
||||
}
|
||||
|
||||
// For albums, generate CUE file
|
||||
if (type === 'album' && playlistSettings.shouldGenerateCUE()) {
|
||||
const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.cue`,
|
||||
lastModified: new Date(),
|
||||
input: cueContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate m3u/m3u8 last, using actual track paths collected during download
|
||||
if (playlistSettings.shouldGenerateM3U()) {
|
||||
const m3uContent = generateM3U(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
null,
|
||||
'flac',
|
||||
trackPaths
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
|
||||
lastModified: new Date(),
|
||||
input: m3uContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (playlistSettings.shouldGenerateM3U8()) {
|
||||
const m3u8Content = generateM3U8(
|
||||
metadata || { title: folderName },
|
||||
tracks,
|
||||
useRelativePaths,
|
||||
null,
|
||||
'flac',
|
||||
trackPaths
|
||||
);
|
||||
yield {
|
||||
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
|
||||
lastModified: new Date(),
|
||||
input: m3u8Content,
|
||||
};
|
||||
}
|
||||
if (method === 'individual') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the bridge explicitly to ensure we go through the parent shell
|
||||
const bridge = await import('./desktop/neutralino-bridge.js');
|
||||
|
||||
// Native Save Dialog via Bridge
|
||||
const savePath = await bridge.os.showSaveDialog(`Select save location for ${folderName}.zip`, {
|
||||
defaultPath: `${folderName}.zip`,
|
||||
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }],
|
||||
});
|
||||
|
||||
if (!savePath) {
|
||||
// Cancelled
|
||||
removeBulkDownloadTask(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = downloadZip(yieldFiles());
|
||||
|
||||
// Initialize file (empty) to ensure it exists
|
||||
// We use writeBinaryFile with an empty buffer to create/overwrite
|
||||
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
|
||||
|
||||
// Stream the response body
|
||||
if (!response.body) throw new Error('ZIP response body is null');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// 'value' is a Uint8Array. Neutralino filesystem expects ArrayBuffer.
|
||||
// value.buffer might contain the whole backing store, so we should be careful to slice if offset is non-zero
|
||||
// but usually read() returns fresh chunks.
|
||||
// However, neutralino bridge's appendBinaryFile takes ArrayBuffer.
|
||||
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
||||
|
||||
await bridge.filesystem.appendBinaryFile(savePath, chunk);
|
||||
receivedLength += value.length;
|
||||
|
||||
// Optional: Update granular progress if we want, but we typically update per-track in yieldFiles
|
||||
}
|
||||
|
||||
console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`);
|
||||
|
||||
completeBulkDownload(notification, true);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
throw error;
|
||||
// method === 'zip' (or folder picker unavailable as fallback)
|
||||
if (!forceZipBlob && hasFileSystemAccess) {
|
||||
return new ZipStreamWriter(`${folderName}.zip`);
|
||||
}
|
||||
return new ZipBlobWriter(`${folderName}.zip`);
|
||||
}
|
||||
|
||||
async function startBulkDownload(
|
||||
|
|
@ -1147,73 +706,32 @@ async function startBulkDownload(
|
|||
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
||||
|
||||
try {
|
||||
const isNeutralino = window.NL_MODE === true;
|
||||
const hasFileSystemAccess =
|
||||
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||
const forceIndividual = bulkDownloadSettings.shouldForceIndividual();
|
||||
const useZip = hasFileSystemAccess && !forceIndividual;
|
||||
const useZipBlob = !hasFileSystemAccess && !forceIndividual;
|
||||
const writer = await createBulkWriter(defaultName);
|
||||
|
||||
if (isNeutralino) {
|
||||
// Neutralino Native Logic
|
||||
await bulkDownloadToZipNeutralino(
|
||||
if (writer) {
|
||||
await bulkDownloadToZip(
|
||||
tracks,
|
||||
defaultName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
writer,
|
||||
coverBlob,
|
||||
type,
|
||||
metadata
|
||||
);
|
||||
} else if (useZip) {
|
||||
// File System Access API available - use streaming
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: `${defaultName}.zip`,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
await bulkDownloadToZipStream(
|
||||
tracks,
|
||||
defaultName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
fileHandle,
|
||||
coverBlob,
|
||||
type,
|
||||
metadata
|
||||
);
|
||||
completeBulkDownload(notification, true);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
removeBulkDownloadTask(notification);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else if (useZipBlob) {
|
||||
// No File System Access API (iOS, etc.) - use blob-based ZIP
|
||||
await bulkDownloadToZipBlob(
|
||||
tracks,
|
||||
defaultName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
notification,
|
||||
coverBlob,
|
||||
type,
|
||||
metadata
|
||||
);
|
||||
completeBulkDownload(notification, true);
|
||||
} else {
|
||||
// Fallback or Forced: Individual sequential downloads
|
||||
// Individual sequential downloads
|
||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
||||
completeBulkDownload(notification, true);
|
||||
}
|
||||
|
||||
completeBulkDownload(notification, true);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
removeBulkDownloadTask(notification);
|
||||
return;
|
||||
}
|
||||
console.error('Bulk download failed:', error);
|
||||
completeBulkDownload(notification, false, error.message);
|
||||
}
|
||||
|
|
@ -1280,10 +798,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
const { abortController } = bulkDownloadTasks.get(notification);
|
||||
const signal = abortController.signal;
|
||||
|
||||
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||
const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
||||
const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
||||
|
||||
async function* yieldDiscography() {
|
||||
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
||||
if (signal.aborted) break;
|
||||
|
|
@ -1310,7 +824,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
);
|
||||
|
||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||
if (coverBlob)
|
||||
if (coverBlob && playlistSettings.shouldIncludeCover())
|
||||
yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||
|
||||
// Generate playlist files for each album
|
||||
|
|
@ -1337,7 +851,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||
|
||||
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||
trackPaths.push(discPath);
|
||||
|
||||
yield {
|
||||
|
|
@ -1429,27 +942,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
}
|
||||
|
||||
try {
|
||||
if (useZip) {
|
||||
// File System Access API available - use streaming
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: `${rootFolder}.zip`,
|
||||
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
||||
});
|
||||
const writable = await fileHandle.createWritable();
|
||||
const { downloadZip } = await loadClientZip();
|
||||
const writer = await createBulkWriter(rootFolder);
|
||||
|
||||
const response = downloadZip(yieldDiscography());
|
||||
await response.body.pipeTo(writable);
|
||||
completeBulkDownload(notification, true);
|
||||
} else if (useZipBlob) {
|
||||
// No File System Access API (iOS, etc.) - use blob-based ZIP
|
||||
const { downloadZip } = await loadClientZip();
|
||||
const response = downloadZip(yieldDiscography());
|
||||
const blob = await response.blob();
|
||||
triggerDownload(blob, `${rootFolder}.zip`);
|
||||
completeBulkDownload(notification, true);
|
||||
if (writer) {
|
||||
await writer.write(yieldDiscography());
|
||||
} else {
|
||||
// Sequential individual downloads for discography
|
||||
// Individual sequential downloads for discography
|
||||
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
||||
if (signal.aborted) break;
|
||||
const album = selectedReleases[albumIndex];
|
||||
|
|
@ -1458,8 +956,9 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
||||
}
|
||||
completeBulkDownload(notification, true);
|
||||
}
|
||||
|
||||
completeBulkDownload(notification, true);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
removeBulkDownloadTask(notification);
|
||||
|
|
|
|||
31
js/ffmpeg.js
31
js/ffmpeg.js
|
|
@ -1,8 +1,7 @@
|
|||
import { fetchBlobURL } from './utils';
|
||||
import FfmpegWorker from './ffmpeg.worker.js?worker';
|
||||
const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm';
|
||||
const coreJs = `${ffmpegBase}/ffmpeg-core.js`;
|
||||
const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`;
|
||||
import coreJs from '!/@ffmpeg/core/dist/esm/ffmpeg-core.js?url';
|
||||
import coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?url';
|
||||
|
||||
class FfmpegError extends Error {
|
||||
constructor(message) {
|
||||
|
|
@ -28,7 +27,7 @@ export function loadFfmpeg() {
|
|||
|
||||
async function ffmpegWorker(
|
||||
audioBlob,
|
||||
args = {},
|
||||
args = [],
|
||||
outputName = 'output',
|
||||
outputMime = 'application/octet-stream',
|
||||
onProgress = null,
|
||||
|
|
@ -94,7 +93,7 @@ async function ffmpegWorker(
|
|||
{
|
||||
audioData,
|
||||
extraFiles,
|
||||
...args,
|
||||
args,
|
||||
output: {
|
||||
name: outputName,
|
||||
mime: outputMime,
|
||||
|
|
@ -109,7 +108,7 @@ async function ffmpegWorker(
|
|||
|
||||
export async function ffmpeg(
|
||||
audioBlob,
|
||||
args = {},
|
||||
args = [],
|
||||
outputName = 'output',
|
||||
outputMime = 'application/octet-stream',
|
||||
onProgress = null,
|
||||
|
|
@ -129,4 +128,24 @@ export async function ffmpeg(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new FFmpeg container with copied codec and stripped metadata.
|
||||
* @param {Blob} audioBlob - The audio blob to process
|
||||
* @param {string} outputExtension - The extension for the output file
|
||||
* @param {string} outputMime - The MIME type for the output blob
|
||||
* @param {Function} onProgress - Callback function to track conversion progress
|
||||
* @param {AbortSignal} signal - AbortSignal for cancelling the operation
|
||||
* @returns {Promise<Blob>} A promise that resolves to the processed data blob
|
||||
*/
|
||||
export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) {
|
||||
return await ffmpeg(
|
||||
audioBlob,
|
||||
['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
|
||||
`output.${outputExtension}`,
|
||||
outputMime,
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
export { FfmpegError };
|
||||
|
|
|
|||
|
|
@ -141,15 +141,21 @@ self.onmessage = async (e) => {
|
|||
} finally {
|
||||
try {
|
||||
if (audioData) await ffmpeg.deleteFile('input');
|
||||
} catch {}
|
||||
} catch {
|
||||
self.postMessage({ type: 'log', message: 'Failed to delete input file from FFmpeg FS.' });
|
||||
}
|
||||
for (const file of extraFiles) {
|
||||
try {
|
||||
await ffmpeg.deleteFile(file.name);
|
||||
} catch {}
|
||||
} catch {
|
||||
self.postMessage({ type: 'log', message: `Failed to delete ${file.name} from FFmpeg FS.` });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ffmpeg.deleteFile(output.name);
|
||||
} catch {}
|
||||
} catch {
|
||||
self.postMessage({ type: 'log', message: `Failed to delete ${output.name} from FFmpeg FS.` });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({ type: 'error', message: error.message });
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ export interface ProgressEvent {
|
|||
export interface CustomFormat {
|
||||
/** Human-readable label shown in the UI */
|
||||
displayName: string;
|
||||
/** Internal identifier, must start with `FFMPEG_` */
|
||||
internalName: string;
|
||||
/** Arguments passed to ffmpeg (excluding input/output file args) */
|
||||
ffmpegArgs: string[];
|
||||
/** Output filename used when calling ffmpeg */
|
||||
|
|
@ -40,37 +38,33 @@ export interface ContainerFormat extends Omit<CustomFormat, 'category'> {
|
|||
needsTranscode: (blob: Blob) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const customFormats: CustomFormat[] = [
|
||||
{
|
||||
export const customFormats: Record<string, CustomFormat> = {
|
||||
FFMPEG_MP3_320: {
|
||||
displayName: 'MP3 320kbps',
|
||||
internalName: 'FFMPEG_MP3_320',
|
||||
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'],
|
||||
outputFilename: 'output.mp3',
|
||||
outputMime: 'audio/mpeg',
|
||||
extension: 'mp3',
|
||||
category: 'MP3',
|
||||
},
|
||||
{
|
||||
FFMPEG_MP3_256: {
|
||||
displayName: 'MP3 256kbps',
|
||||
internalName: 'FFMPEG_MP3_256',
|
||||
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'],
|
||||
outputFilename: 'output.mp3',
|
||||
outputMime: 'audio/mpeg',
|
||||
extension: 'mp3',
|
||||
category: 'MP3',
|
||||
},
|
||||
{
|
||||
FFMPEG_MP3_128: {
|
||||
displayName: 'MP3 128kbps',
|
||||
internalName: 'FFMPEG_MP3_128',
|
||||
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'],
|
||||
outputFilename: 'output.mp3',
|
||||
outputMime: 'audio/mpeg',
|
||||
extension: 'mp3',
|
||||
category: 'MP3',
|
||||
},
|
||||
{
|
||||
FFMPEG_OGG_320: {
|
||||
displayName: 'OGG 320kbps',
|
||||
internalName: 'FFMPEG_OGG_320',
|
||||
ffmpegArgs: [
|
||||
'-map_metadata',
|
||||
'-1',
|
||||
|
|
@ -88,9 +82,8 @@ export const customFormats: CustomFormat[] = [
|
|||
extension: 'ogg',
|
||||
category: 'OGG',
|
||||
},
|
||||
{
|
||||
FFMPEG_OGG_256: {
|
||||
displayName: 'OGG 256kbps',
|
||||
internalName: 'FFMPEG_OGG_256',
|
||||
ffmpegArgs: [
|
||||
'-map_metadata',
|
||||
'-1',
|
||||
|
|
@ -108,9 +101,8 @@ export const customFormats: CustomFormat[] = [
|
|||
extension: 'ogg',
|
||||
category: 'OGG',
|
||||
},
|
||||
{
|
||||
FFMPEG_OGG_128: {
|
||||
displayName: 'OGG 128kbps',
|
||||
internalName: 'FFMPEG_OGG_128',
|
||||
ffmpegArgs: [
|
||||
'-map_metadata',
|
||||
'-1',
|
||||
|
|
@ -128,16 +120,15 @@ export const customFormats: CustomFormat[] = [
|
|||
extension: 'ogg',
|
||||
category: 'OGG',
|
||||
},
|
||||
{
|
||||
FFMPEG_AAC_256: {
|
||||
displayName: 'AAC 256kbps',
|
||||
internalName: 'FFMPEG_AAC_256',
|
||||
ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'],
|
||||
outputFilename: 'output.m4a',
|
||||
outputMime: 'audio/mp4',
|
||||
extension: 'm4a',
|
||||
category: 'AAC',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Container format definitions for lossless re-muxing. Each entry describes
|
||||
|
|
@ -145,47 +136,25 @@ export const customFormats: CustomFormat[] = [
|
|||
* `needsTranscode` predicate so callers can skip the ffmpeg step when the
|
||||
* source is already in the correct container.
|
||||
*/
|
||||
export const containerFormats: ContainerFormat[] = [
|
||||
{
|
||||
export const containerFormats: Record<string, ContainerFormat> = {
|
||||
flac: {
|
||||
displayName: 'FLAC',
|
||||
internalName: 'flac',
|
||||
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
|
||||
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'],
|
||||
outputFilename: 'output.flac',
|
||||
outputMime: 'audio/flac',
|
||||
extension: 'flac',
|
||||
// Only transcode when the source is NOT already a FLAC file.
|
||||
needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac',
|
||||
},
|
||||
{
|
||||
displayName: 'FLAC - Max Compression',
|
||||
internalName: 'flac_max',
|
||||
// `-compression_level 12` is the highest FLAC compression level; audio
|
||||
// data is bit-identical to the source — only the compressed size changes.
|
||||
ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
|
||||
outputFilename: 'output.flac',
|
||||
outputMime: 'audio/flac',
|
||||
extension: 'flac',
|
||||
needsTranscode: async () => true,
|
||||
},
|
||||
{
|
||||
alac: {
|
||||
displayName: 'Apple Lossless',
|
||||
internalName: 'alac',
|
||||
ffmpegArgs: ['-c:a', 'alac'],
|
||||
outputFilename: 'output.m4a',
|
||||
outputMime: 'audio/mp4',
|
||||
extension: 'm4a',
|
||||
needsTranscode: async () => true,
|
||||
},
|
||||
{
|
||||
displayName: "Don't change",
|
||||
internalName: 'nochange',
|
||||
ffmpegArgs: [],
|
||||
outputFilename: '',
|
||||
outputMime: '',
|
||||
extension: '',
|
||||
needsTranscode: async () => false,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */
|
||||
export function isCustomFormat(quality: string): boolean {
|
||||
|
|
@ -194,12 +163,12 @@ export function isCustomFormat(quality: string): boolean {
|
|||
|
||||
/** Looks up a custom format by its internal name, or returns undefined */
|
||||
export function getCustomFormat(internalName: string): CustomFormat | undefined {
|
||||
return customFormats.find((f) => f.internalName === internalName);
|
||||
return customFormats[internalName];
|
||||
}
|
||||
|
||||
/** Looks up a container format by its internal name, or returns undefined */
|
||||
export function getContainerFormat(internalName: string): ContainerFormat | undefined {
|
||||
return containerFormats.find((f) => f.internalName === internalName);
|
||||
return containerFormats[internalName];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,7 +184,7 @@ export async function transcodeWithCustomFormat(
|
|||
): Promise<Blob> {
|
||||
return ffmpeg(
|
||||
audioBlob,
|
||||
{ args: format.ffmpegArgs },
|
||||
format.ffmpegArgs,
|
||||
format.outputFilename,
|
||||
format.outputMime,
|
||||
onProgress,
|
||||
|
|
@ -237,7 +206,7 @@ export async function transcodeWithContainerFormat(
|
|||
): Promise<Blob> {
|
||||
return ffmpeg(
|
||||
audioBlob,
|
||||
{ args: format.ffmpegArgs },
|
||||
format.ffmpegArgs,
|
||||
format.outputFilename,
|
||||
format.outputMime,
|
||||
onProgress,
|
||||
|
|
|
|||
5
js/global.d.ts
vendored
5
js/global.d.ts
vendored
|
|
@ -2,3 +2,8 @@ declare module '*?url' {
|
|||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module 'https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm' {
|
||||
/** Creates a ZIP stream from an async iterable of file entries. */
|
||||
export function downloadZip(files: AsyncIterable<object>): Response;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,14 +47,6 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
|||
*/
|
||||
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 {
|
||||
|
|
@ -64,7 +56,8 @@ 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.numberOfTracks;
|
||||
data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks;
|
||||
data.totalDiscs = track.album.totalDiscs;
|
||||
data.copyright = track.copyright;
|
||||
data.isrc = track.isrc;
|
||||
data.explicit = Boolean(track.explicit);
|
||||
|
|
|
|||
|
|
@ -815,8 +815,8 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
}));
|
||||
|
||||
// Append custom (ffmpeg-transcoded) format options
|
||||
for (const fmt of customFormats) {
|
||||
allOptions.push({ value: fmt.internalName, text: fmt.displayName, category: fmt.category });
|
||||
for (const [key, fmt] of Object.entries(customFormats)) {
|
||||
allOptions.push({ value: key, text: fmt.displayName, category: fmt.category });
|
||||
}
|
||||
|
||||
// Sort by category order first, then by bitrate descending within each category
|
||||
|
|
@ -860,18 +860,34 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
|
||||
downloadQualitySetting.addEventListener('change', (e) => {
|
||||
downloadQualitySettings.setQuality(e.target.value);
|
||||
updateLosslessContainerVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
||||
const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item');
|
||||
|
||||
/** Shows/hides the Lossless Container setting based on the selected quality */
|
||||
function updateLosslessContainerVisibility() {
|
||||
if (!losslessContainerSettingItem) return;
|
||||
const quality = downloadQualitySettings.getQuality();
|
||||
const isLossless = quality === 'LOSSLESS' || quality === 'HI_RES_LOSSLESS';
|
||||
losslessContainerSettingItem.style.display = isLossless ? '' : 'none';
|
||||
}
|
||||
|
||||
if (losslessContainerSetting) {
|
||||
for (const { internalName, displayName } of containerFormats) {
|
||||
const noChangeOption = losslessContainerSetting.querySelector('option:last-child');
|
||||
noChangeOption.remove();
|
||||
|
||||
for (const [internalName, { displayName }] of Object.entries(containerFormats)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = internalName;
|
||||
option.textContent = displayName;
|
||||
losslessContainerSetting.appendChild(option);
|
||||
}
|
||||
|
||||
losslessContainerSetting.append(noChangeOption);
|
||||
|
||||
losslessContainerSetting.value = losslessContainerSettings.getContainer();
|
||||
|
||||
losslessContainerSetting.addEventListener('change', (e) => {
|
||||
|
|
@ -879,6 +895,8 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
updateLosslessContainerVisibility();
|
||||
|
||||
// Cover Art Size setting
|
||||
const coverArtSizeSetting = document.getElementById('cover-art-size-setting');
|
||||
if (coverArtSizeSetting) {
|
||||
|
|
@ -909,11 +927,56 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
const zippedBulkDownloadsToggle = document.getElementById('zipped-bulk-downloads-toggle');
|
||||
if (zippedBulkDownloadsToggle) {
|
||||
zippedBulkDownloadsToggle.checked = !bulkDownloadSettings.shouldForceIndividual();
|
||||
zippedBulkDownloadsToggle.addEventListener('change', (e) => {
|
||||
bulkDownloadSettings.setForceIndividual(!e.target.checked);
|
||||
const forceZipBlobToggle = document.getElementById('force-zip-blob-toggle');
|
||||
const forceZipBlobSettingItem = forceZipBlobToggle?.closest('.setting-item');
|
||||
const hasFileSystemAccess =
|
||||
'showSaveFilePicker' in window &&
|
||||
typeof FileSystemFileHandle !== 'undefined' &&
|
||||
'createWritable' in FileSystemFileHandle.prototype;
|
||||
|
||||
/** Shows/hides the Force ZIP as Blob setting based on method and browser support */
|
||||
function updateForceZipBlobVisibility() {
|
||||
if (!forceZipBlobSettingItem) return;
|
||||
const method = bulkDownloadSettings.getMethod();
|
||||
// Only relevant when zip method is selected and the browser supports streaming
|
||||
const visible = method === 'zip' && hasFileSystemAccess;
|
||||
forceZipBlobSettingItem.style.display = visible ? '' : 'none';
|
||||
}
|
||||
|
||||
const bulkDownloadMethod = document.getElementById('bulk-download-method');
|
||||
if (bulkDownloadMethod) {
|
||||
// Remove the folder picker option if the browser doesn't support it
|
||||
if (!('showDirectoryPicker' in window)) {
|
||||
const folderOption = bulkDownloadMethod.querySelector('option[value="folder"]');
|
||||
if (folderOption) {
|
||||
folderOption.remove();
|
||||
}
|
||||
// If the stored method is 'folder', fall back to 'zip'
|
||||
if (bulkDownloadSettings.getMethod() === 'folder') {
|
||||
bulkDownloadSettings.setMethod('zip');
|
||||
}
|
||||
}
|
||||
bulkDownloadMethod.value = bulkDownloadSettings.getMethod();
|
||||
bulkDownloadMethod.addEventListener('change', (e) => {
|
||||
bulkDownloadSettings.setMethod(e.target.value);
|
||||
updateForceZipBlobVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
if (forceZipBlobToggle) {
|
||||
forceZipBlobToggle.checked = bulkDownloadSettings.shouldForceZipBlob();
|
||||
forceZipBlobToggle.addEventListener('change', (e) => {
|
||||
bulkDownloadSettings.setForceZipBlob(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
updateForceZipBlobVisibility();
|
||||
|
||||
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
||||
if (includeCoverToggle) {
|
||||
includeCoverToggle.checked = playlistSettings.shouldIncludeCover();
|
||||
includeCoverToggle.addEventListener('change', (e) => {
|
||||
playlistSettings.setIncludeCover(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -559,7 +559,8 @@ export const losslessContainerSettings = {
|
|||
STORAGE_KEY: 'lossless-container',
|
||||
getContainer() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) || 'flac';
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY) || 'flac';
|
||||
return stored;
|
||||
} catch {
|
||||
return 'flac';
|
||||
}
|
||||
|
|
@ -634,18 +635,42 @@ export const trackDateSettings = {
|
|||
};
|
||||
|
||||
export const bulkDownloadSettings = {
|
||||
STORAGE_KEY: 'force-individual-downloads',
|
||||
METHOD_KEY: 'bulk-download-method',
|
||||
FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob',
|
||||
|
||||
shouldForceIndividual() {
|
||||
/** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */
|
||||
getMethod() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) === 'true';
|
||||
return localStorage.getItem(this.METHOD_KEY) || 'zip';
|
||||
} catch {
|
||||
return 'zip';
|
||||
}
|
||||
},
|
||||
|
||||
setMethod(method) {
|
||||
localStorage.setItem(this.METHOD_KEY, method);
|
||||
},
|
||||
|
||||
/** When using ZIP mode, force in-memory blob download instead of streaming to disk */
|
||||
shouldForceZipBlob() {
|
||||
try {
|
||||
return localStorage.getItem(this.FORCE_ZIP_BLOB_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setForceZipBlob(enabled) {
|
||||
localStorage.setItem(this.FORCE_ZIP_BLOB_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
// Kept for backward compatibility
|
||||
shouldForceIndividual() {
|
||||
return this.getMethod() === 'individual';
|
||||
},
|
||||
|
||||
setForceIndividual(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
this.setMethod(enabled ? 'individual' : 'zip');
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -657,6 +682,7 @@ export const playlistSettings = {
|
|||
JSON_KEY: 'playlist-generate-json',
|
||||
RELATIVE_PATHS_KEY: 'playlist-relative-paths',
|
||||
SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip',
|
||||
INCLUDE_COVER_KEY: 'playlist-include-cover',
|
||||
|
||||
shouldGenerateM3U() {
|
||||
try {
|
||||
|
|
@ -744,6 +770,19 @@ export const playlistSettings = {
|
|||
setSeparateDiscsInZip(enabled) {
|
||||
localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
|
||||
shouldIncludeCover() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.INCLUDE_COVER_KEY);
|
||||
return val === null ? true : val === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
setIncludeCover(enabled) {
|
||||
localStorage.setItem(this.INCLUDE_COVER_KEY, enabled ? 'true' : 'false');
|
||||
},
|
||||
};
|
||||
|
||||
export const visualizerSettings = {
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' }
|
|||
|
||||
export const formatTemplate = (template, data) => {
|
||||
let result = template;
|
||||
result = result.replace(/\{discNumber\}/g, String(Number(data.discNumber || 1)));
|
||||
result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00');
|
||||
result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist'));
|
||||
result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title'));
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"appwrite": "^23.0.0",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"client-zip": "^2.5.0",
|
||||
"cookie-session": "^2.1.1",
|
||||
"dashjs": "^5.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue