feat: extract bulk download handlers into bulk-download-writer.ts and add folder picker + settings
This commit is contained in:
parent
c9a1f49f23
commit
c1552980eb
8 changed files with 376 additions and 480 deletions
33
index.html
33
index.html
|
|
@ -5074,14 +5074,27 @@
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Zipped Bulk Downloads</span>
|
<span class="label">Bulk Download Method</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Download multiple tracks as a single ZIP file (requires browser
|
>Choose how multiple tracks are downloaded together</span
|
||||||
support)</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>
|
</div>
|
||||||
<label class="toggle-switch">
|
<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>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5245,7 +5258,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Separate Discs in ZIP</span>
|
<span class="label">Separate Discs</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Put tracks in Disc folders when a release has multiple discs</span
|
>Put tracks in Disc folders when a release has multiple discs</span
|
||||||
>
|
>
|
||||||
|
|
@ -5255,6 +5268,16 @@
|
||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm');
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
js/downloads.js
510
js/downloads.js
|
|
@ -21,22 +21,13 @@ import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } fro
|
||||||
import { loadFfmpeg } from './ffmpeg.js';
|
import { loadFfmpeg } from './ffmpeg.js';
|
||||||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||||
|
import { ZipStreamWriter, ZipBlobWriter, ZipNeutralinoWriter, FolderPickerWriter } from './bulk-download-writer.ts';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
const bulkDownloadTasks = new Map();
|
const bulkDownloadTasks = new Map();
|
||||||
const ongoingDownloads = new Set();
|
const ongoingDownloads = new Set();
|
||||||
let downloadNotificationContainer = null;
|
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) {
|
async function createDiscLayoutContext(tracks, api) {
|
||||||
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
||||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||||
|
|
@ -508,27 +499,24 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bulkDownloadToZipStream(
|
async function bulkDownloadToZip(
|
||||||
tracks,
|
tracks,
|
||||||
folderName,
|
folderName,
|
||||||
api,
|
api,
|
||||||
quality,
|
quality,
|
||||||
lyricsManager,
|
lyricsManager,
|
||||||
notification,
|
notification,
|
||||||
fileHandle,
|
writer,
|
||||||
coverBlob = null,
|
coverBlob = null,
|
||||||
type = 'playlist',
|
type = 'playlist',
|
||||||
metadata = null
|
metadata = null
|
||||||
) {
|
) {
|
||||||
const { abortController } = bulkDownloadTasks.get(notification);
|
const { abortController } = bulkDownloadTasks.get(notification);
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
const { downloadZip } = await loadClientZip();
|
|
||||||
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
|
|
||||||
async function* yieldFiles() {
|
async function* yieldFiles() {
|
||||||
// Add cover if available
|
// Add cover if available and enabled
|
||||||
if (coverBlob) {
|
if (coverBlob && playlistSettings.shouldIncludeCover()) {
|
||||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,9 +601,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()) {
|
if (type === 'album' && playlistSettings.shouldGenerateCUE()) {
|
||||||
// Split tracks by volumeNumber and iterate those groups.
|
|
||||||
const tracksByVolume = Object.groupBy(
|
const tracksByVolume = Object.groupBy(
|
||||||
tracks.map((track, index) => ({
|
tracks.map((track, index) => ({
|
||||||
...track,
|
...track,
|
||||||
|
|
@ -625,9 +612,14 @@ async function bulkDownloadToZipStream(
|
||||||
);
|
);
|
||||||
const multiDisc = Object.keys(tracksByVolume).length > 1;
|
const multiDisc = Object.keys(tracksByVolume).length > 1;
|
||||||
|
|
||||||
for (const [volumeNumber, tracks] of Object.entries(tracksByVolume)) {
|
for (const [volumeNumber, volumeTracks] of Object.entries(tracksByVolume)) {
|
||||||
const trackPaths = tracks.map((track) => track.trackPath);
|
const volumeTrackPaths = volumeTracks.map((track) => track.trackPath);
|
||||||
const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths);
|
const cueContent = generateCUE(
|
||||||
|
metadata,
|
||||||
|
volumeTracks,
|
||||||
|
sanitizeForFilename(folderName),
|
||||||
|
volumeTrackPaths
|
||||||
|
);
|
||||||
yield {
|
yield {
|
||||||
name: `${folderName}/${sanitizeForFilename(folderName)}${multiDisc ? ` - Disc ${volumeNumber}` : ''}.cue`,
|
name: `${folderName}/${sanitizeForFilename(folderName)}${multiDisc ? ` - Disc ${volumeNumber}` : ''}.cue`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|
@ -670,370 +662,35 @@ async function bulkDownloadToZipStream(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await writer.write(yieldFiles());
|
||||||
const response = downloadZip(yieldFiles());
|
|
||||||
await response.body.pipeTo(writable);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') return;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ZIP as blob for browsers without File System Access API (iOS, etc.)
|
/**
|
||||||
async function bulkDownloadToZipBlob(
|
* Returns the appropriate bulk download writer for the current settings and environment,
|
||||||
tracks,
|
* or null when individual sequential downloads should be used.
|
||||||
folderName,
|
*/
|
||||||
api,
|
async function createBulkWriter(folderName) {
|
||||||
quality,
|
const isNeutralino = window.NL_MODE === true;
|
||||||
lyricsManager,
|
const method = bulkDownloadSettings.getMethod();
|
||||||
notification,
|
const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob();
|
||||||
coverBlob = null,
|
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||||
type = 'playlist',
|
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||||
metadata = null
|
|
||||||
) {
|
|
||||||
const { abortController } = bulkDownloadTasks.get(notification);
|
|
||||||
const signal = abortController.signal;
|
|
||||||
const { downloadZip } = await loadClientZip();
|
|
||||||
|
|
||||||
async function* yieldFiles() {
|
if (isNeutralino) {
|
||||||
// Add cover if available
|
return new ZipNeutralinoWriter(folderName);
|
||||||
if (coverBlob) {
|
|
||||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
|
||||||
}
|
}
|
||||||
|
if (method === 'folder' && hasFolderPicker) {
|
||||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
// FolderPickerWriter.create() throws AbortError if the user cancels
|
||||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
return await FolderPickerWriter.create();
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
if (method === 'individual') {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch {
|
// method === 'zip' (or folder picker unavailable as fallback)
|
||||||
/* ignore */
|
if (!forceZipBlob && hasFileSystemAccess) {
|
||||||
}
|
return new ZipStreamWriter(`${folderName}.zip`);
|
||||||
}
|
|
||||||
} 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = downloadZip(yieldFiles());
|
|
||||||
const blob = await response.blob();
|
|
||||||
triggerDownload(blob, `${folderName}.zip`);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') return;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return new ZipBlobWriter(`${folderName}.zip`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startBulkDownload(
|
async function startBulkDownload(
|
||||||
|
|
@ -1050,73 +707,32 @@ async function startBulkDownload(
|
||||||
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isNeutralino = window.NL_MODE === true;
|
const writer = await createBulkWriter(defaultName);
|
||||||
const hasFileSystemAccess =
|
|
||||||
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
|
||||||
const forceIndividual = bulkDownloadSettings.shouldForceIndividual();
|
|
||||||
const useZip = hasFileSystemAccess && !forceIndividual;
|
|
||||||
const useZipBlob = !hasFileSystemAccess && !forceIndividual;
|
|
||||||
|
|
||||||
if (isNeutralino) {
|
if (writer) {
|
||||||
// Neutralino Native Logic
|
await bulkDownloadToZip(
|
||||||
await bulkDownloadToZipNeutralino(
|
|
||||||
tracks,
|
tracks,
|
||||||
defaultName,
|
defaultName,
|
||||||
api,
|
api,
|
||||||
quality,
|
quality,
|
||||||
lyricsManager,
|
lyricsManager,
|
||||||
notification,
|
notification,
|
||||||
|
writer,
|
||||||
coverBlob,
|
coverBlob,
|
||||||
type,
|
type,
|
||||||
metadata
|
metadata
|
||||||
);
|
);
|
||||||
} else if (useZip) {
|
} else {
|
||||||
// File System Access API available - use streaming
|
// Individual sequential downloads
|
||||||
try {
|
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
||||||
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);
|
completeBulkDownload(notification, true);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
if (err.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
removeBulkDownloadTask(notification);
|
removeBulkDownloadTask(notification);
|
||||||
return;
|
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
|
|
||||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
|
||||||
completeBulkDownload(notification, true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Bulk download failed:', error);
|
console.error('Bulk download failed:', error);
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
}
|
}
|
||||||
|
|
@ -1183,10 +799,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
const { abortController } = bulkDownloadTasks.get(notification);
|
const { abortController } = bulkDownloadTasks.get(notification);
|
||||||
const signal = abortController.signal;
|
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() {
|
async function* yieldDiscography() {
|
||||||
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
@ -1213,7 +825,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
);
|
);
|
||||||
|
|
||||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||||
if (coverBlob)
|
if (coverBlob && playlistSettings.shouldIncludeCover())
|
||||||
yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||||
|
|
||||||
// Generate playlist files for each album
|
// Generate playlist files for each album
|
||||||
|
|
@ -1332,27 +944,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (useZip) {
|
const writer = await createBulkWriter(rootFolder);
|
||||||
// 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 response = downloadZip(yieldDiscography());
|
if (writer) {
|
||||||
await response.body.pipeTo(writable);
|
await writer.write(yieldDiscography());
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
// Sequential individual downloads for discography
|
// Individual sequential downloads for discography
|
||||||
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
const album = selectedReleases[albumIndex];
|
const album = selectedReleases[albumIndex];
|
||||||
|
|
@ -1361,8 +958,9 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
||||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
||||||
}
|
}
|
||||||
completeBulkDownload(notification, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completeBulkDownload(notification, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
removeBulkDownloadTask(notification);
|
removeBulkDownloadTask(notification);
|
||||||
|
|
|
||||||
|
|
@ -176,15 +176,6 @@ export const containerFormats: ContainerFormat[] = [
|
||||||
extension: 'm4a',
|
extension: 'm4a',
|
||||||
needsTranscode: async () => true,
|
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 */
|
/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */
|
||||||
|
|
|
||||||
5
js/global.d.ts
vendored
5
js/global.d.ts
vendored
|
|
@ -2,3 +2,8 @@ declare module '*?url' {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -861,10 +861,21 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
|
|
||||||
downloadQualitySetting.addEventListener('change', (e) => {
|
downloadQualitySetting.addEventListener('change', (e) => {
|
||||||
downloadQualitySettings.setQuality(e.target.value);
|
downloadQualitySettings.setQuality(e.target.value);
|
||||||
|
updateLosslessContainerVisibility();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const losslessContainerSetting = document.getElementById('lossless-container-setting');
|
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) {
|
if (losslessContainerSetting) {
|
||||||
for (const { internalName, displayName } of containerFormats) {
|
for (const { internalName, displayName } of containerFormats) {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
|
|
@ -880,6 +891,8 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLosslessContainerVisibility();
|
||||||
|
|
||||||
// Cover Art Size setting
|
// Cover Art Size setting
|
||||||
const coverArtSizeSetting = document.getElementById('cover-art-size-setting');
|
const coverArtSizeSetting = document.getElementById('cover-art-size-setting');
|
||||||
if (coverArtSizeSetting) {
|
if (coverArtSizeSetting) {
|
||||||
|
|
@ -910,11 +923,56 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const zippedBulkDownloadsToggle = document.getElementById('zipped-bulk-downloads-toggle');
|
const forceZipBlobToggle = document.getElementById('force-zip-blob-toggle');
|
||||||
if (zippedBulkDownloadsToggle) {
|
const forceZipBlobSettingItem = forceZipBlobToggle?.closest('.setting-item');
|
||||||
zippedBulkDownloadsToggle.checked = !bulkDownloadSettings.shouldForceIndividual();
|
const hasFileSystemAccess =
|
||||||
zippedBulkDownloadsToggle.addEventListener('change', (e) => {
|
'showSaveFilePicker' in window &&
|
||||||
bulkDownloadSettings.setForceIndividual(!e.target.checked);
|
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,9 @@ export const losslessContainerSettings = {
|
||||||
STORAGE_KEY: 'lossless-container',
|
STORAGE_KEY: 'lossless-container',
|
||||||
getContainer() {
|
getContainer() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.STORAGE_KEY) || 'flac';
|
const stored = localStorage.getItem(this.STORAGE_KEY) || 'flac';
|
||||||
|
// 'nochange' was removed as an option; fall back to FLAC
|
||||||
|
return stored === 'nochange' ? 'flac' : stored;
|
||||||
} catch {
|
} catch {
|
||||||
return 'flac';
|
return 'flac';
|
||||||
}
|
}
|
||||||
|
|
@ -650,18 +652,42 @@ export const trackDateSettings = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bulkDownloadSettings = {
|
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 {
|
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 {
|
} catch {
|
||||||
return false;
|
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) {
|
setForceIndividual(enabled) {
|
||||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
this.setMethod(enabled ? 'individual' : 'zip');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -673,6 +699,7 @@ export const playlistSettings = {
|
||||||
JSON_KEY: 'playlist-generate-json',
|
JSON_KEY: 'playlist-generate-json',
|
||||||
RELATIVE_PATHS_KEY: 'playlist-relative-paths',
|
RELATIVE_PATHS_KEY: 'playlist-relative-paths',
|
||||||
SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip',
|
SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip',
|
||||||
|
INCLUDE_COVER_KEY: 'playlist-include-cover',
|
||||||
|
|
||||||
shouldGenerateM3U() {
|
shouldGenerateM3U() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -760,6 +787,19 @@ export const playlistSettings = {
|
||||||
setSeparateDiscsInZip(enabled) {
|
setSeparateDiscsInZip(enabled) {
|
||||||
localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false');
|
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 = {
|
export const visualizerSettings = {
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,7 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' }
|
||||||
|
|
||||||
export const formatTemplate = (template, data) => {
|
export const formatTemplate = (template, data) => {
|
||||||
let result = template;
|
let result = template;
|
||||||
|
result = result.replace(/\{discNumber\}/g, data.discNumber ? String(data.discNumber).padStart(2, '0') : '01');
|
||||||
result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00');
|
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(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist'));
|
||||||
result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title'));
|
result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue