feat(downloads): add local media folder bulk download options and folder template paths
This also implements a ModernSettings class for a more streamlined settings API.
This commit is contained in:
parent
f9a58b1cac
commit
397fc53a46
13 changed files with 947 additions and 117 deletions
39
index.html
39
index.html
|
|
@ -4059,9 +4059,42 @@
|
||||||
<select id="bulk-download-method">
|
<select id="bulk-download-method">
|
||||||
<option value="zip">ZIP Archive</option>
|
<option value="zip">ZIP Archive</option>
|
||||||
<option value="folder">Folder Picker</option>
|
<option value="folder">Folder Picker</option>
|
||||||
|
<option value="local">Local Media Folder</option>
|
||||||
<option value="individual">Individual Files</option>
|
<option value="individual">Individual Files</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Force ZIP as Blob</span>
|
<span class="label">Force ZIP as Blob</span>
|
||||||
|
|
@ -4160,10 +4193,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">ZIP Folder Template</span>
|
<span class="label">Folder Template</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Customize album folder names. Available: {albumTitle}, {albumArtist},
|
>Customize album folder names. Use <code>/</code> for nested folders.
|
||||||
{year}</span
|
Available: {albumTitle}, {albumArtist}, {year}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
289
js/ModernSettings.ts
Normal file
289
js/ModernSettings.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { db } from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dynamically typed settings container that lazily loads and persists values.
|
||||||
|
*
|
||||||
|
* Properties are registered using {@link addProperty}. Each property becomes a real
|
||||||
|
* getter/setter on the instance and is automatically persisted through the backing
|
||||||
|
* `db` implementation.
|
||||||
|
*
|
||||||
|
* All asynchronous reads/writes are tracked internally. Use {@link waitPending}
|
||||||
|
* to await completion of any pending operations.
|
||||||
|
*
|
||||||
|
* @template C The accumulated shape of the settings object.
|
||||||
|
*/
|
||||||
|
class ModernSettings<C extends object = {}> {
|
||||||
|
/** Internal map of pending async operations keyed by unique symbols. */
|
||||||
|
#pending: Record<symbol, Promise<any>> = {};
|
||||||
|
|
||||||
|
/** Whether new properties are prevented from being added. */
|
||||||
|
#finalized: boolean = false;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until all pending asynchronous operations complete.
|
||||||
|
*
|
||||||
|
* This includes:
|
||||||
|
* - Initial property loading
|
||||||
|
* - Any pending writes triggered by property setters
|
||||||
|
*
|
||||||
|
* This method loops until the pending operation list is empty, ensuring
|
||||||
|
* that operations scheduled during awaiting are also handled.
|
||||||
|
*/
|
||||||
|
public async waitPending() {
|
||||||
|
while (true) {
|
||||||
|
const promises = Object.getOwnPropertySymbols(this.#pending).map((s) => this.#pending[s]);
|
||||||
|
|
||||||
|
if (promises.length) {
|
||||||
|
await Promise.all(promises);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a promise as a pending operation.
|
||||||
|
*
|
||||||
|
* The promise is automatically removed from the pending list once settled.
|
||||||
|
*
|
||||||
|
* @param callback Function producing the promise to track.
|
||||||
|
* @returns The created promise.
|
||||||
|
*/
|
||||||
|
#addPending<C extends Promise<any>>(callback: () => C): C {
|
||||||
|
const sym = Symbol();
|
||||||
|
|
||||||
|
return (this.#pending[sym] = callback().finally(() => {
|
||||||
|
delete this.#pending[sym];
|
||||||
|
}) as C);
|
||||||
|
}
|
||||||
|
|
||||||
|
#checkKey(key: string) {
|
||||||
|
if (this.#finalized) {
|
||||||
|
throw new Error("Can't add a key after finalization.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(this).includes(key)) {
|
||||||
|
throw new Error("Can't add a key that already exists.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new dynamically typed property to the settings instance.
|
||||||
|
*
|
||||||
|
* The property will:
|
||||||
|
* - Load its value asynchronously from the backing database.
|
||||||
|
* - Fall back to `defaultValue` if no value exists.
|
||||||
|
* - Persist any updates automatically when set.
|
||||||
|
*
|
||||||
|
* The method returns the same instance but **with the new property added to
|
||||||
|
* the TypeScript type**, allowing fluent chaining with full type safety.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```ts
|
||||||
|
* const settings = new ModernSettings()
|
||||||
|
* .addProperty<boolean>("darkMode", false)
|
||||||
|
* .addProperty<string>("username", "")
|
||||||
|
* .finalize();
|
||||||
|
*
|
||||||
|
* await settings.waitPending();
|
||||||
|
*
|
||||||
|
* settings.darkMode = true;
|
||||||
|
* console.log(settings.username);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @template T Property value type.
|
||||||
|
* @template K Property key name.
|
||||||
|
*
|
||||||
|
* @param key The property name to define on the settings object.
|
||||||
|
* @param defaultValue Value used if the setting is not present in storage.
|
||||||
|
* @param options Optional configuration.
|
||||||
|
*
|
||||||
|
* @param options.backingKey
|
||||||
|
* Optional storage key. Defaults to the property name.
|
||||||
|
*
|
||||||
|
* @param options.legacy
|
||||||
|
* Optional migration configuration for moving a value from `localStorage`
|
||||||
|
* into the database-backed settings store.
|
||||||
|
*
|
||||||
|
* @param options.legacy.key
|
||||||
|
* Legacy key to read from `localStorage`. Defaults to the same key used for storage.
|
||||||
|
*
|
||||||
|
* @param options.legacy.transformer
|
||||||
|
* Function used to convert the legacy string value into the correct type.
|
||||||
|
*
|
||||||
|
* @returns The same instance typed with the new property included.
|
||||||
|
*
|
||||||
|
* @throws If called after {@link finalize}.
|
||||||
|
* @throws If a property with the same name already exists.
|
||||||
|
*/
|
||||||
|
public addProperty<T, K extends string>(
|
||||||
|
key: K,
|
||||||
|
defaultValue: T,
|
||||||
|
options?: {
|
||||||
|
backingKey?: string;
|
||||||
|
getter?: (value: T, settings: C & Record<K, T>) => T;
|
||||||
|
setter?: (value: T, settings: C & Record<K, T>) => T;
|
||||||
|
legacy?: {
|
||||||
|
key?: string;
|
||||||
|
transformer: (value: string) => T;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { backingKey, legacy, getter, setter } = options ?? {};
|
||||||
|
|
||||||
|
this.#checkKey(key);
|
||||||
|
|
||||||
|
const typed = this as unknown as ModernSettings<C & Record<K, T>>;
|
||||||
|
|
||||||
|
let value: T;
|
||||||
|
|
||||||
|
this.#addPending(async () => {
|
||||||
|
if (legacy?.key != null || legacy?.transformer != null) {
|
||||||
|
{
|
||||||
|
const legacyValue = localStorage.getItem(legacy?.key ?? backingKey ?? key);
|
||||||
|
|
||||||
|
if (legacyValue !== null) {
|
||||||
|
db.saveSetting(backingKey ?? key, legacy.transformer!(legacyValue));
|
||||||
|
localStorage.removeItem(legacy?.key ?? backingKey ?? key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
value = (await db.getSetting(backingKey ?? key)) ?? defaultValue;
|
||||||
|
} catch {
|
||||||
|
value = defaultValue;
|
||||||
|
}
|
||||||
|
}).catch(console.trace);
|
||||||
|
|
||||||
|
Object.defineProperty(this, key, {
|
||||||
|
get: () => (getter ? getter(value, typed as ModernSettings<C> & C & Record<K, T>) : value),
|
||||||
|
set: (newValue: T) => {
|
||||||
|
value = setter ? setter(newValue, typed as ModernSettings<C> & C & Record<K, T>) : newValue;
|
||||||
|
this.#addPending(() => db.saveSetting(backingKey ?? key, value));
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return typed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addGetter<K extends string, R>(key: K, getter: (settings: ModernSettings<C>) => R) {
|
||||||
|
this.#checkKey(key);
|
||||||
|
const typed = this as unknown as ModernSettings<C & Readonly<Record<K, R>>> & C & Readonly<Record<K, R>>;
|
||||||
|
|
||||||
|
Object.defineProperty(this, key, {
|
||||||
|
get: () => getter(typed),
|
||||||
|
enumerable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return typed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevents further properties from being added.
|
||||||
|
*
|
||||||
|
* This is typically called once all `addProperty` calls are complete,
|
||||||
|
* ensuring the settings schema is fixed.
|
||||||
|
*
|
||||||
|
* @returns The settings instance.
|
||||||
|
*/
|
||||||
|
public finalize() {
|
||||||
|
this.#finalized = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BulkDownloadMethod {
|
||||||
|
Zip = 'zip',
|
||||||
|
Folder = 'folder',
|
||||||
|
Individual = 'individual',
|
||||||
|
LocalMedia = 'local',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modernSettings = new ModernSettings()
|
||||||
|
.addProperty('bulkDownloadFolder', null as FileSystemDirectoryHandle | null)
|
||||||
|
.addProperty('forceZipBlob', false, {
|
||||||
|
legacy: {
|
||||||
|
key: 'bulk-download-force-zip-blob',
|
||||||
|
transformer: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addProperty('rememberBulkDownloadFolder', false, {
|
||||||
|
legacy: {
|
||||||
|
key: 'bulk-download-remember-folder',
|
||||||
|
transformer: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addProperty('downloadSinglesToFolder', false, {
|
||||||
|
legacy: {
|
||||||
|
key: 'bulk-download-single-to-folder',
|
||||||
|
transformer: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addProperty('force-individual-downloads', false, {
|
||||||
|
legacy: {
|
||||||
|
transformer: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addProperty('bulkDownloadMethod', 'zip' as BulkDownloadMethod, {
|
||||||
|
getter: (stored, settings) => {
|
||||||
|
try {
|
||||||
|
if (stored && Object.values(BulkDownloadMethod).includes(stored)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = settings['force-individual-downloads'];
|
||||||
|
if (legacy) {
|
||||||
|
settings['force-individual-downloads'] = false;
|
||||||
|
return (settings.bulkDownloadMethod = BulkDownloadMethod.Individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BulkDownloadMethod.Zip;
|
||||||
|
} catch {
|
||||||
|
return BulkDownloadMethod.Zip;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addProperty('folderTemplate', '', {
|
||||||
|
getter: (stored) => stored || '{albumTitle} - {albumArtist}',
|
||||||
|
legacy: {
|
||||||
|
key: 'zip-folder-template',
|
||||||
|
transformer: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addProperty('filenameTemplate', '', {
|
||||||
|
getter: (stored) => stored || '{trackNumber} - {artist} - {title}',
|
||||||
|
legacy: {
|
||||||
|
key: 'filename-template',
|
||||||
|
transformer: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.finalize() as ModernSettings & {
|
||||||
|
/** The last used directory handle for bulk downloads */
|
||||||
|
bulkDownloadFolder: FileSystemDirectoryHandle | null;
|
||||||
|
|
||||||
|
/** Force ZIP blobs for bulk downloads even if file system APIs are available */
|
||||||
|
forceZipBlob: boolean;
|
||||||
|
|
||||||
|
/** Whether the Folder Picker should remember the last-used directory handle */
|
||||||
|
rememberBulkDownloadFolder: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether single-track downloads should be routed to the configured
|
||||||
|
* folder (saved Folder Picker handle or Local Media Folder path)
|
||||||
|
* instead of triggering a browser download.
|
||||||
|
*/
|
||||||
|
downloadSinglesToFolder: boolean;
|
||||||
|
|
||||||
|
/** The selected bulk download method */
|
||||||
|
bulkDownloadMethod: BulkDownloadMethod;
|
||||||
|
|
||||||
|
/** Path template for bulk downloads */
|
||||||
|
folderTemplate: string;
|
||||||
|
|
||||||
|
/** Filename template for downloads */
|
||||||
|
filenameTemplate: string;
|
||||||
|
};
|
||||||
|
|
@ -1506,7 +1506,13 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVideo) {
|
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
|
// Add metadata if track information is provided
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import {
|
||||||
parseDynamicCSV,
|
parseDynamicCSV,
|
||||||
importToLibrary,
|
importToLibrary,
|
||||||
} from './playlist-importer.js';
|
} from './playlist-importer.js';
|
||||||
|
import { modernSettings } from './ModernSettings.js';
|
||||||
import {
|
import {
|
||||||
SVG_OFFLINE,
|
SVG_OFFLINE,
|
||||||
SVG_RIGHT_ARROW,
|
SVG_RIGHT_ARROW,
|
||||||
|
|
@ -382,6 +383,8 @@ async function uploadCoverImage(file) {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await modernSettings.waitPending();
|
||||||
|
|
||||||
// Initialize analytics
|
// Initialize analytics
|
||||||
initAnalytics();
|
initAnalytics();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ interface NeutralinoBridge {
|
||||||
title: string,
|
title: string,
|
||||||
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
|
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
|
||||||
): Promise<string | null>;
|
): Promise<string | null>;
|
||||||
|
showFolderDialog(title: string, options?: Record<string, unknown>): Promise<string | null>;
|
||||||
};
|
};
|
||||||
filesystem: {
|
filesystem: {
|
||||||
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
||||||
appendBinaryFile(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 {
|
export class FolderPickerWriter implements IBulkDownloadWriter {
|
||||||
private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {}
|
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.
|
* Returns a new {@link FolderPickerWriter} bound to the chosen directory.
|
||||||
* If the user dismisses the picker, the promise rejects with a DOMException
|
* If the user dismisses the picker, the promise rejects with a DOMException
|
||||||
* whose name is "AbortError".
|
* 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)
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
try {
|
try {
|
||||||
|
|
@ -214,3 +244,54 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes files directly into a folder on the local filesystem via the
|
||||||
|
* Neutralino desktop bridge. Subdirectories are created automatically.
|
||||||
|
*/
|
||||||
|
export class NeutralinoFolderWriter implements IBulkDownloadWriter {
|
||||||
|
constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
||||||
|
// Import once per write() call; the module system caches the result.
|
||||||
|
const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge;
|
||||||
|
const createdDirs = new Set<string>();
|
||||||
|
|
||||||
|
for await (const file of files) {
|
||||||
|
const parts = file.name.split('/').filter(Boolean);
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
|
||||||
|
// Ensure all parent directories exist
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const dirPath = this.basePath + '/' + parts.slice(0, i).join('/');
|
||||||
|
if (!createdDirs.has(dirPath)) {
|
||||||
|
try {
|
||||||
|
await bridge.filesystem.createDirectory(dirPath);
|
||||||
|
} catch {
|
||||||
|
// Directory may already exist; ignore
|
||||||
|
}
|
||||||
|
createdDirs.add(dirPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.basePath + '/' + file.name;
|
||||||
|
let buffer: ArrayBuffer;
|
||||||
|
const { input } = file;
|
||||||
|
if (input instanceof Blob) {
|
||||||
|
buffer = await input.arrayBuffer();
|
||||||
|
} else if (typeof input === 'string') {
|
||||||
|
const encoded = new TextEncoder().encode(input);
|
||||||
|
buffer = encoded.buffer.slice(
|
||||||
|
encoded.byteOffset,
|
||||||
|
encoded.byteOffset + encoded.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
} else if (input instanceof Uint8Array) {
|
||||||
|
buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) as ArrayBuffer;
|
||||||
|
} else {
|
||||||
|
buffer = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bridge.filesystem.writeBinaryFile(filePath, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,21 @@ export const filesystem = {
|
||||||
window.parent.postMessage({ type: 'NL_FS_APPEND_BINARY', id, path, buffer }, '*', [buffer]);
|
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 = {
|
export const updater = {
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,68 @@ export function triggerDownload(blob: Blob, filename: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies audio post-processing to a blob:
|
* Apply post-processing to an audio Blob according to the requested quality.
|
||||||
* 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").
|
|
||||||
*
|
*
|
||||||
* 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(
|
export async function applyAudioPostProcessing(
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
quality: string,
|
quality: string,
|
||||||
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
onProgress: ((progress: ProgressEvent) => void) | null = null,
|
||||||
signal: AbortSignal | null = null
|
signal: AbortSignal | null = null,
|
||||||
|
trackAudioQuality: string | null = null
|
||||||
): Promise<Blob> {
|
): 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 (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);
|
const format = getCustomFormat(quality);
|
||||||
if (format) {
|
if (format) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -59,8 +106,15 @@ export async function applyAudioPostProcessing(
|
||||||
|
|
||||||
if (quality.endsWith('LOSSLESS')) {
|
if (quality.endsWith('LOSSLESS')) {
|
||||||
try {
|
try {
|
||||||
const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
|
const containerName = losslessContainerSettings.getContainer();
|
||||||
const extension = await getExtensionFromBlob(blob);
|
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)) {
|
if (await containerFmt?.needsTranscode(blob)) {
|
||||||
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
|
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
|
||||||
|
|
|
||||||
226
js/downloads.js
226
js/downloads.js
|
|
@ -5,6 +5,7 @@ import {
|
||||||
RATE_LIMIT_ERROR_MESSAGE,
|
RATE_LIMIT_ERROR_MESSAGE,
|
||||||
getTrackArtists,
|
getTrackArtists,
|
||||||
getTrackTitle,
|
getTrackTitle,
|
||||||
|
formatPathTemplate,
|
||||||
getCoverBlob,
|
getCoverBlob,
|
||||||
getExtensionFromBlob,
|
getExtensionFromBlob,
|
||||||
formatTemplate,
|
formatTemplate,
|
||||||
|
|
@ -12,17 +13,20 @@ import {
|
||||||
getTrackDiscNumber,
|
getTrackDiscNumber,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { AbortError } from './errorTypes.ts';
|
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 { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||||
import {
|
import {
|
||||||
ZipStreamWriter,
|
ZipStreamWriter,
|
||||||
ZipBlobWriter,
|
ZipBlobWriter,
|
||||||
ZipNeutralinoWriter,
|
ZipNeutralinoWriter,
|
||||||
FolderPickerWriter,
|
FolderPickerWriter,
|
||||||
|
NeutralinoFolderWriter,
|
||||||
SequentialFileWriter,
|
SequentialFileWriter,
|
||||||
} from './bulk-download-writer.ts';
|
} from './bulk-download-writer.ts';
|
||||||
import { FfmpegProgress } from './ffmpeg.types.js';
|
import { FfmpegProgress } from './ffmpeg.types.js';
|
||||||
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
|
import { DownloadProgress, ProgressMessage, SegmentedDownloadProgress } from './progressEvents.js';
|
||||||
|
import { db } from './db.js';
|
||||||
|
import { modernSettings } from './ModernSettings.js';
|
||||||
import { SVG_CLOSE } from './icons.ts';
|
import { SVG_CLOSE } from './icons.ts';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
|
|
@ -30,6 +34,11 @@ const bulkDownloadTasks = new Map();
|
||||||
const ongoingDownloads = new Set();
|
const ongoingDownloads = new Set();
|
||||||
let downloadNotificationContainer = null;
|
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) {
|
async function createDiscLayoutContext(tracks, api) {
|
||||||
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
||||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||||
|
|
@ -488,6 +497,71 @@ async function bulkDownload(
|
||||||
await writer.write(yieldFiles());
|
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,
|
* Returns the appropriate bulk download writer for the current settings and environment,
|
||||||
* or null when individual sequential downloads should be used.
|
* or null when individual sequential downloads should be used.
|
||||||
|
|
@ -496,17 +570,75 @@ async function createBulkWriter(folderName) {
|
||||||
const isNeutralino =
|
const isNeutralino =
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
(window.NL_MODE || window.location.search.includes('mode=neutralino') || window.parent !== window);
|
(window.NL_MODE || window.location.search.includes('mode=neutralino') || window.parent !== window);
|
||||||
const method = bulkDownloadSettings.getMethod();
|
const method = modernSettings.bulkDownloadMethod;
|
||||||
const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob();
|
const forceZipBlob = modernSettings.forceZipBlob;
|
||||||
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
||||||
const hasFolderPicker = 'showDirectoryPicker' in window;
|
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) {
|
if (isNeutralino) {
|
||||||
return new ZipNeutralinoWriter(folderName);
|
return new ZipNeutralinoWriter(folderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder Picker method ─────────────────────────────────────────────────
|
||||||
if (method === 'folder' && hasFolderPicker) {
|
if (method === 'folder' && hasFolderPicker) {
|
||||||
|
const rememberFolder = modernSettings.rememberBulkDownloadFolder;
|
||||||
|
const savedHandle = rememberFolder ? await db.getSetting('bulk_download_folder_handle') : null;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -514,6 +646,7 @@ async function createBulkWriter(folderName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'individual') {
|
if (method === 'individual') {
|
||||||
return new SequentialFileWriter();
|
return new SequentialFileWriter();
|
||||||
}
|
}
|
||||||
|
|
@ -556,6 +689,11 @@ async function startBulkDownload(
|
||||||
}
|
}
|
||||||
|
|
||||||
completeBulkDownload(notification, true);
|
completeBulkDownload(notification, true);
|
||||||
|
|
||||||
|
// If the download went to the local media folder, refresh the local library.
|
||||||
|
if (modernSettings.bulkDownloadMethod === 'local') {
|
||||||
|
window.refreshLocalMediaFolder?.();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
removeBulkDownloadTask(notification);
|
removeBulkDownloadTask(notification);
|
||||||
|
|
@ -579,7 +717,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
||||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
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,
|
albumTitle: album.title,
|
||||||
albumArtist: album.artist?.name,
|
albumArtist: album.artist?.name,
|
||||||
year: year,
|
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) {
|
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,
|
albumTitle: playlist.title,
|
||||||
albumArtist: 'Playlist',
|
albumArtist: 'Playlist',
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
|
|
@ -643,14 +781,11 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
||||||
|
|
||||||
const albumFolder = formatTemplate(
|
const albumFolder = formatPathTemplate(modernSettings.folderTemplate, {
|
||||||
localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}',
|
albumTitle: fullAlbum.title,
|
||||||
{
|
albumArtist: fullAlbum.artist?.name,
|
||||||
albumTitle: fullAlbum.title,
|
year: year,
|
||||||
albumArtist: fullAlbum.artist?.name,
|
});
|
||||||
year: year,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||||
if (coverBlob && playlistSettings.shouldIncludeCover())
|
if (coverBlob && playlistSettings.shouldIncludeCover())
|
||||||
|
|
@ -942,16 +1077,63 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
ongoingDownloads.add(downloadKey);
|
ongoingDownloads.add(downloadKey);
|
||||||
|
|
||||||
try {
|
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);
|
addDownloadTask(track.id, enrichedTrack, filename, api, controller);
|
||||||
|
|
||||||
await api.downloadTrack(track.id, quality, filename, {
|
// Try to write directly to the configured folder when the feature is enabled.
|
||||||
signal: controller.signal,
|
if (folderWriter) {
|
||||||
track: enrichedTrack,
|
// Download the blob (metadata already applied inside downloadTrack)
|
||||||
onProgress: (progress) => {
|
const blob = await api.downloadTrack(track.id, quality, filename, {
|
||||||
updateDownloadProgress(track.id, progress);
|
signal: controller.signal,
|
||||||
},
|
track: enrichedTrack,
|
||||||
calculateDashBytes: true,
|
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);
|
completeDownloadTask(track.id, true);
|
||||||
|
|
||||||
|
|
|
||||||
143
js/settings.js
143
js/settings.js
|
|
@ -16,7 +16,6 @@ import {
|
||||||
qualityBadgeSettings,
|
qualityBadgeSettings,
|
||||||
trackDateSettings,
|
trackDateSettings,
|
||||||
visualizerSettings,
|
visualizerSettings,
|
||||||
bulkDownloadSettings,
|
|
||||||
playlistSettings,
|
playlistSettings,
|
||||||
equalizerSettings,
|
equalizerSettings,
|
||||||
listenBrainzSettings,
|
listenBrainzSettings,
|
||||||
|
|
@ -41,6 +40,7 @@ import { db } from './db.js';
|
||||||
import { authManager } from './accounts/auth.js';
|
import { authManager } from './accounts/auth.js';
|
||||||
import { syncManager } from './accounts/pocketbase.js';
|
import { syncManager } from './accounts/pocketbase.js';
|
||||||
import { containerFormats, customFormats } from './ffmpegFormats.ts';
|
import { containerFormats, customFormats } from './ffmpegFormats.ts';
|
||||||
|
import { modernSettings } from './ModernSettings.js';
|
||||||
|
|
||||||
async function getButterchurnPresets(...args) {
|
async function getButterchurnPresets(...args) {
|
||||||
const butterchurnModule = await import('./visualizers/butterchurn.js');
|
const butterchurnModule = await import('./visualizers/butterchurn.js');
|
||||||
|
|
@ -949,44 +949,157 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
'showSaveFilePicker' in window &&
|
'showSaveFilePicker' in window &&
|
||||||
typeof FileSystemFileHandle !== 'undefined' &&
|
typeof FileSystemFileHandle !== 'undefined' &&
|
||||||
'createWritable' in FileSystemFileHandle.prototype;
|
'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 */
|
/** Shows/hides the Force ZIP as Blob setting based on method and browser support */
|
||||||
function updateForceZipBlobVisibility() {
|
function updateForceZipBlobVisibility() {
|
||||||
if (!forceZipBlobSettingItem) return;
|
if (!forceZipBlobSettingItem) return;
|
||||||
const method = bulkDownloadSettings.getMethod();
|
const method = modernSettings.bulkDownloadMethod;
|
||||||
// Only relevant when zip method is selected and the browser supports streaming
|
// Only relevant when zip method is selected and the browser supports streaming
|
||||||
const visible = method === 'zip' && hasFileSystemAccess;
|
const visible = method === 'zip' && hasFileSystemAccess;
|
||||||
forceZipBlobSettingItem.style.display = visible ? '' : 'none';
|
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');
|
const bulkDownloadMethod = document.getElementById('bulk-download-method');
|
||||||
if (bulkDownloadMethod) {
|
if (bulkDownloadMethod) {
|
||||||
// Remove the folder picker option if the browser doesn't support it
|
// 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"]');
|
const folderOption = bulkDownloadMethod.querySelector('option[value="folder"]');
|
||||||
if (folderOption) {
|
if (folderOption) {
|
||||||
folderOption.remove();
|
folderOption.remove();
|
||||||
}
|
}
|
||||||
// If the stored method is 'folder', fall back to 'zip'
|
const localOption = bulkDownloadMethod.querySelector('option[value="local"]');
|
||||||
if (bulkDownloadSettings.getMethod() === 'folder') {
|
if (localOption) {
|
||||||
bulkDownloadSettings.setMethod('zip');
|
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.value = modernSettings.bulkDownloadMethod;
|
||||||
bulkDownloadMethod.addEventListener('change', (e) => {
|
bulkDownloadMethod.addEventListener('change', async (e) => {
|
||||||
bulkDownloadSettings.setMethod(e.target.value);
|
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();
|
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) {
|
if (forceZipBlobToggle) {
|
||||||
forceZipBlobToggle.checked = bulkDownloadSettings.shouldForceZipBlob();
|
forceZipBlobToggle.checked = modernSettings.forceZipBlob;
|
||||||
forceZipBlobToggle.addEventListener('change', (e) => {
|
forceZipBlobToggle.addEventListener('change', (e) => {
|
||||||
bulkDownloadSettings.setForceZipBlob(e.target.checked);
|
modernSettings.forceZipBlob = !!e.target.checked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateForceZipBlobVisibility();
|
updateForceZipBlobVisibility();
|
||||||
|
updateFolderMethodVisibility();
|
||||||
|
|
||||||
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
const includeCoverToggle = document.getElementById('include-cover-toggle');
|
||||||
if (includeCoverToggle) {
|
if (includeCoverToggle) {
|
||||||
|
|
@ -2745,18 +2858,18 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// Filename template setting
|
// Filename template setting
|
||||||
const filenameTemplate = document.getElementById('filename-template');
|
const filenameTemplate = document.getElementById('filename-template');
|
||||||
if (filenameTemplate) {
|
if (filenameTemplate) {
|
||||||
filenameTemplate.value = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
|
filenameTemplate.value = modernSettings.filenameTemplate;
|
||||||
filenameTemplate.addEventListener('change', (e) => {
|
filenameTemplate.addEventListener('change', (e) => {
|
||||||
localStorage.setItem('filename-template', e.target.value);
|
modernSettings.filenameTemplate = String(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZIP folder template
|
// ZIP folder template
|
||||||
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
||||||
if (zipFolderTemplate) {
|
if (zipFolderTemplate) {
|
||||||
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}';
|
zipFolderTemplate.value = modernSettings.folderTemplate;
|
||||||
zipFolderTemplate.addEventListener('change', (e) => {
|
zipFolderTemplate.addEventListener('change', (e) => {
|
||||||
localStorage.setItem('zip-folder-template', e.target.value);
|
modernSettings.folderTemplate = String(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -721,58 +721,6 @@ export const trackDateSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bulkDownloadSettings = {
|
|
||||||
METHOD_KEY: 'bulk-download-method',
|
|
||||||
FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob',
|
|
||||||
LEGACY_INDIVIDUAL_KEY: 'force-individual-downloads',
|
|
||||||
VALID_METHODS: ['zip', 'folder', 'individual'],
|
|
||||||
|
|
||||||
/** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */
|
|
||||||
getMethod() {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(this.METHOD_KEY);
|
|
||||||
if (stored && this.VALID_METHODS.includes(stored)) {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
const legacy = localStorage.getItem(this.LEGACY_INDIVIDUAL_KEY);
|
|
||||||
if (legacy === 'true') {
|
|
||||||
localStorage.setItem(this.METHOD_KEY, 'individual');
|
|
||||||
localStorage.removeItem(this.LEGACY_INDIVIDUAL_KEY);
|
|
||||||
return 'individual';
|
|
||||||
}
|
|
||||||
return 'zip';
|
|
||||||
} catch {
|
|
||||||
return 'zip';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setMethod(method) {
|
|
||||||
localStorage.setItem(this.METHOD_KEY, method);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** When using ZIP mode, force in-memory blob download instead of streaming to disk */
|
|
||||||
shouldForceZipBlob() {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(this.FORCE_ZIP_BLOB_KEY) === 'true';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setForceZipBlob(enabled) {
|
|
||||||
localStorage.setItem(this.FORCE_ZIP_BLOB_KEY, enabled ? 'true' : 'false');
|
|
||||||
},
|
|
||||||
|
|
||||||
// Kept for backward compatibility
|
|
||||||
shouldForceIndividual() {
|
|
||||||
return this.getMethod() === 'individual';
|
|
||||||
},
|
|
||||||
|
|
||||||
setForceIndividual(enabled) {
|
|
||||||
this.setMethod(enabled ? 'individual' : 'zip');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const playlistSettings = {
|
export const playlistSettings = {
|
||||||
M3U_KEY: 'playlist-generate-m3u',
|
M3U_KEY: 'playlist-generate-m3u',
|
||||||
M3U8_KEY: 'playlist-generate-m3u8',
|
M3U8_KEY: 'playlist-generate-m3u8',
|
||||||
|
|
|
||||||
6
js/ui.js
6
js/ui.js
|
|
@ -1852,6 +1852,12 @@ export class UIRenderer {
|
||||||
if (introDiv) introDiv.style.display = 'block';
|
if (introDiv) introDiv.style.display = 'block';
|
||||||
if (headerDiv) headerDiv.style.display = 'none';
|
if (headerDiv) headerDiv.style.display = 'none';
|
||||||
if (listContainer) listContainer.innerHTML = '';
|
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 {
|
} else {
|
||||||
if (selectBtnText) selectBtnText.textContent = 'Select Music Folder';
|
if (selectBtnText) selectBtnText.textContent = 'Select Music Folder';
|
||||||
|
|
|
||||||
106
js/utils.js
106
js/utils.js
|
|
@ -1,4 +1,5 @@
|
||||||
//js/utils.js
|
//js/utils.js
|
||||||
|
import { modernSettings } from './ModernSettings.js';
|
||||||
import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js';
|
import { qualityBadgeSettings, coverArtSizeSettings, trackDateSettings } from './storage.js';
|
||||||
|
|
||||||
export const QUALITY = 'HI_RES_LOSSLESS';
|
export const QUALITY = 'HI_RES_LOSSLESS';
|
||||||
|
|
@ -69,6 +70,45 @@ export const sanitizeForFilename = (value) => {
|
||||||
.trim();
|
.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
|
* Detects audio format from DataView of first bytes
|
||||||
* @param {DataView} view - DataView of first 12 bytes of audio file
|
* @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) => {
|
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 ext = extension || getExtensionForQuality(quality);
|
||||||
|
|
||||||
const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist';
|
const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist';
|
||||||
|
|
@ -366,18 +406,17 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' }
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatTemplate = (template, data) => {
|
export const formatTemplate = (template, data) =>
|
||||||
let result = template;
|
replaceTokens(template, {
|
||||||
result = result.replace(/\{discNumber\}/g, String(Number(data.discNumber || 1)));
|
discNumber: String(Number(data.discNumber || 1)),
|
||||||
result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00');
|
trackNumber: data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00',
|
||||||
result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist'));
|
artist: sanitizeForFilename(data.artist || 'Unknown Artist'),
|
||||||
result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title'));
|
title: sanitizeForFilename(data.title || 'Unknown Title'),
|
||||||
result = result.replace(/\{album\}/g, sanitizeForFilename(data.album || 'Unknown Album'));
|
album: sanitizeForFilename(data.album || 'Unknown Album'),
|
||||||
result = result.replace(/\{albumArtist\}/g, sanitizeForFilename(data.albumArtist || 'Unknown Artist'));
|
albumArtist: sanitizeForFilename(data.albumArtist || 'Unknown Artist'),
|
||||||
result = result.replace(/\{albumTitle\}/g, sanitizeForFilename(data.albumTitle || 'Unknown Album'));
|
albumTitle: sanitizeForFilename(data.albumTitle || 'Unknown Album'),
|
||||||
result = result.replace(/\{year\}/g, data.year || 'Unknown');
|
year: data.year || 'Unknown',
|
||||||
return result;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export const calculateTotalDuration = (tracks) => {
|
export const calculateTotalDuration = (tracks) => {
|
||||||
if (!Array.isArray(tracks) || tracks.length === 0) return 0;
|
if (!Array.isArray(tracks) || tracks.length === 0) return 0;
|
||||||
|
|
@ -678,3 +717,44 @@ export function getTrackDiscNumber(track) {
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a function with a fallback error handler.
|
||||||
|
* Works with both synchronous and asynchronous callbacks.
|
||||||
|
*
|
||||||
|
* If the callback returns a Promise, the result will also be a Promise.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {() => T | Promise<T>} fn Function to execute
|
||||||
|
* @param {(error: unknown) => T | Promise<T>} onError Error handler
|
||||||
|
* @returns {T | Promise<T>}
|
||||||
|
*/
|
||||||
|
export function tryCatch(fn, onError) {
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
return result.catch(onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
return onError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace `{token}` placeholders in a template string.
|
||||||
|
*
|
||||||
|
* Replacement values are inserted verbatim and are NOT reprocessed,
|
||||||
|
* preventing cascading replacements if values contain token patterns.
|
||||||
|
*
|
||||||
|
* @param {string} template The input string containing tokens like `{tokenName}`
|
||||||
|
* @param {Record<string, string>} tokens An object of tokens to replace and the replacement values.
|
||||||
|
* @returns {string} The string with valid tokens replaced
|
||||||
|
*/
|
||||||
|
export function replaceTokens(template, tokens) {
|
||||||
|
return template.replace(/{([^{}]+)}/g, (match, key) => {
|
||||||
|
return key in tokens ? tokens[key] : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue