Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions electron/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use strict';

// Exports tracks (a whole library, an album, a playlist, or a single track)
// to a folder as either untouched copies or ffmpeg transcodes at a chosen
// quality, organized as <dest>/<Artist>/<Album>/<NN - Title>.<ext> — the
// layout portable players and DAPs expect, cover.jpg included per album.

const { spawn } = require('child_process');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');

let ffmpegPath = null;
try {
ffmpegPath = require('ffmpeg-static');
if (ffmpegPath && ffmpegPath.includes('app.asar')) {
ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked');
}
} catch { /* bundled binary missing on this install */ }

const FORMATS = {
copy: { label: 'Keep original (copy)', ext: null },
flac: {
label: 'FLAC (lossless)', ext: 'flac',
args: (ds) => ['-c:a', 'flac', '-compression_level', '8', ...(ds ? ['-ar', '44100', '-sample_fmt', 's16'] : [])],
},
alac: {
label: 'ALAC (lossless, Apple)', ext: 'm4a',
args: (ds) => ['-c:a', 'alac', ...(ds ? ['-ar', '44100', '-sample_fmt', 's16p'] : [])],
},
mp3_320: {
label: 'MP3 320 kbps', ext: 'mp3',
args: (ds) => ['-c:a', 'libmp3lame', '-b:a', '320k', ...(ds ? ['-ar', '44100'] : [])],
},
mp3_v0: {
label: 'MP3 V0 (VBR, ~245 kbps)', ext: 'mp3',
args: (ds) => ['-c:a', 'libmp3lame', '-q:a', '0', ...(ds ? ['-ar', '44100'] : [])],
},
aac_256: {
label: 'AAC 256 kbps', ext: 'm4a',
args: (ds) => ['-c:a', 'aac', '-b:a', '256k', ...(ds ? ['-ar', '44100'] : [])],
},
};

function sanitizeSegment(s) {
const cleaned = String(s || '').trim()
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') // Windows-illegal chars + control chars
.replace(/[.\s]+$/, ''); // trailing dots/spaces (Windows can't create these)
return (cleaned || 'Unknown').slice(0, 120);
}

function buildDestPath(destDir, track, fmt) {
const artist = sanitizeSegment(track.albumArtist || track.artist);
const album = sanitizeSegment(track.album);
const ext = fmt.ext || path.extname(track.path).slice(1).toLowerCase() || 'audio';
const num = track.trackNo ? String(track.trackNo).padStart(2, '0') + ' - ' : '';
return {
albumDir: path.join(destDir, artist, album),
filePath: path.join(destDir, artist, album, `${num}${sanitizeSegment(track.title)}.${ext}`),
};
}

class LibraryExporter {
constructor({ resolveArtPath }) {
this.resolveArtPath = resolveArtPath;
this.cancelled = false;
this.currentChild = null;
}

cancel() {
this.cancelled = true;
try { this.currentChild?.kill('SIGKILL'); } catch { /* already gone */ }
}

_runFfmpeg(args) {
return new Promise((resolve, reject) => {
if (!ffmpegPath) return reject(new Error('Bundled ffmpeg is unavailable on this install'));
const child = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] });
this.currentChild = child;
let stderr = '';
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
child.on('error', reject);
child.on('close', (code) => {
this.currentChild = null;
if (code === 0) resolve();
else if (this.cancelled) reject(new Error('cancelled'));
else reject(new Error(stderr.trim().split('\n').pop() || `ffmpeg exited with code ${code}`));
});
});
}

async _writeAlbumArt(track, albumDir, doneDirs) {
if (doneDirs.has(albumDir) || !track.artUrl) return;
doneDirs.add(albumDir); // mark attempted even on failure — don't retry every track
const src = this.resolveArtPath(track.artUrl);
if (!src) return;
try {
const ext = path.extname(src).toLowerCase() === '.png' ? '.png' : '.jpg';
await fsp.copyFile(src, path.join(albumDir, 'cover' + ext));
} catch { /* non-fatal — the audio file is what matters */ }
}

// opts: { format: 'copy'|'flac'|'alac'|'mp3_320'|'mp3_v0'|'aac_256', downsample: bool }
async run(tracks, destDir, opts, onProgress) {
const fmt = FORMATS[opts.format] || FORMATS.copy;
const doneArtDirs = new Set();
let exported = 0, skipped = 0, failed = 0;
const failures = [];

for (let i = 0; i < tracks.length; i++) {
if (this.cancelled) break;
const track = tracks[i];
onProgress({ done: i, total: tracks.length, file: `${track.artist} — ${track.title}` });
try {
const { albumDir, filePath } = buildDestPath(destDir, track, fmt);
await fsp.mkdir(albumDir, { recursive: true });
await this._writeAlbumArt(track, albumDir, doneArtDirs);

let already = false;
try { already = (await fsp.stat(filePath)).size > 0; } catch { /* doesn't exist yet */ }
if (already) { skipped++; continue; }

if (opts.format === 'copy' || !fmt.args) {
await fsp.copyFile(track.path, filePath);
} else {
await this._runFfmpeg([
'-y', '-v', 'error', '-i', track.path, '-map_metadata', '0', '-vn',
...fmt.args(!!opts.downsample), filePath,
]);
}
exported++;
} catch (err) {
if (this.cancelled) break;
failed++;
failures.push({ title: track.title, error: err.message });
}
}

onProgress({ done: tracks.length, total: tracks.length, file: '' });
return { ok: !this.cancelled, cancelled: this.cancelled, exported, skipped, failed, failures, destDir };
}
}

