feat(downloads): add local media folder bulk download options and folder template paths
This also implements a ModernSettings class for a more streamlined settings API.
This commit is contained in:
parent
f9a58b1cac
commit
397fc53a46
13 changed files with 947 additions and 117 deletions
39
index.html
39
index.html
|
|
@ -4059,9 +4059,42 @@
|
|||
<select id="bulk-download-method">
|
||||
<option value="zip">ZIP Archive</option>
|
||||
<option value="folder">Folder Picker</option>
|
||||
<option value="local">Local Media Folder</option>
|
||||
<option value="individual">Individual Files</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item" id="remember-folder-setting">
|
||||
<div class="info">
|
||||
<span class="label">Remember Last Folder</span>
|
||||
<span class="description"
|
||||
>Re-use the last chosen directory for Folder Picker downloads</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="remember-folder-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="reset-saved-folder-setting">
|
||||
<div class="info">
|
||||
<span class="label">Reset Saved Folder</span>
|
||||
<span class="description">Clear the remembered Folder Picker directory</span>
|
||||
</div>
|
||||
<button id="reset-saved-folder-btn" class="btn-secondary">Reset</button>
|
||||
</div>
|
||||
<div class="setting-item" id="single-to-folder-setting">
|
||||
<div class="info">
|
||||
<span class="label">Single Downloads to Folder</span>
|
||||
<span class="description"
|
||||
>Save individual track downloads directly to the configured folder instead
|
||||
of triggering a browser download</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="single-to-folder-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Force ZIP as Blob</span>
|
||||
|
|
@ -4160,10 +4193,10 @@
|
|||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ZIP Folder Template</span>
|
||||
<span class="label">Folder Template</span>
|
||||
<span class="description"
|
||||
>Customize album folder names. Available: {albumTitle}, {albumArtist},
|
||||
{year}</span
|
||||
>Customize album folder names. Use <code>/</code> for nested folders.
|
||||
Available: {albumTitle}, {albumArtist}, {year}</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
|
|
|
|||
289
js/ModernSettings.ts
Normal file
289
js/ModernSettings.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { db } from './db';
|
||||
|
||||
/**
|
||||
* A dynamically typed settings container that lazily loads and persists values.
|
||||
*
|
||||
* Properties are registered using {@link addProperty}. Each property becomes a real
|
||||
* getter/setter on the instance and is automatically persisted through the backing
|
||||
* `db` implementation.
|
||||
*
|
||||
* All asynchronous reads/writes are tracked internally. Use {@link waitPending}
|
||||
* to await completion of any pending operations.
|
||||
*
|
||||
* @template C The accumulated shape of the settings object.
|
||||
*/
|
||||
class ModernSettings<C extends object = {}> {
|
||||
/** Internal map of pending async operations keyed by unique symbols. */
|
||||
#pending: Record<symbol, Promise<any>> = {};
|
||||
|
||||
/** Whether new properties are prevented from being added. */
|
||||
#finalized: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Waits until all pending asynchronous operations complete.
|
||||
*
|
||||
* This includes:
|
||||
* - Initial property loading
|
||||
* - Any pending writes triggered by property setters
|
||||
*
|
||||
* This method loops until the pending operation list is empty, ensuring
|
||||
* that operations scheduled during awaiting are also handled.
|
||||
*/
|
||||
public async waitPending() {
|
||||
while (true) {
|
||||
const promises = Object.getOwnPropertySymbols(this.#pending).map((s) => this.#pending[s]);
|
||||
|
||||
if (promises.length) {
|
||||
await Promise.all(promises);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a promise as a pending operation.
|
||||
*
|
||||
* The promise is automatically removed from the pending list once settled.
|
||||
*
|
||||
* @param callback Function producing the promise to track.
|
||||
* @returns The created promise.
|
||||
*/
|
||||
#addPending<C extends Promise<any>>(callback: () => C): C {
|
||||
const sym = Symbol();
|
||||
|
||||
return (this.#pending[sym] = callback().finally(() => {
|
||||
delete this.#pending[sym];
|
||||
}) as C);
|
||||
}
|
||||
|
||||
#checkKey(key: string) {
|
||||
if (this.#finalized) {
|
||||
throw new Error("Can't add a key after finalization.");
|
||||
}
|
||||
|
||||
if (Object.keys(this).includes(key)) {
|
||||
throw new Error("Can't add a key that already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new dynamically typed property to the settings instance.
|
||||
*
|
||||
* The property will:
|
||||
* - Load its value asynchronously from the backing database.
|
||||
* - Fall back to `defaultValue` if no value exists.
|
||||
* - Persist any updates automatically when set.
|
||||
*
|
||||
* The method returns the same instance but **with the new property added to
|
||||
* the TypeScript type**, allowing fluent chaining with full type safety.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const settings = new ModernSettings()
|
||||
* .addProperty<boolean>("darkMode", false)
|
||||
* .addProperty<string>("username", "")
|
||||
* .finalize();
|
||||
*
|
||||
* await settings.waitPending();
|
||||
*
|
||||
* settings.darkMode = true;
|
||||
* console.log(settings.username);
|
||||
* ```
|
||||
*
|
||||
* @template T Property value type.
|
||||
* @template K Property key name.
|
||||
*
|
||||
* @param key The property name to define on the settings object.
|
||||
* @param defaultValue Value used if the setting is not present in storage.
|
||||
* @param options Optional configuration.
|
||||
*
|
||||
* @param options.backingKey
|
||||
* Optional storage key. Defaults to the property name.
|
||||
*
|
||||
* @param options.legacy
|
||||
* Optional migration configuration for moving a value from `localStorage`
|
||||
* into the database-backed settings store.
|
||||
*
|
||||
* @param options.legacy.key
|
||||
* Legacy key to read from `localStorage`. Defaults to the same key used for storage.
|
||||
*
|
||||
* @param options.legacy.transformer
|
||||
* Function used to convert the legacy string value into the correct type.
|
||||
*
|
||||
* @returns The same instance typed with the new property included.
|
||||
*
|
||||
* @throws If called after {@link finalize}.
|
||||
* @throws If a property with the same name already exists.
|
||||
*/
|
||||
public addProperty<T, K extends string>(
|
||||
key: K,
|
||||
defaultValue: T,
|
||||
options?: {
|
||||
backingKey?: string;
|
||||
getter?: (value: T, settings: C & Record<K, T>) => T;
|
||||
setter?: (value: T, settings: C & Record<K, T>) => T;
|
||||
legacy?: {
|
||||
key?: string;
|
||||
transformer: (value: string) => T;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const { backingKey, legacy, getter, setter } = options ?? {};
|
||||
|
||||
this.#checkKey(key);
|
||||
|
||||
const typed = this as unknown as ModernSettings<C & Record<K, T>>;
|
||||
|
||||
let value: T;
|
||||
|
||||
this.#addPending(async () => {
|
||||
if (legacy?.key != null || legacy?.transformer != null) {
|
||||
{
|
||||
const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key);
|
||||
|
||||
if (legacyValue !== null) {
|
||||
db.saveSetting(backingKey ?? key, legacy.transformer!(legacyValue));
|
||||
localStorage.removeItem(legacy?.key ?? backingKey ?? key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
value = (await db.getSetting(backingKey ?? key)) ?? defaultValue;
|
||||
} catch {
|
||||
value = defaultValue;
|
||||
}
|
||||
}).catch(console.trace);
|
||||
|
||||
Object.defineProperty(this, key, {
|
||||
get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value),
|
||||
set: (newValue: T) => {
|
||||
value = setter ? setter(newValue, typed as ModernSettings<C> & C & Record<K, T>) : newValue;
|
||||
this.#addPending(() => db.saveSetting(backingKey ?? key, value));
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
return typed;
|
||||
}
|
||||
|
||||
public addGetter<K extends string, R>(key: K, getter: (settings: ModernSettings<C>) => R) {
|
||||
this.#checkKey(key);
|
||||
const typed = this as unknown as ModernSettings<C & Readonly<Record<K, R>>> & C & Readonly<Record<K, R>>;
|
||||
|
||||
Object.defineProperty(this, key, {
|
||||
get: () => getter(typed),
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
return typed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents further properties from being added.
|
||||
*
|
||||
* This is typically called once all `addProperty` calls are complete,
|
||||
* ensuring the settings schema is fixed.
|
||||
*
|
||||
* @returns The settings instance.
|
||||
*/
|
||||
public finalize() {
|
||||
this.#finalized = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export enum BulkDownloadMethod {
|
||||
Zip = 'zip',
|
||||
Folder = 'folder',
|
||||
Individual = 'individual',
|
||||
LocalMedia = 'local',
|
||||
}
|
||||
|
||||
export const modernSettings = new ModernSettings()
|
||||
.addProperty('bulkDownloadFolder', null as FileSystemDirectoryHandle | null)
|
||||
.addProperty('forceZipBlob', false, {
|
||||
legacy: {
|
||||
key: 'bulk-download-force-zip-blob',
|
||||
transformer: Boolean,
|
||||
},
|
||||
})
|
||||
.addProperty('rememberBulkDownloadFolder', false, {
|
||||
legacy: {
|
||||
key: 'bulk-download-remember-folder',
|
||||
transformer: Boolean,
|
||||
},
|
||||
})
|
||||
.addProperty('downloadSinglesToFolder', false, {
|
||||
legacy: {
|
||||
key: 'bulk-download-single-to-folder',
|
||||
transformer: Boolean,
|
||||
},
|
||||
})
|
||||
.addProperty('force-individual-downloads', false, {
|
||||
legacy: {
|
||||
transformer: Boolean,
|
||||
},
|
||||
})
|
||||
.addProperty('bulkDownloadMethod', 'zip' as BulkDownloadMethod, {
|
||||
getter: (stored, settings) => {
|
||||
try {
|
||||
if (stored && Object.values(BulkDownloadMethod).includes(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const legacy = settings['force-individual-downloads'];
|
||||
if (legacy) {
|
||||
settings['force-individual-downloads'] = false;
|
||||
return (settings.bulkDownloadMethod = BulkDownloadMethod.Individual);
|
||||
}
|
||||
|
||||
return BulkDownloadMethod.Zip;
|
||||
} catch {
|
||||
return BulkDownloadMethod.Zip;
|
||||
}
|
||||
},
|
||||
})
|
||||
.addProperty('folderTemplate', '', {
|
||||
getter: (stored) => stored || '{albumTitle} - {albumArtist}',
|
||||
legacy: {
|
||||
key: 'zip-folder-template',
|
||||
transformer: String,
|
||||
},
|
||||
})
|
||||
.addProperty('filenameTemplate', '', {
|
||||
getter: (stored) => stored || '{trackNumber} - {artist} - {title}',
|
||||
legacy: {
|
||||
key: 'filename-template',
|
||||
transformer: String,
|
||||
},
|
||||
})
|
||||
.finalize() as ModernSettings & {
|
||||
/** The last used directory handle for bulk downloads */
|
||||
bulkDownloadFolder: FileSystemDirectoryHandle | null;
|
||||
|
||||
/** Force ZIP blobs for bulk downloads even if file system APIs are available */
|
||||
forceZipBlob: boolean;
|
||||
|
||||
/** Whether the Folder Picker should remember the last-used directory handle */
|
||||
rememberBulkDownloadFolder: boolean;
|
||||
|
||||
/**
|
||||
* Whether single-track downloads should be routed to the configured
|
||||
* folder (saved Folder Picker handle or Local Media Folder path)
|
||||
* instead of triggering a browser download.
|
||||
*/
|
||||
downloadSinglesToFolder: boolean;
|
||||
|
||||
/** The selected bulk download method */
|
||||
bulkDownloadMethod: BulkDownloadMethod;
|
||||
|
||||
/** Path template for bulk downloads */
|
||||
folderTemplate: string;
|
||||
|
||||
/** Filename template for downloads */
|
||||
filenameTemplate: string;
|
||||
};
|
||||
|
|
@ -1506,7 +1506,13 @@ export class LosslessAPI {
|
|||
}
|
||||
|
||||
if (!isVideo) {
|
||||
blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal);
|
||||
blob = await applyAudioPostProcessing(
|
||||
blob,
|
||||
quality,
|
||||
onProgress,
|
||||
options.signal,
|
||||
track?.audioQuality ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// Add metadata if track information is provided
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import {
|
|||
parseDynamicCSV,
|
||||
importToLibrary,
|
||||
} from './playlist-importer.js';
|
||||
import { modernSettings } from './ModernSettings.js';
|
||||
import {
|
||||
SVG_OFFLINE,
|
||||
SVG_RIGHT_ARROW,
|
||||
|
|
@ -382,6 +383,8 @@ async function uploadCoverImage(file) {
|
|||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await modernSettings.waitPending();
|
||||
|
||||
// Initialize analytics
|
||||
initAnalytics();
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ interface NeutralinoBridge {
|
|||
title: string,
|
||||
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
|
||||
): Promise<string | null>;
|
||||
showFolderDialog(title: string, options?: Record<string, unknown>): Promise<string | null>;
|
||||
};
|
||||
filesystem: {
|
||||
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
||||
appendBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
||||
createDirectory(path: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -156,13 +158,41 @@ export class ZipNeutralinoWriter implements IBulkDownloadWriter {
|
|||
export class FolderPickerWriter implements IBulkDownloadWriter {
|
||||
private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {}
|
||||
|
||||
/** Returns the underlying directory handle (e.g. to persist it for later re-use). */
|
||||
getDirHandle(): FileSystemDirectoryHandle {
|
||||
return this.dirHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to pick a writable directory.
|
||||
* Creates a {@link FolderPickerWriter} from an already-obtained handle
|
||||
* without showing a directory picker. Useful when re-using a stored handle
|
||||
* whose permission has already been verified by the caller.
|
||||
*/
|
||||
static fromHandle(handle: FileSystemDirectoryHandle): FolderPickerWriter {
|
||||
return new FolderPickerWriter(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to pick a writable directory, or re-uses a previously
|
||||
* saved handle when one is supplied and write permission can be obtained.
|
||||
* 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> {
|
||||
static async create(savedHandle?: FileSystemDirectoryHandle | null): Promise<FolderPickerWriter> {
|
||||
// Try to re-use a saved handle first
|
||||
if (savedHandle) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const permission = await (savedHandle as any).requestPermission({ mode: 'readwrite' });
|
||||
if (permission === 'granted') {
|
||||
return new FolderPickerWriter(savedHandle);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to show the picker
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
try {
|
||||
|
|
@ -214,3 +244,54 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes files directly into a folder on the local filesystem via the
|
||||
* Neutralino desktop bridge. Subdirectories are created automatically.
|
||||
*/
|
||||
export class NeutralinoFolderWriter implements IBulkDownloadWriter {
|
||||
constructor(private readonly basePath: string) {}
|
||||
|
||||
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||
// Import once per write() call; the module system caches the result.
|
||||
const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge;
|
||||
const createdDirs = new Set<string>();
|
||||
|
||||
for await (const file of files) {
|
||||
const parts = file.name.split('/').filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
// Ensure all parent directories exist
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const dirPath = this.basePath + '/' + parts.slice(0, i).join('/');
|
||||
if (!createdDirs.has(dirPath)) {
|
||||
try {
|
||||
await bridge.filesystem.createDirectory(dirPath);
|
||||
} catch {
|
||||
// Directory may already exist; ignore
|
||||
}
|
||||
createdDirs.add(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = this.basePath + '/' + file.name;
|
||||
let buffer: ArrayBuffer;
|
||||
const { input } = file;
|
||||
if (input instanceof Blob) {
|
||||
buffer = await input.arrayBuffer();
|
||||
} else if (typeof input === 'string') {
|
||||
const encoded = new TextEncoder().encode(input);
|
||||
buffer = encoded.buffer.slice(
|
||||
encoded.byteOffset,
|
||||
encoded.byteOffset + encoded.byteLength
|
||||
) as ArrayBuffer;
|
||||
} else if (input instanceof Uint8Array) {
|
||||
buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) as ArrayBuffer;
|
||||
} else {
|
||||
buffer = input;
|
||||
}
|
||||
|
||||
await bridge.filesystem.writeBinaryFile(filePath, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,21 @@ export const filesystem = {
|
|||
window.parent.postMessage({ type: 'NL_FS_APPEND_BINARY', id, path, buffer }, '*', [buffer]);
|
||||
});
|
||||
},
|
||||
createDirectory: async (path) => {
|
||||
if (!isNeutralino) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
const handler = (event) => {
|
||||
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
|
||||
window.removeEventListener('message', handler);
|
||||
if (event.data.error) reject(event.data.error);
|
||||
else resolve(event.data.result);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
window.parent.postMessage({ type: 'NL_FS_CREATE_DIR', id, path }, '*');
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const updater = {
|
||||
|
|
|
|||
|
|
@ -26,21 +26,68 @@ export function triggerDownload(blob: Blob, filename: string): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
* Apply post-processing to an audio Blob according to the requested quality.
|
||||
*
|
||||
* Returns the (possibly transformed) blob.
|
||||
* This function:
|
||||
* - Detects the source container/extension via getExtensionFromBlob.
|
||||
* - Determines whether the source is lossless:
|
||||
* - FLAC is always lossless.
|
||||
* - M4A is treated as lossless only when trackAudioQuality is "LOSSLESS" or "HI_RES_LOSSLESS".
|
||||
* - If a custom lossy format is requested (isCustomFormat(quality)):
|
||||
* - If the source is already lossy, returns the original Blob to avoid quality degradation.
|
||||
* - Otherwise, obtains the custom format via getCustomFormat and transcodes using
|
||||
* transcodeWithCustomFormat(...). Progress events are reported via onProgress.
|
||||
* - If encoding fails, onProgress is notified with an error stage and the original error is rethrown.
|
||||
* - If a lossless output is requested (quality ends with "LOSSLESS"):
|
||||
* - Retrieves the configured lossless container and its format handler.
|
||||
* - If the source is not lossless, logs a warning and returns the original Blob.
|
||||
* - Otherwise:
|
||||
* - If containerFmt.needsTranscode(blob) is true, transcodes via transcodeWithContainerFormat(...).
|
||||
* - Else if the source is FLAC, calls rebuildFlacWithoutMetadata to strip/rebuild metadata safely.
|
||||
* - Else remuxes into the desired container via ffmpegNewContainer (maps m4a -> mp4 where appropriate).
|
||||
* - Any non-abort errors during lossless container conversion are caught and logged (conversion is best-effort).
|
||||
*
|
||||
* Progress and cancellation:
|
||||
* - onProgress, if provided, will be called with progress/update/error events from the underlying encoding/transcode helpers.
|
||||
* - An AbortSignal may be provided to cancel long-running transcode operations; abort-related errors (AbortError)
|
||||
* are propagated.
|
||||
*
|
||||
* @param blob - The source audio Blob to process.
|
||||
* @param quality - Requested output quality identifier (may indicate custom lossy format or lossless output).
|
||||
* @param onProgress - Optional callback invoked with progress/update events (or error notifications).
|
||||
* @param signal - Optional AbortSignal used to cancel asynchronous transcode operations.
|
||||
* @param trackAudioQuality - Optional track audio quality information from the API (e.g. "LOSSLESS", "HI_RES_LOSSLESS")
|
||||
* used to determine whether an m4a source should be treated as lossless.
|
||||
* @returns A Promise that resolves to the resulting audio Blob (may be the original blob if no processing was needed
|
||||
* or if processing was skipped due to source/quality constraints).
|
||||
* @throws Throws underlying encoding/transcoding errors (including AbortError when aborted). Encoding errors during
|
||||
* custom-format transcode are rethrown after reporting via onProgress. Non-abort errors during lossless
|
||||
* container conversion are logged and do not necessarily propagate.
|
||||
*/
|
||||
export async function applyAudioPostProcessing(
|
||||
blob: Blob,
|
||||
quality: string,
|
||||
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
||||
signal: AbortSignal | null = null
|
||||
signal: AbortSignal | null = null,
|
||||
trackAudioQuality: string | null = null
|
||||
): Promise<Blob> {
|
||||
// Transcode to custom format if requested
|
||||
const extension = await getExtensionFromBlob(blob);
|
||||
|
||||
// Determine whether the downloaded source is lossless.
|
||||
// FLAC is always lossless. m4a is lossless only when the track's
|
||||
// audio quality from the API is LOSSLESS or HI_RES_LOSSLESS; otherwise
|
||||
// it is AAC (lossy).
|
||||
const sourceIsLossless =
|
||||
extension === 'flac' ||
|
||||
(extension === 'm4a' && (trackAudioQuality === 'LOSSLESS' || trackAudioQuality === 'HI_RES_LOSSLESS'));
|
||||
|
||||
// Transcode to custom lossy format if requested
|
||||
if (isCustomFormat(quality)) {
|
||||
// If the source is already lossy, transcoding would degrade quality
|
||||
// further (lossy → lossy). Return the blob as-is instead.
|
||||
if (!sourceIsLossless) {
|
||||
return blob;
|
||||
}
|
||||
const format = getCustomFormat(quality);
|
||||
if (format) {
|
||||
try {
|
||||
|
|
@ -59,8 +106,15 @@ export async function applyAudioPostProcessing(
|
|||
|
||||
if (quality.endsWith('LOSSLESS')) {
|
||||
try {
|
||||
const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
|
||||
const extension = await getExtensionFromBlob(blob);
|
||||
const containerName = losslessContainerSettings.getContainer();
|
||||
const containerFmt = getContainerFormat(containerName);
|
||||
|
||||
if (!sourceIsLossless) {
|
||||
console.warn(
|
||||
`Requested lossless output but source is not lossless (quality: ${quality}, trackAudioQuality: ${trackAudioQuality}, extension: ${extension}).`
|
||||
);
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (await containerFmt?.needsTranscode(blob)) {
|
||||
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
|
||||
|
|
|
|||
226
js/downloads.js
226
js/downloads.js
|
|
@ -5,6 +5,7 @@ import {
|
|||
RATE_LIMIT_ERROR_MESSAGE,
|
||||
getTrackArtists,
|
||||
getTrackTitle,
|
||||
formatPathTemplate,
|
||||
getCoverBlob,
|
||||
getExtensionFromBlob,
|
||||
formatTemplate,
|
||||
|
|
@ -12,17 +13,20 @@ import {
|
|||
getTrackDiscNumber,
|
||||
} from './utils.js';
|
||||
import { AbortError } from './errorTypes.ts';
|
||||
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
||||
import { lyricsSettings, playlistSettings } from './storage.js';
|
||||
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||
import {
|
||||
ZipStreamWriter,
|
||||
ZipBlobWriter,
|
||||
ZipNeutralinoWriter,
|
||||
FolderPickerWriter,
|
||||
NeutralinoFolderWriter,
|
||||
SequentialFileWriter,
|
||||
} from './bulk-download-writer.ts';
|
||||
import { FfmpegProgress } from './ffmpeg.types.js';
|
||||
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
|
||||
import { db } from './db.js';
|
||||
import { modernSettings } from './ModernSettings.js';
|
||||
import { SVG_CLOSE } from './icons.ts';
|
||||
|
||||
const downloadTasks = new Map();
|
||||
|
|
@ -30,6 +34,11 @@ const bulkDownloadTasks = new Map();
|
|||
const ongoingDownloads = new Set();
|
||||
let downloadNotificationContainer = null;
|
||||
|
||||
/** Wraps a single {@link WriterEntry}-like object as an AsyncIterable for use with IBulkDownloadWriter.write(). */
|
||||
async function* singleWriterEntry(entry) {
|
||||
yield entry;
|
||||
}
|
||||
|
||||
async function createDiscLayoutContext(tracks, api) {
|
||||
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||
|
|
@ -488,6 +497,71 @@ async function bulkDownload(
|
|||
await writer.write(yieldFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a writer that can be used to save a single-track download directly
|
||||
* to the configured folder (Local Media Folder or saved Folder Picker handle),
|
||||
* or `null` if the feature is not active / no folder is configured.
|
||||
*
|
||||
* In contrast to {@link createBulkWriter}, this never prompts the user – it
|
||||
* only succeeds when the folder is already known.
|
||||
*/
|
||||
async function createSingleTrackFolderWriter() {
|
||||
if (!modernSettings.downloadSinglesToFolder) return null;
|
||||
|
||||
const isNeutralino =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.NL_MODE || window.location.search.includes('mode=neutralino') || window.parent !== window);
|
||||
const method = modernSettings.bulkDownloadMethod;
|
||||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||
|
||||
if (method === 'local') {
|
||||
const localHandle = await db.getSetting('local_folder_handle');
|
||||
if (isNeutralino) {
|
||||
if (localHandle?.path) return new NeutralinoFolderWriter(localHandle.path);
|
||||
} else if (hasFolderPicker && localHandle && typeof localHandle.requestPermission === 'function') {
|
||||
try {
|
||||
const permission = await localHandle.requestPermission({ mode: 'readwrite' });
|
||||
if (permission === 'granted') return FolderPickerWriter.fromHandle(localHandle);
|
||||
} catch {
|
||||
// no permission
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method === 'folder' && hasFolderPicker) {
|
||||
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
|
||||
const savedHandle = rememberFolder ? modernSettings.bulkDownloadFolder : null;
|
||||
// Try to reuse the saved handle silently first.
|
||||
if (savedHandle && typeof savedHandle.requestPermission === 'function') {
|
||||
try {
|
||||
const permission = await savedHandle.requestPermission({ mode: 'readwrite' });
|
||||
if (permission === 'granted') return FolderPickerWriter.fromHandle(savedHandle);
|
||||
} catch {
|
||||
// fall through to picker
|
||||
}
|
||||
}
|
||||
// No usable saved handle – open the picker so the user can choose a folder.
|
||||
try {
|
||||
const writer = await FolderPickerWriter.create();
|
||||
if (rememberFolder) {
|
||||
modernSettings.bulkDownloadFolder = writer.getDirHandle();
|
||||
await modernSettings.waitPending();
|
||||
}
|
||||
return writer;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
// User cancelled the picker – return null so we fall back to the
|
||||
// normal browser download instead of erroring out.
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate bulk download writer for the current settings and environment,
|
||||
* or null when individual sequential downloads should be used.
|
||||
|
|
@ -496,17 +570,75 @@ async function createBulkWriter(folderName) {
|
|||
const isNeutralino =
|
||||
typeof window !== 'undefined' &&
|
||||
(window.NL_MODE || window.location.search.includes('mode=neutralino') || window.parent !== window);
|
||||
const method = bulkDownloadSettings.getMethod();
|
||||
const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob();
|
||||
const method = modernSettings.bulkDownloadMethod;
|
||||
const forceZipBlob = modernSettings.forceZipBlob;
|
||||
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||
|
||||
// ── Local Media Folder method ────────────────────────────────────────────
|
||||
if (method === 'local') {
|
||||
const localHandle = await db.getSetting('local_folder_handle');
|
||||
if (isNeutralino) {
|
||||
if (localHandle?.path) {
|
||||
return new NeutralinoFolderWriter(localHandle.path);
|
||||
}
|
||||
// No folder configured – prompt now
|
||||
const bridge = await import('./desktop/neutralino-bridge.js');
|
||||
const pickedPath = await bridge.os.showFolderDialog('Select Download Folder');
|
||||
if (!pickedPath) return null; // user cancelled – fall back to default
|
||||
// Persist as the local media folder so future downloads reuse it
|
||||
const handle = {
|
||||
name: pickedPath.split(/[/\\]/).pop() || pickedPath,
|
||||
isNeutralino: true,
|
||||
path: pickedPath,
|
||||
};
|
||||
await db.saveSetting('local_folder_handle', handle);
|
||||
return new NeutralinoFolderWriter(pickedPath);
|
||||
} else if (hasFolderPicker) {
|
||||
// Browser mode: try to reuse the stored handle with write permission
|
||||
if (localHandle && typeof localHandle.requestPermission === 'function') {
|
||||
try {
|
||||
const permission = await localHandle.requestPermission({ mode: 'readwrite' });
|
||||
if (permission === 'granted') {
|
||||
return FolderPickerWriter.fromHandle(localHandle);
|
||||
}
|
||||
} catch {
|
||||
// fall through to picker
|
||||
}
|
||||
}
|
||||
// No usable handle – prompt and persist
|
||||
try {
|
||||
const writer = await FolderPickerWriter.create();
|
||||
await db.saveSetting('local_folder_handle', writer.getDirHandle());
|
||||
return writer;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Browser without File System Access API – fall through to ZIP
|
||||
}
|
||||
|
||||
// ── Neutralino default (ZIP) ─────────────────────────────────────────────
|
||||
if (isNeutralino) {
|
||||
return new ZipNeutralinoWriter(folderName);
|
||||
}
|
||||
|
||||
// ── Folder Picker method ─────────────────────────────────────────────────
|
||||
if (method === 'folder' && hasFolderPicker) {
|
||||
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
|
||||
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
|
||||
try {
|
||||
return await FolderPickerWriter.create();
|
||||
const writer = await FolderPickerWriter.create(savedHandle);
|
||||
if (rememberFolder) {
|
||||
await db.saveSetting('bulk_download_folder_handle', writer.getDirHandle());
|
||||
} else {
|
||||
await db.saveSetting('bulk_download_folder_handle', null);
|
||||
}
|
||||
|
||||
return writer;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw error;
|
||||
|
|
@ -514,6 +646,7 @@ async function createBulkWriter(folderName) {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'individual') {
|
||||
return new SequentialFileWriter();
|
||||
}
|
||||
|
|
@ -556,6 +689,11 @@ async function startBulkDownload(
|
|||
}
|
||||
|
||||
completeBulkDownload(notification, true);
|
||||
|
||||
// If the download went to the local media folder, refresh the local library.
|
||||
if (modernSettings.bulkDownloadMethod === 'local') {
|
||||
window.refreshLocalMediaFolder?.();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
removeBulkDownloadTask(notification);
|
||||
|
|
@ -579,7 +717,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
|||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||
|
||||
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
|
||||
albumTitle: album.title,
|
||||
albumArtist: album.artist?.name,
|
||||
year: year,
|
||||
|
|
@ -600,7 +738,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
|||
}
|
||||
|
||||
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
||||
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||
const folderName = formatPathTemplate(modernSettings.folderTemplate, {
|
||||
albumTitle: playlist.title,
|
||||
albumArtist: 'Playlist',
|
||||
year: new Date().getFullYear(),
|
||||
|
|
@ -643,14 +781,11 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||
|
||||
const albumFolder = formatTemplate(
|
||||
localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}',
|
||||
{
|
||||
albumTitle: fullAlbum.title,
|
||||
albumArtist: fullAlbum.artist?.name,
|
||||
year: year,
|
||||
}
|
||||
);
|
||||
const albumFolder = formatPathTemplate(modernSettings.folderTemplate, {
|
||||
albumTitle: fullAlbum.title,
|
||||
albumArtist: fullAlbum.artist?.name,
|
||||
year: year,
|
||||
});
|
||||
|
||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||
if (coverBlob && playlistSettings.shouldIncludeCover())
|
||||
|
|
@ -942,16 +1077,63 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
ongoingDownloads.add(downloadKey);
|
||||
|
||||
try {
|
||||
// Resolve the folder writer before registering the download task so that
|
||||
// any permission prompt (requestPermission) shows before the UI task appears.
|
||||
const folderWriter = await createSingleTrackFolderWriter();
|
||||
|
||||
addDownloadTask(track.id, enrichedTrack, filename, api, controller);
|
||||
|
||||
await api.downloadTrack(track.id, quality, filename, {
|
||||
signal: controller.signal,
|
||||
track: enrichedTrack,
|
||||
onProgress: (progress) => {
|
||||
updateDownloadProgress(track.id, progress);
|
||||
},
|
||||
calculateDashBytes: true,
|
||||
});
|
||||
// Try to write directly to the configured folder when the feature is enabled.
|
||||
if (folderWriter) {
|
||||
// Download the blob (metadata already applied inside downloadTrack)
|
||||
const blob = await api.downloadTrack(track.id, quality, filename, {
|
||||
signal: controller.signal,
|
||||
track: enrichedTrack,
|
||||
onProgress: (progress) => {
|
||||
updateDownloadProgress(track.id, progress);
|
||||
},
|
||||
calculateDashBytes: true,
|
||||
triggerDownload: false,
|
||||
});
|
||||
|
||||
const currentExtension = filename.split('.').pop()?.toLowerCase();
|
||||
const finalFilename = buildTrackFilename(track, quality, await getExtensionFromBlob(blob))
|
||||
.split('/')
|
||||
.pop();
|
||||
|
||||
// Compute a subfolder path using the same template as bulk downloads so
|
||||
// the track lands in e.g. "Album Title - Artist/" instead of the folder root.
|
||||
const releaseDateStr =
|
||||
enrichedTrack.album?.releaseDate ||
|
||||
(enrichedTrack.streamStartDate ? enrichedTrack.streamStartDate.split('T')[0] : '');
|
||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||
const releaseYear = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||
const subFolder = formatPathTemplate(modernSettings.folderTemplate, {
|
||||
albumTitle: enrichedTrack.album?.title,
|
||||
albumArtist: enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name,
|
||||
year: releaseYear,
|
||||
});
|
||||
const entryName = subFolder ? `${subFolder}/${finalFilename}` : finalFilename;
|
||||
|
||||
// Write to folder using IBulkDownloadWriter.write() via singleWriterEntry().
|
||||
await folderWriter.write(singleWriterEntry({ name: entryName, lastModified: new Date(), input: blob }));
|
||||
|
||||
// If the target is the local media folder, do a cheap partial update:
|
||||
// pass the downloaded blob and base filename so only this one track's metadata
|
||||
// is read and inserted into localFilesCache instead of re-walking the whole folder.
|
||||
if (modernSettings.bulkDownloadMethod === 'local') {
|
||||
window.refreshLocalMediaFolder?.(blob, finalFilename);
|
||||
}
|
||||
} else {
|
||||
await api.downloadTrack(track.id, quality, filename, {
|
||||
signal: controller.signal,
|
||||
track: enrichedTrack,
|
||||
onProgress: (progress) => {
|
||||
updateDownloadProgress(track.id, progress);
|
||||
},
|
||||
calculateDashBytes: true,
|
||||
});
|
||||
}
|
||||
|
||||
completeDownloadTask(track.id, true);
|
||||
|
||||
|
|
|
|||
143
js/settings.js
143
js/settings.js
|
|
@ -16,7 +16,6 @@ import {
|
|||
qualityBadgeSettings,
|
||||
trackDateSettings,
|
||||
visualizerSettings,
|
||||
bulkDownloadSettings,
|
||||
playlistSettings,
|
||||
equalizerSettings,
|
||||
listenBrainzSettings,
|
||||
|
|
@ -41,6 +40,7 @@ import { db } from './db.js';
|
|||
import { authManager } from './accounts/auth.js';
|
||||
import { syncManager } from './accounts/pocketbase.js';
|
||||
import { containerFormats, customFormats } from './ffmpegFormats.ts';
|
||||
import { modernSettings } from './ModernSettings.js';
|
||||
|
||||
async function getButterchurnPresets(...args) {
|
||||
const butterchurnModule = await import('./visualizers/butterchurn.js');
|
||||
|
|
@ -949,44 +949,157 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
'showSaveFilePicker' in window &&
|
||||
typeof FileSystemFileHandle !== 'undefined' &&
|
||||
'createWritable' in FileSystemFileHandle.prototype;
|
||||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
||||
|
||||
const rememberFolderSetting = document.getElementById('remember-folder-setting');
|
||||
const rememberFolderToggle = document.getElementById('remember-folder-toggle');
|
||||
const resetSavedFolderSetting = document.getElementById('reset-saved-folder-setting');
|
||||
const resetSavedFolderBtn = document.getElementById('reset-saved-folder-btn');
|
||||
const singleToFolderSetting = document.getElementById('single-to-folder-setting');
|
||||
const singleToFolderToggle = document.getElementById('single-to-folder-toggle');
|
||||
|
||||
/** Shows/hides the Force ZIP as Blob setting based on method and browser support */
|
||||
function updateForceZipBlobVisibility() {
|
||||
if (!forceZipBlobSettingItem) return;
|
||||
const method = bulkDownloadSettings.getMethod();
|
||||
const method = modernSettings.bulkDownloadMethod;
|
||||
// Only relevant when zip method is selected and the browser supports streaming
|
||||
const visible = method === 'zip' && hasFileSystemAccess;
|
||||
forceZipBlobSettingItem.style.display = visible ? '' : 'none';
|
||||
}
|
||||
|
||||
/** Shows/hides folder-picker-specific and folder-method settings */
|
||||
async function updateFolderMethodVisibility() {
|
||||
const method = modernSettings.bulkDownloadMethod;
|
||||
const isFolderMethod = method === 'folder';
|
||||
const isFolderOrLocal = isFolderMethod || method === 'local';
|
||||
|
||||
if (rememberFolderSetting) {
|
||||
rememberFolderSetting.style.display = isFolderMethod && hasFolderPicker ? '' : 'none';
|
||||
}
|
||||
|
||||
// Reset button: only visible when folder method + remember enabled + valid saved handle exists
|
||||
if (resetSavedFolderSetting) {
|
||||
let showReset = false;
|
||||
if (isFolderMethod && hasFolderPicker && modernSettings.rememberBulkDownloadFolder) {
|
||||
const savedHandle = await db.getSetting('bulk_download_folder_handle');
|
||||
showReset = !!savedHandle;
|
||||
}
|
||||
resetSavedFolderSetting.style.display = showReset ? '' : 'none';
|
||||
}
|
||||
|
||||
if (singleToFolderSetting) {
|
||||
singleToFolderSetting.style.display = isFolderOrLocal ? '' : '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)) {
|
||||
if (!hasFolderPicker) {
|
||||
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');
|
||||
const localOption = bulkDownloadMethod.querySelector('option[value="local"]');
|
||||
if (localOption) {
|
||||
localOption.remove();
|
||||
}
|
||||
// If the stored method is 'folder' or 'local' without native support, fall back to 'zip'
|
||||
const currentMethod = modernSettings.bulkDownloadMethod;
|
||||
if (currentMethod === 'folder' || currentMethod === 'local') {
|
||||
modernSettings.bulkDownloadMethod = 'zip';
|
||||
}
|
||||
}
|
||||
bulkDownloadMethod.value = bulkDownloadSettings.getMethod();
|
||||
bulkDownloadMethod.addEventListener('change', (e) => {
|
||||
bulkDownloadSettings.setMethod(e.target.value);
|
||||
bulkDownloadMethod.value = modernSettings.bulkDownloadMethod;
|
||||
bulkDownloadMethod.addEventListener('change', async (e) => {
|
||||
const previousMethod = modernSettings.bulkDownloadMethod;
|
||||
const newMethod = e.target.value;
|
||||
modernSettings.bulkDownloadMethod = newMethod;
|
||||
|
||||
// When switching to 'local', prompt to select the local media folder if not yet configured
|
||||
if (newMethod === 'local') {
|
||||
const existingHandle = await db.getSetting('local_folder_handle');
|
||||
if (!existingHandle) {
|
||||
let picked = false;
|
||||
try {
|
||||
const isNeutralino =
|
||||
window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino'));
|
||||
if (isNeutralino) {
|
||||
const path = await window.Neutralino.os.showFolderDialog('Select Local Media Folder');
|
||||
if (path) {
|
||||
picked = true;
|
||||
const handle = {
|
||||
name: path.split(/[/\\]/).pop() || path,
|
||||
isNeutralino: true,
|
||||
path,
|
||||
};
|
||||
await db.saveSetting('local_folder_handle', handle);
|
||||
}
|
||||
} else if (hasFolderPicker) {
|
||||
const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||
if (handle) {
|
||||
picked = true;
|
||||
await db.saveSetting('local_folder_handle', handle);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// User cancelled the picker
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
// Revert to the previous method since no folder was selected.
|
||||
// Guard against the edge case where the previousMethod option
|
||||
// no longer exists in the dropdown (e.g. removed due to no API support).
|
||||
if (bulkDownloadMethod.querySelector(`option[value="${previousMethod}"]`)) {
|
||||
modernSettings.bulkDownloadMethod = previousMethod;
|
||||
bulkDownloadMethod.value = previousMethod;
|
||||
} else {
|
||||
// Fall back to zip which is always present
|
||||
modernSettings.bulkDownloadMethod = 'zip';
|
||||
bulkDownloadMethod.value = 'zip';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await modernSettings.waitPending();
|
||||
|
||||
updateForceZipBlobVisibility();
|
||||
await updateFolderMethodVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
if (rememberFolderToggle) {
|
||||
rememberFolderToggle.checked = modernSettings.rememberBulkDownloadFolder;
|
||||
rememberFolderToggle.addEventListener('change', async (e) => {
|
||||
modernSettings.rememberBulkDownloadFolder = !!e.target.checked;
|
||||
await modernSettings.waitPending();
|
||||
await updateFolderMethodVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
if (resetSavedFolderBtn) {
|
||||
resetSavedFolderBtn.addEventListener('click', async () => {
|
||||
await db.saveSetting('bulk_download_folder_handle', null);
|
||||
await updateFolderMethodVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
if (singleToFolderToggle) {
|
||||
singleToFolderToggle.checked = modernSettings.downloadSinglesToFolder;
|
||||
singleToFolderToggle.addEventListener('change', (e) => {
|
||||
modernSettings.downloadSinglesToFolder = !!e.target.checked;
|
||||
});
|
||||
}
|
||||
|
||||
if (forceZipBlobToggle) {
|
||||
forceZipBlobToggle.checked = bulkDownloadSettings.shouldForceZipBlob();
|
||||
forceZipBlobToggle.checked = modernSettings.forceZipBlob;
|
||||
forceZipBlobToggle.addEventListener('change', (e) => {
|
||||
bulkDownloadSettings.setForceZipBlob(e.target.checked);
|
||||
modernSettings.forceZipBlob = !!e.target.checked;
|
||||
});
|
||||
}
|
||||
|
||||
updateForceZipBlobVisibility();
|
||||
updateFolderMethodVisibility();
|
||||
|
||||
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
||||
if (includeCoverToggle) {
|
||||
|
|
@ -2745,18 +2858,18 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
|||
// Filename template setting
|
||||
const filenameTemplate = document.getElementById('filename-template');
|
||||
if (filenameTemplate) {
|
||||
filenameTemplate.value = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
|
||||
filenameTemplate.value = modernSettings.filenameTemplate;
|
||||
filenameTemplate.addEventListener('change', (e) => {
|
||||
localStorage.setItem('filename-template', e.target.value);
|
||||
modernSettings.filenameTemplate = String(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// ZIP folder template
|
||||
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
||||
if (zipFolderTemplate) {
|
||||
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}';
|
||||
zipFolderTemplate.value = modernSettings.folderTemplate;
|
||||
zipFolderTemplate.addEventListener('change', (e) => {
|
||||
localStorage.setItem('zip-folder-template', e.target.value);
|
||||
modernSettings.folderTemplate = String(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -721,58 +721,6 @@ export const trackDateSettings = {
|
|||
},
|
||||
};
|
||||
|
||||
export const bulkDownloadSettings = {
|
||||
METHOD_KEY: 'bulk-download-method',
|
||||
FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob',
|
||||
LEGACY_INDIVIDUAL_KEY: 'force-individual-downloads',
|
||||
VALID_METHODS: ['zip', 'folder', 'individual'],
|
||||
|
||||
/** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */
|
||||
getMethod() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.METHOD_KEY);
|
||||
if (stored && this.VALID_METHODS.includes(stored)) {
|
||||
return stored;
|
||||
}
|
||||
const legacy = localStorage.getItem(this.LEGACY_INDIVIDUAL_KEY);
|
||||
if (legacy === 'true') {
|
||||
localStorage.setItem(this.METHOD_KEY, 'individual');
|
||||
localStorage.removeItem(this.LEGACY_INDIVIDUAL_KEY);
|
||||
return 'individual';
|
||||
}
|
||||
return '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) {
|
||||
this.setMethod(enabled ? 'individual' : 'zip');
|
||||
},
|
||||
};
|
||||
|
||||
export const playlistSettings = {
|
||||
M3U_KEY: 'playlist-generate-m3u',
|
||||
M3U8_KEY: 'playlist-generate-m3u8',
|
||||
|
|
|
|||
6
js/ui.js
6
js/ui.js
|
|
@ -1852,6 +1852,12 @@ export class UIRenderer {
|
|||
if (introDiv) introDiv.style.display = 'block';
|
||||
if (headerDiv) headerDiv.style.display = 'none';
|
||||
if (listContainer) listContainer.innerHTML = '';
|
||||
// Kick off a background scan when there is a saved folder handle but
|
||||
// the cache hasn't been populated yet (e.g. first visit after a page
|
||||
// reload where the startup scan was silently denied permission).
|
||||
if (!window.localFilesScanInProgress && !window.localFilesCache) {
|
||||
window.refreshLocalMediaFolder?.();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (selectBtnText) selectBtnText.textContent = 'Select Music Folder';
|
||||
|
|
|
|||
106
js/utils.js
106
js/utils.js
|
|
@ -1,4 +1,5 @@
|
|||
//js/utils.js
|
||||
import { modernSettings } from './ModernSettings.js';
|
||||
import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js';
|
||||
|
||||
export const QUALITY = 'HI_RES_LOSSLESS';
|
||||
|
|
@ -69,6 +70,45 @@ export const sanitizeForFilename = (value) => {
|
|||
.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes a single path component (no slashes allowed in the output).
|
||||
* Invalid filesystem characters are replaced with underscores.
|
||||
*/
|
||||
export const sanitizeForPathComponent = (value) => {
|
||||
if (!value) return 'Unknown';
|
||||
return value
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Like {@link formatTemplate} but allows `/` in the template for nested
|
||||
* directory structures. Each path component has invalid characters replaced,
|
||||
* the path is normalised to forward-slash separators, and empty components,
|
||||
* `.`, and `..` segments are stripped.
|
||||
*/
|
||||
export const formatPathTemplate = (template, data) => {
|
||||
let result = replaceTokens(template, {
|
||||
discNumber: String(Number(data.discNumber || 1)),
|
||||
trackNumber: data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00',
|
||||
artist: sanitizeForPathComponent(data.artist || 'Unknown Artist'),
|
||||
title: sanitizeForPathComponent(data.title || 'Unknown Title'),
|
||||
album: sanitizeForPathComponent(data.album || 'Unknown Album'),
|
||||
albumArtist: sanitizeForPathComponent(data.albumArtist || 'Unknown Artist'),
|
||||
albumTitle: sanitizeForPathComponent(data.albumTitle || 'Unknown Album'),
|
||||
year: sanitizeForPathComponent(String(data.year || 'Unknown')),
|
||||
});
|
||||
|
||||
// Normalise separators, collapse duplicates, strip . and ..
|
||||
return result
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p !== '' && p !== '.' && p !== '..')
|
||||
.join('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects audio format from DataView of first bytes
|
||||
* @param {DataView} view - DataView of first 12 bytes of audio file
|
||||
|
|
@ -203,7 +243,7 @@ export const getExtensionForQuality = (quality) => {
|
|||
};
|
||||
|
||||
export const buildTrackFilename = (track, quality, extension = null) => {
|
||||
const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
|
||||
const template = modernSettings.filenameTemplate;
|
||||
const ext = extension || getExtensionForQuality(quality);
|
||||
|
||||
const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist';
|
||||
|
|
@ -366,18 +406,17 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' }
|
|||
return fallback;
|
||||
};
|
||||
|
||||
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'));
|
||||
result = result.replace(/\{album\}/g, sanitizeForFilename(data.album || 'Unknown Album'));
|
||||
result = result.replace(/\{albumArtist\}/g, sanitizeForFilename(data.albumArtist || 'Unknown Artist'));
|
||||
result = result.replace(/\{albumTitle\}/g, sanitizeForFilename(data.albumTitle || 'Unknown Album'));
|
||||
result = result.replace(/\{year\}/g, data.year || 'Unknown');
|
||||
return result;
|
||||
};
|
||||
export const formatTemplate = (template, data) =>
|
||||
replaceTokens(template, {
|
||||
discNumber: String(Number(data.discNumber || 1)),
|
||||
trackNumber: data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00',
|
||||
artist: sanitizeForFilename(data.artist || 'Unknown Artist'),
|
||||
title: sanitizeForFilename(data.title || 'Unknown Title'),
|
||||
album: sanitizeForFilename(data.album || 'Unknown Album'),
|
||||
albumArtist: sanitizeForFilename(data.albumArtist || 'Unknown Artist'),
|
||||
albumTitle: sanitizeForFilename(data.albumTitle || 'Unknown Album'),
|
||||
year: data.year || 'Unknown',
|
||||
});
|
||||
|
||||
export const calculateTotalDuration = (tracks) => {
|
||||
if (!Array.isArray(tracks) || tracks.length === 0) return 0;
|
||||
|
|
@ -678,3 +717,44 @@ export function getTrackDiscNumber(track) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with a fallback error handler.
|
||||
* Works with both synchronous and asynchronous callbacks.
|
||||
*
|
||||
* If the callback returns a Promise, the result will also be a Promise.
|
||||
*
|
||||
* @template T
|
||||
* @param {() => T | Promise<T>} fn Function to execute
|
||||
* @param {(error: unknown) => T | Promise<T>} onError Error handler
|
||||
* @returns {T | Promise<T>}
|
||||
*/
|
||||
export function tryCatch(fn, onError) {
|
||||
try {
|
||||
const result = fn();
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch(onError);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return onError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `{token}` placeholders in a template string.
|
||||
*
|
||||
* Replacement values are inserted verbatim and are NOT reprocessed,
|
||||
* preventing cascading replacements if values contain token patterns.
|
||||
*
|
||||
* @param {string} template The input string containing tokens like `{tokenName}`
|
||||
* @param {Record<string, string>} tokens An object of tokens to replace and the replacement values.
|
||||
* @returns {string} The string with valid tokens replaced
|
||||
*/
|
||||
export function replaceTokens(template, tokens) {
|
||||
return template.replace(/{([^{}]+)}/g, (match, key) => {
|
||||
return key in tokens ? tokens[key] : match;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,6 +347,26 @@
|
|||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NL_FS_CREATE_DIR':
|
||||
try {
|
||||
await Neutralino.filesystem.createDirectory(event.data.path);
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'NL_RESPONSE', id: event.data.id, result: 'success' },
|
||||
'*'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Shell] Create Directory failed:', e);
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
|
||||
'*'
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue