feat(tag-editor): add batch metadata editor with artwork support (Duplicate) (#2252)

* feat(tag-editor): add batch metadata editor with artwork support

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Chris Scott
2026-07-17 22:17:11 -04:00
committed by GitHub
parent cf4556743d
commit 75bdf60912
47 changed files with 4055 additions and 737 deletions
+1
View File
@@ -4,4 +4,5 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './tag-editor';
import './visualizer';
+116
View File
@@ -0,0 +1,116 @@
import type {
ArtworkOp,
ReadSongMetadataBatchResult,
TagValue,
WriteSongTagsBatchResult,
} from '/@/shared/types/tag-editor';
import { ipcMain, type IpcMainInvokeEvent } from 'electron';
import { readFilesMetadataBatch, readLocalImageFile, writeFilesTags } from './taglib-service';
const sendBatchProgress = (event: IpcMainInvokeEvent, processed: number, total: number) => {
event.sender.send('batch-progress', { processed, total });
};
let activeReadController: AbortController | null = null;
ipcMain.handle('cancel-read-song-metadata', () => {
activeReadController?.abort();
activeReadController = null;
});
ipcMain.handle(
'read-song-metadata-batch',
async (event, filePaths: string[]): Promise<ReadSongMetadataBatchResult> => {
activeReadController?.abort();
activeReadController = new AbortController();
const { signal } = activeReadController;
try {
const result = await readFilesMetadataBatch(
filePaths,
(processed, total) => sendBatchProgress(event, processed, total),
signal,
);
if (signal.aborted) {
return { artworkKind: 'none', success: false };
}
if (!result.success) {
return {
artworkKind: 'none',
error: result.failedFiles[0]?.error ?? 'No readable audio files in selection',
failedFiles: result.failedFiles,
readCount: result.readCount,
success: false,
totalCount: result.totalCount,
};
}
return {
artworkKind: result.artworkKind,
failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined,
fileArtwork: result.fileArtwork,
fileTags: result.fileTags,
multiValueKeys: result.multiValueKeys,
readCount: result.readCount,
success: true,
tagSummary: result.tagSummary,
totalCount: result.totalCount,
...(result.artworkData
? {
artworkData: result.artworkData,
artworkMimeType: result.artworkMimeType,
}
: {}),
};
} catch (err) {
return { artworkKind: 'none', error: String(err), success: false };
} finally {
activeReadController = null;
}
},
);
ipcMain.handle(
'write-song-tags-batch',
async (
event,
filePaths: string[],
edits: Record<string, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
): Promise<WriteSongTagsBatchResult> => {
try {
const result = await writeFilesTags(
filePaths,
edits,
removed,
artworkOp,
(processed, total) => sendBatchProgress(event, processed, total),
);
if (!result.success) {
return {
error: result.failedFiles[0]?.error,
failedFiles: result.failedFiles,
success: false,
};
}
return { success: true };
} catch (err) {
return { error: String(err), success: false };
}
},
);
ipcMain.handle('read-local-image', async (_event, filePath: string) => {
try {
const { data, mimeType } = await readLocalImageFile(filePath);
return { data, mimeType, success: true };
} catch (err) {
return { error: String(err), success: false };
}
});
@@ -0,0 +1,325 @@
import type { ArtworkKind, ArtworkOp, BatchFileError, TagValue } from '/@/shared/types/tag-editor';
import { constants, promises as fsPromises } from 'fs';
import { PROPERTIES, TagLib } from 'taglib-wasm';
import { getImageMimeTypeFromPath } from '/@/shared/utils/image-mime';
let _taglib: null | TagLib = null;
const getTagLib = async (): Promise<TagLib> => {
if (!_taglib) _taglib = await TagLib.initialize();
return _taglib;
};
const BATCH_CONCURRENCY = 8;
/**
* Known PROPERTIES stay camelCase for the taglib-wasm JS API. Custom tags use
* TagLib's ALL_CAPS wire form so read/write/settings keys stay consistent.
*/
const canonicalizePropertyKey = (key: string): string => {
if (key in PROPERTIES) return key;
return key.toUpperCase();
};
/** Returns an error entry for each path that is missing or not writable by the current process. */
export async function checkPathsWritable(paths: string[]): Promise<BatchFileError[]> {
const failed: BatchFileError[] = [];
await Promise.all(
paths.map(async (filePath) => {
try {
await fsPromises.access(filePath, constants.F_OK | constants.W_OK);
} catch (err) {
failed.push({
code: (err as NodeJS.ErrnoException).code,
error: err instanceof Error ? err.message : String(err),
path: filePath,
});
}
}),
);
return failed;
}
/** Reads an image file and returns it as base64 + MIME type for IPC transport to the renderer. */
export async function readLocalImageFile(filePath: string) {
const buf = await fsPromises.readFile(filePath);
return {
data: buf.toString('base64'),
mimeType: getImageMimeTypeFromPath(filePath),
};
}
/** Normalizes a taglib-wasm PropertyMap while preserving true multivalue arrays. */
function normalizeProperties(
props: Record<string, string[] | undefined>,
): Record<string, TagValue> {
const normalized: Record<string, TagValue> = {};
for (const [key, values] of Object.entries(props)) {
if (values && values.length > 0 && values[0] !== '') {
const canonicalKey = canonicalizePropertyKey(key);
normalized[canonicalKey] = values.length === 1 ? values[0] : [...values];
}
}
const coveredByUpperCase = new Set(
Object.keys(normalized)
.filter((k) => k !== k.toUpperCase())
.map((k) => k.toUpperCase()),
);
for (const key of Object.keys(normalized)) {
if (key === key.toUpperCase() && coveredByUpperCase.has(key)) {
delete normalized[key];
}
}
return normalized;
}
const tagValuesEqual = (a: TagValue, b: TagValue | undefined): boolean => {
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((value, index) => value === b[index])
);
}
return a === b;
};
/** Runs `fn` over `items` with at most `concurrency` tasks in flight at once. Stops early if `signal` is aborted. */
const mapWithConcurrency = async <T>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<void>,
signal?: AbortSignal,
): Promise<void> => {
let nextIndex = 0;
const worker = async () => {
while (nextIndex < items.length) {
if (signal?.aborted) return;
const index = nextIndex;
nextIndex += 1;
await fn(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
};
/**
* Reads all tags and artwork from a batch of audio files via taglib-wasm.
* Returns every tag present on disk — not limited to any known-field list.
* Values that differ across files are merged to `null` and shown as "(Multiple Values)" in the editor.
* Artwork bytes are captured from the first successful file; subsequent files only contribute their size for comparison.
*/
export async function readFilesMetadataBatch(
filePaths: string[],
onProgress?: (processed: number, total: number) => void,
signal?: AbortSignal,
): Promise<{
artworkData?: string;
artworkKind: ArtworkKind;
artworkMimeType?: string;
failedFiles: BatchFileError[];
fileArtwork: Record<string, { data: string; mimeType: string }>;
fileTags: Record<string, Record<string, TagValue>>;
multiValueKeys: string[];
readCount: number;
success: boolean;
tagSummary: Record<string, null | TagValue>;
totalCount: number;
}> {
const totalCount = filePaths.length;
const failedFiles: BatchFileError[] = [];
const multiValueKeys = new Set<string>();
const tagSummary: Record<string, null | TagValue> = {};
const fileTags: Record<string, Record<string, TagValue>> = {};
const fileArtwork: Record<string, { data: string; mimeType: string }> = {};
let artworkKind = 'none' as ArtworkKind;
let artworkByteSize: number | undefined;
let artworkData: string | undefined;
let artworkMimeType: string | undefined;
let readCount = 0;
let processed = 0;
const taglib = await getTagLib();
await mapWithConcurrency(
filePaths,
BATCH_CONCURRENCY,
async (filePath) => {
try {
await fsPromises.access(filePath, constants.F_OK);
const file = await taglib.open(filePath);
try {
const rawProperties = file.properties();
const embeddedLyrics = file.getLyrics();
if (embeddedLyrics.length > 0) {
rawProperties.lyrics = [
embeddedLyrics.map(({ text }) => text).join('\n\n'),
];
}
const normalized = normalizeProperties(rawProperties);
fileTags[filePath] = normalized;
for (const [key, value] of Object.entries(normalized)) {
if (Array.isArray(value)) multiValueKeys.add(key);
}
const pictures = file.getPictures();
const frontCover = pictures.find((p) => p.type === 'FrontCover') ?? pictures[0];
const hasCoverArt = frontCover !== undefined;
const picSize = hasCoverArt ? frontCover.data.length : undefined;
if (frontCover) {
fileArtwork[filePath] = {
data: Buffer.from(frontCover.data).toString('base64'),
mimeType: frontCover.mimeType,
};
}
if (readCount === 0) {
Object.assign(tagSummary, normalized);
artworkKind = hasCoverArt ? 'common' : 'none';
artworkByteSize = picSize;
if (frontCover) {
artworkData = fileArtwork[filePath].data;
artworkMimeType = frontCover.mimeType;
}
} else {
for (const k of Object.keys(tagSummary)) {
if (
tagSummary[k] !== null &&
!tagValuesEqual(tagSummary[k], normalized[k])
)
tagSummary[k] = null;
}
for (const k of Object.keys(normalized)) {
if (!(k in tagSummary)) tagSummary[k] = null;
}
if (artworkKind !== 'mixed') {
if (
hasCoverArt !== (artworkKind === 'common') ||
picSize !== artworkByteSize
) {
artworkKind = 'mixed';
}
}
}
readCount += 1;
} finally {
file.dispose();
}
} catch (err) {
failedFiles.push({
code: (err as NodeJS.ErrnoException).code,
error: err instanceof Error ? err.message : String(err),
path: filePath,
});
}
processed += 1;
onProgress?.(processed, totalCount);
},
signal,
);
return {
artworkKind,
failedFiles,
fileArtwork,
fileTags,
multiValueKeys: [...multiValueKeys],
readCount,
success: readCount > 0,
tagSummary,
totalCount,
...(artworkData ? { artworkData, artworkMimeType } : {}),
};
}
/**
* Writes tag edits and/or artwork to a batch of audio files in-place via taglib-wasm (WASI).
* Only the fields present in `edits`/`removed` are touched — all other existing tags are preserved.
* Array values are written as true multivalue properties; scalar values remain single-valued.
* Lyrics use TagLib's dedicated unsynchronized-lyrics API.
*/
export async function writeFilesTags(
filePaths: string[],
edits: Record<string, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
onProgress?: (processed: number, total: number) => void,
): Promise<{ failedFiles: BatchFileError[]; success: boolean }> {
const notWritable = await checkPathsWritable(filePaths);
const notWritableSet = new Set(notWritable.map((f) => f.path));
const writablePaths = filePaths.filter((p) => !notWritableSet.has(p));
const failedFiles: BatchFileError[] = [...notWritable];
if (writablePaths.length === 0) {
return { failedFiles, success: false };
}
const hasEdits = Object.keys(edits).length > 0 || removed.length > 0;
if (!hasEdits && !artworkOp) {
return { failedFiles, success: failedFiles.length === 0 };
}
const taglib = await getTagLib();
let processed = 0;
const total = writablePaths.length;
await mapWithConcurrency(writablePaths, BATCH_CONCURRENCY, async (path) => {
try {
await taglib.edit(path, (file) => {
const propertyEdits = Object.entries(edits).filter(([key]) => key !== 'lyrics');
const propertyRemovals = removed.filter((key) => key !== 'lyrics');
if (propertyEdits.length > 0 || propertyRemovals.length > 0) {
const properties = file.properties();
for (const [key, value] of propertyEdits) {
properties[canonicalizePropertyKey(key)] = Array.isArray(value)
? value
: [value];
}
for (const key of propertyRemovals) {
delete properties[canonicalizePropertyKey(key)];
}
file.setProperties(properties);
}
if (removed.includes('lyrics')) {
file.setLyrics([]);
} else if (edits.lyrics !== undefined) {
file.setLyrics([
{
text: Array.isArray(edits.lyrics)
? edits.lyrics.join('\n\n')
: edits.lyrics,
},
]);
}
if (artworkOp?.type === 'clear') {
file.removePictures();
} else if (artworkOp?.type === 'set') {
file.removePictures();
file.addPicture({
data: artworkOp.bytes,
mimeType: artworkOp.mimeType,
type: 'FrontCover',
});
}
});
} catch (err) {
failedFiles.push({ error: String(err), path });
}
processed += 1;
onProgress?.(processed, total);
});
return { failedFiles, success: failedFiles.length === 0 };
}