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