mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 05:12:57 +02:00
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:
@@ -4,4 +4,5 @@ import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
import './tag-editor';
|
||||
import './visualizer';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user