diff --git a/index.html b/index.html
index 0f6a9ba..04b6aa1 100644
--- a/index.html
+++ b/index.html
@@ -4059,9 +4059,42 @@
+
+
+ Remember Last Folder
+ Re-use the last chosen directory for Folder Picker downloads
+
+
+
+
+
+ Reset Saved Folder
+ Clear the remembered Folder Picker directory
+
+
+
+
+
+ Single Downloads to Folder
+ Save individual track downloads directly to the configured folder instead
+ of triggering a browser download
+
+
+
Force ZIP as Blob
@@ -4160,10 +4193,10 @@
- ZIP Folder Template
+ Folder Template
Customize album folder names. Available: {albumTitle}, {albumArtist},
- {year}Customize album folder names. Use / for nested folders.
+ Available: {albumTitle}, {albumArtist}, {year}
{
+ /** Internal map of pending async operations keyed by unique symbols. */
+ #pending: Record
> = {};
+
+ /** 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>(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("darkMode", false)
+ * .addProperty("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(
+ key: K,
+ defaultValue: T,
+ options?: {
+ backingKey?: string;
+ getter?: (value: T, settings: C & Record) => T;
+ setter?: (value: T, settings: C & Record) => T;
+ legacy?: {
+ key?: string;
+ transformer: (value: string) => T;
+ };
+ }
+ ) {
+ const { backingKey, legacy, getter, setter } = options ?? {};
+
+ this.#checkKey(key);
+
+ const typed = this as unknown as ModernSettings>;
+
+ 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 & Record) : value),
+ set: (newValue: T) => {
+ value = setter ? setter(newValue, typed as ModernSettings & C & Record) : newValue;
+ this.#addPending(() => db.saveSetting(backingKey ?? key, value));
+ },
+ enumerable: true,
+ });
+
+ return typed;
+ }
+
+ public addGetter(key: K, getter: (settings: ModernSettings) => R) {
+ this.#checkKey(key);
+ const typed = this as unknown as ModernSettings>> & C & Readonly>;
+
+ 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;
+};
diff --git a/js/api.js b/js/api.js
index 4d906cf..1712072 100644
--- a/js/api.js
+++ b/js/api.js
@@ -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
diff --git a/js/app.js b/js/app.js
index 11eb00f..eb44c95 100644
--- a/js/app.js
+++ b/js/app.js
@@ -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();
diff --git a/js/bulk-download-writer.ts b/js/bulk-download-writer.ts
index d0975f8..e5d7551 100644
--- a/js/bulk-download-writer.ts
+++ b/js/bulk-download-writer.ts
@@ -16,10 +16,12 @@ interface NeutralinoBridge {
title: string,
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
): Promise;
+ showFolderDialog(title: string, options?: Record): Promise;
};
filesystem: {
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise;
appendBinaryFile(path: string, buffer: ArrayBuffer): Promise;
+ createDirectory(path: string): Promise;
};
}
@@ -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 {
+ static async create(savedHandle?: FileSystemDirectoryHandle | null): Promise {
+ // 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): Promise {
+ // 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();
+
+ 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);
+ }
+ }
+}
diff --git a/js/desktop/neutralino-bridge.js b/js/desktop/neutralino-bridge.js
index 85a686a..eceee4a 100644
--- a/js/desktop/neutralino-bridge.js
+++ b/js/desktop/neutralino-bridge.js
@@ -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 = {
diff --git a/js/download-utils.ts b/js/download-utils.ts
index 61c7b6f..51ed90c 100644
--- a/js/download-utils.ts
+++ b/js/download-utils.ts
@@ -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 {
- // 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);
diff --git a/js/downloads.js b/js/downloads.js
index 987a0b8..58749dd 100644
--- a/js/downloads.js
+++ b/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);
diff --git a/js/settings.js b/js/settings.js
index 060b910..98119c3 100644
--- a/js/settings.js
+++ b/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);
});
}
diff --git a/js/storage.js b/js/storage.js
index 8483a25..f4884a2 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -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',
diff --git a/js/ui.js b/js/ui.js
index 770252a..5793b0b 100644
--- a/js/ui.js
+++ b/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';
diff --git a/js/utils.js b/js/utils.js
index c244fc8..b7425ab 100644
--- a/js/utils.js
+++ b/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} fn Function to execute
+ * @param {(error: unknown) => T | Promise} onError Error handler
+ * @returns {T | Promise}
+ */
+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} 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;
+ });
+}
diff --git a/public/neutralino_loader.html b/public/neutralino_loader.html
index 50ef8fb..0ee807d 100644
--- a/public/neutralino_loader.html
+++ b/public/neutralino_loader.html
@@ -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;
}
});