module.exports = { LibraryExporter, FORMATS };
26 changes: 26 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const ART_CACHE_DIR = () => path.join(app.getPath('userData'), 'art-cache');

let mainWindow = null;
let scanCancelled = false;
let activeExporter = null;

// music-metadata is ESM-only; load it lazily via dynamic import.
let mmPromise = null;
Expand Down Expand Up @@ -539,6 +540,31 @@ function registerIpc() {
activeScan?.worker?.postMessage({ type: 'cancel' });
});

ipcMain.handle('library:choose-export-folder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Export To…',
properties: ['openDirectory', 'createDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});

ipcMain.handle('library:export', async (_e, { trackIds, destDir, format, downsample }) => {
if (activeExporter) return { ok: false, error: 'An export is already running' };
const { LibraryExporter } = require('./export');
const lib = await readJson(LIBRARY_FILE(), { tracks: [] });
const byId = new Map(lib.tracks.map((t) => [t.id, t]));
const tracks = (trackIds || []).map((id) => byId.get(id)).filter(Boolean);
activeExporter = new LibraryExporter({ resolveArtPath: decodeMediaUrl });
try {
return await activeExporter.run(tracks, destDir, { format, downsample }, (p) => {
mainWindow?.webContents.send('export:progress', p);
});
} finally {
activeExporter = null;
}
});
ipcMain.handle('library:cancel-export', () => { activeExporter?.cancel(); });

ipcMain.handle('settings:get', () => readJson(SETTINGS_FILE(), {}));
ipcMain.handle('settings:set', async (_e, settings) => writeJson(SETTINGS_FILE(), settings));

Expand Down
8 changes: 8 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ contextBridge.exposeInMainWorld('auralis', {
ipcRenderer.on('scan:progress', listener);
return () => ipcRenderer.removeListener('scan:progress', listener);
},
chooseExportFolder: () => ipcRenderer.invoke('library:choose-export-folder'),
export: (payload) => ipcRenderer.invoke('library:export', payload),
cancelExport: () => ipcRenderer.invoke('library:cancel-export'),
onExportProgress: (cb) => {
const listener = (_e, data) => cb(data);
ipcRenderer.on('export:progress', listener);
return () => ipcRenderer.removeListener('export:progress', listener);
},
},
settings: {
get: () => ipcRenderer.invoke('settings:get'),
Expand Down
13 changes: 10 additions & 3 deletions electron/scan-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ async function extractTrack(mm, filePath, existingArtByAlbum) {
const albumKey = `${albumArtist}::${album}`.toLowerCase();

let artUrl = existingArtByAlbum.get(albumKey);
if (artUrl === undefined) {
if (!artUrl) {
artUrl = await cacheAlbumArt(c.picture, albumKey);
existingArtByAlbum.set(albumKey, artUrl);
if (artUrl) existingArtByAlbum.set(albumKey, artUrl);
}

return {
Expand Down Expand Up @@ -144,7 +144,7 @@ async function extractTrack(mm, filePath, existingArtByAlbum) {
const file = allFiles[i];
const prev = byPath.get(file);
try {
if (prev && prev.mtime === (await fsp.stat(file)).mtimeMs) {
if (prev && prev.mtime === (await fsp.stat(file)).mtimeMs && prev.artUrl) {
tracks.push(prev);
} else {
tracks.push(await extractTrack(mm, file, artByAlbum));
Expand All @@ -160,6 +160,13 @@ async function extractTrack(mm, filePath, existingArtByAlbum) {
}
}

// A track processed before its album's art was found (e.g. track 1 has no
// embedded art but track 4 does) would otherwise stay stuck without art —
// backfill from the now-complete per-album cache.
for (const t of tracks) {
if (!t.artUrl && artByAlbum.get(t.albumKey)) t.artUrl = artByAlbum.get(t.albumKey);
}

parentPort.postMessage({ type: 'done', tracks });
})().catch((err) => {
parentPort.postMessage({ type: 'error', message: err.message });
Expand Down
24 changes: 20 additions & 4 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1537,8 +1537,8 @@ body.mini { background: transparent; }
}
.modal .pl-choice:hover { background: var(--accent-soft); color: var(--accent); }

/* scan progress strip */
#scan-strip {
/* scan / export progress strip */
#scan-strip, #export-strip {
position: fixed;
bottom: calc(var(--pb-height) + 14px);
left: 50%;
Expand All @@ -1553,13 +1553,29 @@ body.mini { background: transparent; }
align-items: center;
gap: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
max-width: min(70vw, 620px);
}
#scan-strip .spin {
#export-strip .es-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#scan-strip .spin, #export-strip .spin {
flex: none;
width: 14px; height: 14px;
border: 2px solid var(--accent-soft);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#scan-strip .pct { font-family: var(--mono); color: var(--accent); }
#scan-strip .pct, #export-strip .pct { font-family: var(--mono); color: var(--accent); flex: none; }
#export-strip .es-cancel {
flex: none;
color: var(--text-3);
font-size: 11.5px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--line-strong);
}
#export-strip .es-cancel:hover { color: var(--text-1); border-color: var(--text-3); }
Loading
Loading