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:
Daniel 2026-03-20 17:59:13 -05:00
parent f9a58b1cac
commit 397fc53a46
13 changed files with 947 additions and 117 deletions

View file

@ -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
View 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;
};

View file

@ -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

View file

@ -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();

View file

@ -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);
}
}
}

View file

@ -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 = {

View file

@ -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);

View file

@ -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);

View file

@ -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);
});
}

View file

@ -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',

View file

@ -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';

View file

@ -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;
});
}

View file

@ -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>