mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-23 10:56:28 +02:00
feat(tag-editor): add batch metadata editor with artwork support
New metadata editor accessible via "Edit Metadata" in the song, album, and playlist song context menus. Supports editing one or more files at once. Tags: - Displays all metadata fields for the selected files - Fields differing across files are shown as "(Multiple Values)" - Fields can be added from a dropdown or removed individually - Only modified fields are written back to disk Artwork: - Displays shared cover art across all selected files - Supports setting new artwork via file picker or drag-and-drop - Supports clearing existing artwork - Shows a placeholder when selected files have different covers Reading and writing: - Tags are read via music-metadata, preserving full ISO date strings - Writes use taglib-wasm WASI in-place editing via setProperty, only touching fields that were explicitly changed - Writability of all files is checked before any writes begin - Partial read failures show a warning but still allow editing Other: - Per-file batch progress is reported during reads and writes - In-flight reads are cancelled when the modal is closed - An optional server library rescan can be triggered after saving
This commit is contained in:
@@ -118,6 +118,7 @@
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.40.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"music-metadata": "^11.12.3",
|
||||
"nanoid": "^3.3.12",
|
||||
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
@@ -137,6 +138,7 @@
|
||||
"react-window-v2": "npm:react-window@^2.2.7",
|
||||
"semver": "^7.8.2",
|
||||
"string-to-color": "^2.2.2",
|
||||
"taglib-wasm": "^1.3.0",
|
||||
"wavesurfer.js": "^7.12.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^3.25.76",
|
||||
|
||||
Generated
+2248
-2190
File diff suppressed because it is too large
Load Diff
@@ -538,7 +538,8 @@
|
||||
"goTo": "Go to",
|
||||
"goToAlbum": "Go to $t(entity.album, {\"count\": 1})",
|
||||
"goToAlbumArtist": "Go to $t(entity.albumArtist, {\"count\": 1})",
|
||||
"showDetails": "Get info"
|
||||
"showDetails": "Get info",
|
||||
"editMetadata": "Edit Metadata"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -593,7 +594,22 @@
|
||||
"itemDetail": {
|
||||
"copyPath": "Copy path to clipboard",
|
||||
"copiedPath": "Path copied successfully",
|
||||
"openFile": "Show track in file manager"
|
||||
"openFile": "Show track in file manager",
|
||||
"tagsSaved": "Tags saved successfully",
|
||||
"rescanStarted": "Library rescan started",
|
||||
"fileNotWritable": "File is not accessible or not writable",
|
||||
"triggerRescan": "Trigger library rescan after saving",
|
||||
"addField": "Add field…",
|
||||
"emptyFields": "Fields cannot be empty",
|
||||
"tagsTab": "Tags",
|
||||
"artworkTab": "Artwork",
|
||||
"removeArtwork": "Remove Artwork",
|
||||
"noArtwork": "No Artwork",
|
||||
"multipleValues": "(Multiple Values)",
|
||||
"multipleArtworks": "Multiple Artworks",
|
||||
"noLocalSongs": "No songs with local file paths found",
|
||||
"readPartialFailure": "Could not read metadata from {{count}} of {{total}} file(s)",
|
||||
"writePartialFailure": "Failed to save {{count}} file(s)"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "Reordering only enabled when sorting by ID"
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
success: false,
|
||||
readCount: result.readCount,
|
||||
totalCount: result.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
artworkKind: result.artworkKind,
|
||||
failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined,
|
||||
success: true,
|
||||
readCount: result.readCount,
|
||||
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, string>,
|
||||
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,289 @@
|
||||
import type { ArtworkKind, ArtworkOp, BatchFileError } from '/@/shared/types/tag-editor';
|
||||
import type { ICommonTagsResult } from 'music-metadata';
|
||||
|
||||
import { EDITOR_FIELD_KEYS } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { constants, promises as fsPromises } from 'fs';
|
||||
import * as mm from 'music-metadata';
|
||||
import { 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;
|
||||
|
||||
/** 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),
|
||||
};
|
||||
}
|
||||
|
||||
const pickFrontCover = <T extends { type?: string }>(pictures: T[]): T | undefined =>
|
||||
pictures.find((p) => p.type === 'Front Cover') ?? pictures[0];
|
||||
|
||||
type TagAccessor = (c: ICommonTagsResult) => null | number | string | undefined;
|
||||
|
||||
// Only fields where the editor key differs from the music-metadata key.
|
||||
const MM_RENAMES: Partial<Record<string, keyof ICommonTagsResult>> = {
|
||||
acoustidId: 'acoustid_id',
|
||||
albumArtist: 'albumartist',
|
||||
albumArtistSort: 'albumartistsort',
|
||||
albumSort: 'albumsort',
|
||||
artistSort: 'artistsort',
|
||||
catalogNumber: 'catalognumber',
|
||||
composerSort: 'composersort',
|
||||
musicbrainzArtistId: 'musicbrainz_artistid',
|
||||
musicbrainzReleaseArtistId: 'musicbrainz_albumartistid',
|
||||
musicbrainzReleaseGroupId: 'musicbrainz_releasegroupid',
|
||||
musicbrainzReleaseId: 'musicbrainz_albumid',
|
||||
musicbrainzReleaseTrackId: 'musicbrainz_trackid',
|
||||
musicbrainzTrackId: 'musicbrainz_recordingid',
|
||||
musicbrainzWorkId: 'musicbrainz_workid',
|
||||
originalAlbum: 'originalalbum',
|
||||
originalArtist: 'originalartist',
|
||||
originalDate: 'originaldate',
|
||||
remixedBy: 'remixer',
|
||||
titleSort: 'titlesort',
|
||||
};
|
||||
|
||||
// Only fields that need sub-property access or live on a nested object.
|
||||
const MM_CUSTOM: Partial<Record<string, TagAccessor>> = {
|
||||
comment: (c) => c.comment?.[0]?.text,
|
||||
discNumber: (c) => c.disk.no,
|
||||
lyrics: (c) => c.lyrics?.[0]?.text,
|
||||
totalDiscs: (c) => c.disk.of,
|
||||
totalTracks: (c) => c.track.of,
|
||||
trackNumber: (c) => c.track.no,
|
||||
};
|
||||
|
||||
/** Maps a music-metadata `ICommonTagsResult` to the flat camelCase key map used by the editor. */
|
||||
export function flattenMusicMetadata(common: ICommonTagsResult): Record<string, string> {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const key of EDITOR_FIELD_KEYS) {
|
||||
const custom = MM_CUSTOM[key];
|
||||
const raw: unknown = custom
|
||||
? custom(common)
|
||||
: common[(MM_RENAMES[key] ?? key) as keyof ICommonTagsResult];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (value != null && value !== '') flat[key] = String(value);
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** 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({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** 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 tags and artwork from a batch of audio files via music-metadata (which preserves full ISO dates).
|
||||
* Values that differ across files are merged to `null` and shown as "(Multiple Values)" in the editor.
|
||||
* Artwork bytes are only loaded for one representative file, after confirming all files share the same cover.
|
||||
*/
|
||||
export async function readFilesMetadataBatch(
|
||||
filePaths: string[],
|
||||
onProgress?: (processed: number, total: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
artworkData?: string;
|
||||
artworkKind: ArtworkKind;
|
||||
artworkMimeType?: string;
|
||||
failedFiles: BatchFileError[];
|
||||
success: boolean;
|
||||
readCount: number;
|
||||
tagSummary: Record<string, null | string>;
|
||||
totalCount: number;
|
||||
}> {
|
||||
const totalCount = filePaths.length;
|
||||
const failedFiles: BatchFileError[] = [];
|
||||
|
||||
// Merged incrementally; safe because JS is single-threaded between awaits.
|
||||
const tagSummary: Record<string, null | string> = {};
|
||||
let artworkKind = 'none' as ArtworkKind;
|
||||
let artworkByteSize: number | undefined;
|
||||
let firstSuccessPath: string | undefined;
|
||||
let readCount = 0;
|
||||
let processed = 0;
|
||||
|
||||
await mapWithConcurrency(
|
||||
filePaths,
|
||||
BATCH_CONCURRENCY,
|
||||
async (filePath) => {
|
||||
try {
|
||||
const { common } = await mm.parseFile(filePath);
|
||||
const flat = flattenMusicMetadata(common);
|
||||
const hasCoverArt = (common.picture?.length ?? 0) > 0;
|
||||
// Only access artwork bytes if we still need them for comparison.
|
||||
const picSize =
|
||||
artworkKind !== 'mixed' && hasCoverArt
|
||||
? pickFrontCover(common.picture!)?.data.length
|
||||
: undefined;
|
||||
// common.picture's Uint8Array is not stored anywhere — eligible for GC here.
|
||||
|
||||
if (readCount === 0) {
|
||||
// First success: seed the summary.
|
||||
Object.assign(tagSummary, flat);
|
||||
firstSuccessPath = filePath;
|
||||
artworkKind = hasCoverArt ? 'common' : 'none';
|
||||
artworkByteSize = picSize;
|
||||
} else {
|
||||
// Merge tags: keys already null stay null; new divergences become null.
|
||||
for (const k of Object.keys(tagSummary)) {
|
||||
if (tagSummary[k] !== null && flat[k] !== tagSummary[k])
|
||||
tagSummary[k] = null;
|
||||
}
|
||||
for (const k of Object.keys(flat)) {
|
||||
if (!(k in tagSummary)) tagSummary[k] = null;
|
||||
}
|
||||
// Merge artwork.
|
||||
if (artworkKind !== 'mixed') {
|
||||
if (
|
||||
hasCoverArt !== (artworkKind === 'common') ||
|
||||
picSize !== artworkByteSize
|
||||
) {
|
||||
artworkKind = 'mixed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readCount += 1;
|
||||
} catch (err) {
|
||||
failedFiles.push({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
processed += 1;
|
||||
onProgress?.(processed, totalCount);
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
let artworkData: string | undefined;
|
||||
let artworkMimeType: string | undefined;
|
||||
|
||||
// Re-read artwork from one file only after confirming all files share the same cover.
|
||||
if (artworkKind === 'common' && firstSuccessPath) {
|
||||
const { common } = await mm.parseFile(firstSuccessPath);
|
||||
const pic = pickFrontCover(common.picture ?? []);
|
||||
if (pic) {
|
||||
artworkData = Buffer.from(pic.data).toString('base64');
|
||||
artworkMimeType = pic.format;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
artworkKind,
|
||||
failedFiles,
|
||||
success: readCount > 0,
|
||||
readCount,
|
||||
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.
|
||||
* Fields in `removed` are written as empty strings, which clears them in TagLib.
|
||||
*/
|
||||
export async function writeFilesTags(
|
||||
filePaths: string[],
|
||||
edits: Record<string, string>,
|
||||
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 mergedEdits: Record<string, string> = { ...edits };
|
||||
for (const key of removed) {
|
||||
mergedEdits[key] = '';
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(mergedEdits).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) => {
|
||||
for (const [k, v] of Object.entries(mergedEdits)) {
|
||||
file.setProperty(k.toUpperCase(), v);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log/main';
|
||||
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
||||
import { access, constants } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path, { join } from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
@@ -39,6 +40,10 @@ import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
|
||||
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
||||
|
||||
if (isWindows() && typeof (global as any).require !== 'function') {
|
||||
(global as any).require = createRequire(__filename);
|
||||
}
|
||||
|
||||
const ALPHA_UPDATER_CONFIG: {
|
||||
bucket: string;
|
||||
channel: string;
|
||||
|
||||
+44
-1
@@ -1,4 +1,12 @@
|
||||
import { ipcRenderer, webFrame } from 'electron';
|
||||
import { type IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
|
||||
|
||||
import type {
|
||||
ArtworkOp,
|
||||
BatchProgress,
|
||||
ReadLocalImageResult,
|
||||
ReadSongMetadataBatchResult,
|
||||
WriteSongTagsBatchResult,
|
||||
} from '../shared/types/tag-editor';
|
||||
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
|
||||
|
||||
@@ -6,6 +14,35 @@ const openItem = async (path: string) => {
|
||||
return ipcRenderer.invoke('open-item', path);
|
||||
};
|
||||
|
||||
const cancelReadSongMetadata = (): void => {
|
||||
ipcRenderer.invoke('cancel-read-song-metadata');
|
||||
};
|
||||
|
||||
const readSongMetadataBatch = (filePaths: string[]): Promise<ReadSongMetadataBatchResult> => {
|
||||
return ipcRenderer.invoke('read-song-metadata-batch', filePaths);
|
||||
};
|
||||
|
||||
const writeSongTagsBatch = (
|
||||
filePaths: string[],
|
||||
edits: Record<string, string>,
|
||||
removed: string[],
|
||||
artworkOp?: ArtworkOp,
|
||||
): Promise<WriteSongTagsBatchResult> => {
|
||||
return ipcRenderer.invoke('write-song-tags-batch', filePaths, edits, removed, artworkOp);
|
||||
};
|
||||
|
||||
const onBatchProgress = (cb: (event: IpcRendererEvent, data: BatchProgress) => void) => {
|
||||
ipcRenderer.on('batch-progress', cb);
|
||||
};
|
||||
|
||||
const offBatchProgress = (cb: (event: IpcRendererEvent, data: BatchProgress) => void) => {
|
||||
ipcRenderer.removeListener('batch-progress', cb);
|
||||
};
|
||||
|
||||
const readLocalImage = (filePath: string): Promise<ReadLocalImageResult> => {
|
||||
return ipcRenderer.invoke('read-local-image', filePath);
|
||||
};
|
||||
|
||||
const openApplicationDirectory = async () => {
|
||||
return ipcRenderer.invoke('open-application-directory');
|
||||
};
|
||||
@@ -117,6 +154,7 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
cancelReadSongMetadata,
|
||||
checkForUpdates,
|
||||
customCssUpdatedListener,
|
||||
disableAutoUpdates,
|
||||
@@ -127,10 +165,14 @@ export const utils = {
|
||||
isMacOS,
|
||||
isWindows,
|
||||
mainMessageListener,
|
||||
offBatchProgress,
|
||||
onBatchProgress,
|
||||
openApplicationDirectory,
|
||||
openCustomCssFolder,
|
||||
openItem,
|
||||
playerErrorListener,
|
||||
readLocalImage,
|
||||
readSongMetadataBatch,
|
||||
rendererOpenCommandPalette,
|
||||
rendererOpenManageServers,
|
||||
rendererOpenReleaseNotes,
|
||||
@@ -143,6 +185,7 @@ export const utils = {
|
||||
setInputFocused,
|
||||
startPowerSaveBlocker,
|
||||
stopPowerSaveBlocker,
|
||||
writeSongTagsBatch,
|
||||
};
|
||||
|
||||
export type Utils = typeof utils;
|
||||
|
||||
@@ -924,4 +924,16 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
startScan(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${i18n.t('error.apiRouteError')}: startScan`);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'startScan',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -392,6 +392,16 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'Library/Refresh',
|
||||
query: z.object({}),
|
||||
responses: {
|
||||
204: z.null(),
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -1996,6 +1996,20 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
'Failed to upload playlist image',
|
||||
);
|
||||
},
|
||||
startScan: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).startScan({
|
||||
body: null,
|
||||
query: {},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to start scan');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
function getLibraryId(musicFolderId?: string | string[]) {
|
||||
|
||||
@@ -1411,4 +1411,5 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
return res.data?.status === 'ok';
|
||||
},
|
||||
startScan: SubsonicController.startScan,
|
||||
};
|
||||
|
||||
@@ -353,6 +353,22 @@ export const contract = c.router({
|
||||
200: ssType._response.baseResponse,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
method: 'GET',
|
||||
path: 'startScan.view',
|
||||
query: ssType._parameters.startScan,
|
||||
responses: {
|
||||
200: ssType._response.startScan,
|
||||
},
|
||||
},
|
||||
getScanStatus: {
|
||||
method: 'GET',
|
||||
path: 'getScanStatus.view',
|
||||
query: ssType._parameters.getScanStatus,
|
||||
responses: {
|
||||
200: ssType._response.getScanStatus,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -2497,6 +2497,17 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
startScan: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).startScan({ query: {} });
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to start scan');
|
||||
}
|
||||
|
||||
return res.body.scanStatus;
|
||||
},
|
||||
};
|
||||
|
||||
function getLibraryId(musicFolderId?: string | string[]) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { SongEditModal } from '/@/renderer/features/tag-editor/components/song-edit-modal';
|
||||
|
||||
interface EditMetadataActionProps {
|
||||
albumIds?: string[];
|
||||
songs?: Song[];
|
||||
}
|
||||
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
const getAlbumSongs = async (albumIds: string[], serverId: string): Promise<Song[]> => {
|
||||
const albumDetails = await Promise.all(
|
||||
albumIds.map((id) =>
|
||||
controller.getAlbumDetail({
|
||||
apiClientProps: { serverId },
|
||||
query: { id },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return albumDetails.flatMap((album) => album?.songs ?? []).filter((s) => s.path);
|
||||
};
|
||||
|
||||
export const EditMetadataAction = ({ albumIds, songs: songItems }: EditMetadataActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const songs = songItems?.filter((s) => s.path) ?? [];
|
||||
const count = albumIds?.length ?? songs.length;
|
||||
|
||||
const onSelect = useCallback(async () => {
|
||||
let resolvedSongs: Song[];
|
||||
|
||||
if (albumIds) {
|
||||
resolvedSongs = server?.id ? await getAlbumSongs(albumIds, server.id) : [];
|
||||
} else {
|
||||
resolvedSongs = songs;
|
||||
}
|
||||
|
||||
const trackCount = resolvedSongs.length;
|
||||
openModal({
|
||||
children: <SongEditModal songs={resolvedSongs} />,
|
||||
size: 'xl',
|
||||
styles: { body: { paddingBottom: 'var(--theme-spacing-xl)' } },
|
||||
title:
|
||||
trackCount > 1
|
||||
? `${t('page.contextMenu.editMetadata')} (${trackCount} ${t('common.tracks', 'tracks')})`
|
||||
: t('page.contextMenu.editMetadata'),
|
||||
});
|
||||
}, [albumIds, server, songs, t]);
|
||||
|
||||
if (!utils) return null;
|
||||
|
||||
return (
|
||||
<ContextMenu.Item disabled={count === 0} leftIcon="edit" onSelect={onSelect}>
|
||||
{t('page.contextMenu.editMetadata')}
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
@@ -41,6 +42,7 @@ export const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => {
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction albumIds={ids} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
@@ -46,6 +47,7 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
@@ -43,6 +44,7 @@ export const SongContextMenu = ({ items, type }: SongContextMenuProps) => {
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.artworkBox {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--mantine-color-dark-6);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.artworkImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
height: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
font-size: 5cqw;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
color: var(--theme-colors-state-error);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
import styles from './artwork-panel.module.css';
|
||||
|
||||
interface ArtworkPanelProps {
|
||||
artworkDisplayUrl: null | string;
|
||||
artworkIsMixed: boolean;
|
||||
multipleArtworksLabel: string;
|
||||
noArtworkLabel: string;
|
||||
onApplyBytes: (bytes: Uint8Array, mimeType: string) => void;
|
||||
onBrowse: () => void;
|
||||
onRemove: () => void;
|
||||
removeArtworkLabel: string;
|
||||
showRemoveButton: boolean;
|
||||
}
|
||||
|
||||
export const ArtworkPanel = ({
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
multipleArtworksLabel,
|
||||
noArtworkLabel,
|
||||
onApplyBytes,
|
||||
onBrowse,
|
||||
onRemove,
|
||||
removeArtworkLabel,
|
||||
showRemoveButton,
|
||||
}: ArtworkPanelProps) => (
|
||||
<Stack align="center" gap="md" pt="md">
|
||||
<DragDropZone
|
||||
className={styles.artworkBox}
|
||||
mode="file"
|
||||
onClick={onBrowse}
|
||||
onFileSelected={async (file) => {
|
||||
const buf = await file.arrayBuffer();
|
||||
onApplyBytes(new Uint8Array(buf), file.type);
|
||||
}}
|
||||
>
|
||||
{artworkDisplayUrl ? (
|
||||
<img alt="Cover art" className={styles.artworkImage} src={artworkDisplayUrl} />
|
||||
) : (
|
||||
<Stack align="center" className={styles.placeholder} justify="center">
|
||||
<Text className={styles.placeholderText}>
|
||||
{artworkIsMixed ? multipleArtworksLabel : noArtworkLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</DragDropZone>
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
className={styles.removeButton}
|
||||
onClick={onRemove}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{removeArtworkLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
.tabLabel {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tableScroller {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--theme-spacing-sm);
|
||||
padding-top: var(--theme-spacing-sm);
|
||||
padding-bottom: var(--theme-spacing-sm);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
width: 1%;
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
font-weight: 500;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './song-edit-modal.module.css';
|
||||
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { Tabs } from '/@/shared/components/tabs/tabs';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
||||
import { ArtworkPanel } from './artwork-panel';
|
||||
import { TagFieldRow } from './tag-field-row';
|
||||
|
||||
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Opens the metadata editor modal that displays a table of editable fields for the songs, as well as an artwork panel for changing the songs' artwork
|
||||
const editor = useMetadataEditor({
|
||||
browser: window.api.browser,
|
||||
songs,
|
||||
utils: window.api.utils,
|
||||
});
|
||||
|
||||
// After adding the field, scrolls to and focuses the new field's row in the table.
|
||||
const handleAddField = (key: null | string) => {
|
||||
editor.handleAddField(key);
|
||||
if (!key) return;
|
||||
|
||||
// Scroll to focus on the newly added field
|
||||
requestAnimationFrame(() => {
|
||||
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-field-key="${key}"]`,
|
||||
);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// While loading, shows a spinner
|
||||
if (editor.isLoading) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="xl">
|
||||
<Spinner />
|
||||
{editor.loadProgress && editor.loadProgress.total > 1 && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{editor.loadProgress.processed} / {editor.loadProgress.total}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// If there was an error loading the metadata, shows the error message
|
||||
if (editor.error) {
|
||||
return (
|
||||
<Stack p="md">
|
||||
<Text c="red">{editor.error}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Tabs defaultValue="tags" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab className={styles.tabLabel} value="tags">
|
||||
{t('page.itemDetail.tagsTab', 'Tags')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab className={styles.tabLabel} value="artwork">
|
||||
{t('page.itemDetail.artworkTab', 'Artwork')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="tags">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{editor.readWarning && (
|
||||
<Text c="orange" size="sm">
|
||||
{editor.readWarning}
|
||||
</Text>
|
||||
)}
|
||||
{editor.availableToAdd.length > 0 && (
|
||||
<Select
|
||||
clearable
|
||||
data={editor.availableToAdd}
|
||||
onChange={handleAddField}
|
||||
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
||||
value={null}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.tableScroller} ref={tableContainerRef}>
|
||||
<Table
|
||||
classNames={{ td: styles.tableCell, th: styles.tableHeader }}
|
||||
highlightOnHover={false}
|
||||
withRowBorders
|
||||
>
|
||||
<Table.Tbody>
|
||||
{editor.sortedFieldEntries.map(([key, value]) => (
|
||||
<TagFieldRow
|
||||
isMixed={editor.mixedKeys.has(key)}
|
||||
key={key}
|
||||
meta={editor.getFieldMeta(key)}
|
||||
mixedPlaceholder={
|
||||
editor.mixedKeys.has(key)
|
||||
? editor.mixedPlaceholder
|
||||
: undefined
|
||||
}
|
||||
onChange={(v) => editor.handleFieldChange(key, v)}
|
||||
onRemove={() => editor.handleRemoveField(key)}
|
||||
tagKey={key}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="artwork">
|
||||
<ArtworkPanel
|
||||
artworkDisplayUrl={editor.artworkDisplayUrl}
|
||||
artworkIsMixed={editor.artworkIsMixed}
|
||||
multipleArtworksLabel={t(
|
||||
'page.itemDetail.multipleArtworks',
|
||||
'Multiple Artworks',
|
||||
)}
|
||||
noArtworkLabel={t('page.itemDetail.noArtwork', 'No Artwork')}
|
||||
onApplyBytes={editor.applyArtworkBytes}
|
||||
onBrowse={editor.handleChangeArtwork}
|
||||
onRemove={editor.handleRemoveArtwork}
|
||||
removeArtworkLabel={t('page.itemDetail.removeArtwork', 'Remove Artwork')}
|
||||
showRemoveButton={editor.showRemoveArtworkButton}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Checkbox
|
||||
checked={editor.rescan}
|
||||
label={t('page.itemDetail.triggerRescan')}
|
||||
onChange={(e) => editor.setRescan(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => closeAllModals()} variant="subtle">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={editor.isSaving} onClick={editor.handleSave} variant="filled">
|
||||
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
|
||||
? `${t('common.save', 'Save')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
|
||||
: t('common.save', 'Save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.removeCell {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { RiCloseLine } from 'react-icons/ri';
|
||||
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
|
||||
import type { KnownTag } from '../utils/known-tags';
|
||||
|
||||
import styles from './tag-field-row.module.css';
|
||||
|
||||
interface TagFieldRowProps {
|
||||
isMixed: boolean;
|
||||
meta: KnownTag;
|
||||
mixedPlaceholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
tagKey: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const TagFieldRow = ({
|
||||
isMixed,
|
||||
meta,
|
||||
mixedPlaceholder,
|
||||
onChange,
|
||||
onRemove,
|
||||
tagKey,
|
||||
value,
|
||||
}: TagFieldRowProps) => (
|
||||
<Table.Tr data-field-key={tagKey} key={tagKey}>
|
||||
<Table.Th>{meta.label}</Table.Th>
|
||||
<Table.Td>
|
||||
{meta.type === 'textarea' ? (
|
||||
<Textarea
|
||||
autosize
|
||||
maxRows={6}
|
||||
minRows={2}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={value}
|
||||
/>
|
||||
) : meta.type === 'number' ? (
|
||||
<NumberInput
|
||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={isMixed || value === '' ? undefined : Number(value)}
|
||||
/>
|
||||
) : meta.type === 'boolean' ? (
|
||||
<Checkbox
|
||||
checked={!isMixed && value === '1'}
|
||||
indeterminate={isMixed}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td className={styles.removeCell}>
|
||||
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
|
||||
<RiCloseLine size={16} />
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
ArtworkKind,
|
||||
ArtworkOp,
|
||||
BatchProgress,
|
||||
TagEditorUtils,
|
||||
} from '/@/shared/types/tag-editor';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
|
||||
import { base64ToBytes, filterTagSummary, formatBatchFileErrors } from '../utils/utils';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
/**
|
||||
* Subscribes to batch progress events for the duration of `fn`, then unsubscribes.
|
||||
* Ensures the progress handler is always cleaned up even if `fn` throws.
|
||||
*/
|
||||
const withBatchProgress = async <T>(
|
||||
utils: TagEditorUtils,
|
||||
onProgress: (data: BatchProgress) => void,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const handler = (_e: unknown, data: BatchProgress) => onProgress(data);
|
||||
utils.onBatchProgress(handler);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
utils.offBatchProgress(handler);
|
||||
}
|
||||
};
|
||||
|
||||
interface UseMetadataEditorArgs {
|
||||
browser: null | { clearCache: () => Promise<void> };
|
||||
songs?: Song[];
|
||||
utils: TagEditorUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the metadata editor UI: loads song tags from disk, tracks field edits
|
||||
* and artwork changes, and writes them back on save.
|
||||
*/
|
||||
export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetadataEditorArgs) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [readWarning, setReadWarning] = useState<null | string>(null);
|
||||
const [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
|
||||
const [rescan, setRescan] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tagSummary, setTagSummary] = useState<Record<string, null | string>>({});
|
||||
const [editedFields, setEditedFields] = useState<Record<string, string>>({});
|
||||
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
|
||||
const [loadedArtwork, setLoadedArtwork] = useState<{ kind: ArtworkKind }>({ kind: 'none' });
|
||||
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
|
||||
const [artworkOp, setArtworkOp] = useState<ArtworkOp | null>(null);
|
||||
|
||||
/**
|
||||
* Merges `tagSummary` (on-disk values) and `editedFields` (unsaved edits) into
|
||||
* `displayFields`. Keys present in `tagSummary` with a `null` value (differing
|
||||
* across files) are added to `mixedKeys`. `sortedFieldEntries` applies
|
||||
* `FIELD_PRIORITY` ordering, then alphabetical by label for unlisted keys.
|
||||
*/
|
||||
const { displayFields, mixedKeys, sortedFieldEntries } = useMemo(() => {
|
||||
const allKeys = new Set<string>();
|
||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
||||
for (const k of removedKeys) allKeys.delete(k);
|
||||
|
||||
const displayFields: Record<string, string> = {};
|
||||
const mixedKeys = new Set<string>();
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key in editedFields) {
|
||||
displayFields[key] = editedFields[key];
|
||||
continue;
|
||||
}
|
||||
const summaryVal = tagSummary[key];
|
||||
if (summaryVal === null) {
|
||||
mixedKeys.add(key);
|
||||
displayFields[key] = '';
|
||||
} else if (summaryVal !== undefined) {
|
||||
displayFields[key] = summaryVal;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFieldEntries = Object.entries(displayFields).sort(([a], [b]) => {
|
||||
const pa = FIELD_PRIORITY.indexOf(a);
|
||||
const pb = FIELD_PRIORITY.indexOf(b);
|
||||
if (pa !== -1 && pb !== -1) return pa - pb;
|
||||
if (pa !== -1) return -1;
|
||||
if (pb !== -1) return 1;
|
||||
const labelA = KNOWN_TAG_MAP.get(a)?.label ?? a;
|
||||
const labelB = KNOWN_TAG_MAP.get(b)?.label ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
|
||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||
}, [tagSummary, editedFields, removedKeys]);
|
||||
|
||||
/**
|
||||
* Runs once on mount: reads metadata for all songs in batch and populates
|
||||
* tag summary and initial artwork state.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const songs = (songsProp ?? []).filter((s) => s.path);
|
||||
|
||||
if (songs.length === 0) {
|
||||
setError(t('page.itemDetail.noLocalSongs', 'No songs with local file paths found'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedSongs(songs);
|
||||
const paths = songs.map((s) => s.path!);
|
||||
|
||||
const batchResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.readSongMetadataBatch(paths),
|
||||
);
|
||||
|
||||
if (!batchResult.success || !batchResult.tagSummary) {
|
||||
setError(batchResult.error ?? t('page.itemDetail.fileNotWritable'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchResult.failedFiles?.length) {
|
||||
const count = batchResult.failedFiles.length;
|
||||
const total = batchResult.totalCount ?? paths.length;
|
||||
setReadWarning(
|
||||
t('page.itemDetail.readPartialFailure', {
|
||||
count,
|
||||
defaultValue: `Could not read metadata from ${count} of ${total} file(s).`,
|
||||
total,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setTagSummary(filterTagSummary(batchResult.tagSummary));
|
||||
|
||||
if (
|
||||
batchResult.artworkKind === 'common' &&
|
||||
batchResult.artworkData &&
|
||||
batchResult.artworkMimeType
|
||||
) {
|
||||
setArtworkDisplayUrl(
|
||||
`data:${batchResult.artworkMimeType};base64,${batchResult.artworkData}`,
|
||||
);
|
||||
}
|
||||
setLoadedArtwork({ kind: batchResult.artworkKind });
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initialize().catch((err) => {
|
||||
setError(String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => utils.cancelReadSongMetadata();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** Records an edited value for `key`, overriding the on-disk summary. */
|
||||
const handleFieldChange = useCallback((key: string, value: string) => {
|
||||
setEditedFields((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
/** Removes `key` from both `editedFields` and the display, marking it for deletion on save. */
|
||||
const handleRemoveField = useCallback((key: string) => {
|
||||
setEditedFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||
}, []);
|
||||
|
||||
/** Adds `key` to `editedFields` with an empty value and un-marks it from removal. */
|
||||
const handleAddField = useCallback((key: null | string) => {
|
||||
if (!key) return;
|
||||
setEditedFields((prev) => ({ ...prev, [key]: '' }));
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Creates a blob URL from raw image bytes and queues a `set` artwork operation. */
|
||||
const applyArtworkBytes = useCallback((bytes: Uint8Array, mimeType: string) => {
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
|
||||
setArtworkDisplayUrl(URL.createObjectURL(blob));
|
||||
setArtworkOp({ bytes, mimeType, type: 'set' });
|
||||
}, []);
|
||||
|
||||
/** Opens a native file picker, reads the selected image, and applies it as the new artwork. */
|
||||
const handleChangeArtwork = useCallback(async () => {
|
||||
const path = await window.api.localSettings.openFileSelector({
|
||||
filters: [{ extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'], name: 'Images' }],
|
||||
});
|
||||
if (!path) return;
|
||||
const result = await utils.readLocalImage(path);
|
||||
if (result.success && result.data && result.mimeType) {
|
||||
applyArtworkBytes(base64ToBytes(result.data), result.mimeType);
|
||||
}
|
||||
}, [applyArtworkBytes, utils]);
|
||||
|
||||
/** Clears the artwork preview and queues a `clear` artwork operation. */
|
||||
const handleRemoveArtwork = useCallback(() => {
|
||||
setArtworkDisplayUrl(null);
|
||||
setArtworkOp({ type: 'clear' });
|
||||
}, []);
|
||||
|
||||
/** Returns the `KnownTag` descriptor for `key`, falling back to a generic string entry. */
|
||||
const getFieldMeta = useCallback(
|
||||
(key: string): KnownTag => KNOWN_TAG_MAP.get(key) ?? { key, label: key, type: 'string' },
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates that no editable fields are empty, writes all tag and artwork
|
||||
* changes to disk in batch, then optionally triggers a server rescan and
|
||||
* closes the modal.
|
||||
*/
|
||||
const handleSave = useCallback(async () => {
|
||||
if (resolvedSongs.length === 0) return;
|
||||
|
||||
const emptyFields = Object.entries(editedFields)
|
||||
.filter(([key, value]) => {
|
||||
const meta = KNOWN_TAG_MAP.get(key);
|
||||
return meta?.type !== 'boolean' && value.trim() === '';
|
||||
})
|
||||
.map(([key]) => KNOWN_TAG_MAP.get(key)?.label ?? key);
|
||||
|
||||
if (emptyFields.length > 0) {
|
||||
toast.error({
|
||||
message: `${t('page.itemDetail.emptyFields', 'Fields cannot be empty')}: ${emptyFields.join(', ')}`,
|
||||
title: t('error.generalError', 'Error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const paths = resolvedSongs.map((s) => s.path!).filter(Boolean);
|
||||
|
||||
try {
|
||||
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.writeSongTagsBatch(
|
||||
paths,
|
||||
editedFields,
|
||||
[...removedKeys],
|
||||
artworkOp ?? undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (!writeResult.success) {
|
||||
const failed = writeResult.failedFiles ?? [];
|
||||
const message =
|
||||
failed.length > 0
|
||||
? formatBatchFileErrors(
|
||||
failed,
|
||||
t('page.itemDetail.writePartialFailure', {
|
||||
count: failed.length,
|
||||
defaultValue: `Failed to save ${failed.length} file(s).`,
|
||||
}),
|
||||
)
|
||||
: (writeResult.error ?? t('page.itemDetail.fileNotWritable'));
|
||||
toast.error({ message, title: t('error.generalError', 'Error') });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success({ message: t('page.itemDetail.tagsSaved') });
|
||||
|
||||
if (artworkOp) {
|
||||
await browser?.clearCache();
|
||||
}
|
||||
|
||||
if (rescan && server) {
|
||||
try {
|
||||
await controller.startScan({ apiClientProps: { serverId: server.id } });
|
||||
toast.success({ message: t('page.itemDetail.rescanStarted') });
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
closeAllModals();
|
||||
} finally {
|
||||
setLoadProgress(null);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [artworkOp, browser, editedFields, removedKeys, rescan, resolvedSongs, server, t, utils]);
|
||||
|
||||
/** Known tags not yet present in `displayFields`, sorted alphabetically for the add-field dropdown. */
|
||||
const availableToAdd = useMemo(
|
||||
() =>
|
||||
KNOWN_TAGS.filter((tag) => !(tag.key in displayFields))
|
||||
.map((tag) => ({ label: tag.label, value: tag.key }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[displayFields],
|
||||
);
|
||||
|
||||
const artworkIsMixed = artworkOp === null && loadedArtwork.kind === 'mixed';
|
||||
const showRemoveArtworkButton =
|
||||
artworkOp?.type !== 'clear' && (artworkOp?.type === 'set' || loadedArtwork.kind !== 'none');
|
||||
|
||||
const mixedPlaceholder = t('page.itemDetail.multipleValues', '(Multiple Values)');
|
||||
|
||||
return {
|
||||
applyArtworkBytes,
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
availableToAdd,
|
||||
error,
|
||||
getFieldMeta,
|
||||
handleAddField,
|
||||
handleChangeArtwork,
|
||||
handleFieldChange,
|
||||
handleRemoveArtwork,
|
||||
handleRemoveField,
|
||||
handleSave,
|
||||
isLoading,
|
||||
isSaving,
|
||||
loadProgress,
|
||||
mixedKeys,
|
||||
mixedPlaceholder,
|
||||
readWarning,
|
||||
rescan,
|
||||
setRescan,
|
||||
showRemoveArtworkButton,
|
||||
sortedFieldEntries,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EditorFieldKey } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { EDITOR_FIELD_KEYS } from '/@/shared/types/tag-editor';
|
||||
|
||||
/** Tags pinned to the top of the editor table (most frequently edited). */
|
||||
export const FIELD_PRIORITY: readonly string[] = [
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'albumArtist',
|
||||
'trackNumber',
|
||||
'discNumber',
|
||||
'date',
|
||||
];
|
||||
|
||||
/** Human-readable labels where auto-generated text is awkward. */
|
||||
const FIELD_LABEL_OVERRIDES: Partial<Record<EditorFieldKey, string>> = {
|
||||
acoustidId: 'AcoustID',
|
||||
albumArtist: 'Album Artist',
|
||||
albumArtistSort: 'Album Artist Sort',
|
||||
albumSort: 'Album Sort',
|
||||
artistSort: 'Artist Sort',
|
||||
catalogNumber: 'Catalog Number',
|
||||
composerSort: 'Composer Sort',
|
||||
discNumber: 'Disc Number',
|
||||
musicbrainzArtistId: 'MusicBrainz Artist ID',
|
||||
musicbrainzReleaseArtistId: 'MusicBrainz Album Artist ID',
|
||||
musicbrainzReleaseGroupId: 'MusicBrainz Release Group ID',
|
||||
musicbrainzReleaseId: 'MusicBrainz Album ID',
|
||||
musicbrainzReleaseTrackId: 'MusicBrainz Release Track ID',
|
||||
musicbrainzTrackId: 'MusicBrainz Track ID',
|
||||
musicbrainzWorkId: 'MusicBrainz Work ID',
|
||||
originalAlbum: 'Original Album',
|
||||
originalArtist: 'Original Artist',
|
||||
originalDate: 'Original Date',
|
||||
remixedBy: 'Remixer',
|
||||
titleSort: 'Title Sort',
|
||||
totalDiscs: 'Total Discs',
|
||||
totalTracks: 'Total Tracks',
|
||||
trackNumber: 'Track Number',
|
||||
};
|
||||
|
||||
/** Input widget overrides (taglib metadata type is not always specific enough). */
|
||||
const FIELD_TYPE_OVERRIDES: Partial<
|
||||
Record<EditorFieldKey, 'boolean' | 'number' | 'string' | 'textarea'>
|
||||
> = {
|
||||
bpm: 'number',
|
||||
comment: 'textarea',
|
||||
lyrics: 'textarea',
|
||||
};
|
||||
|
||||
/** Which form control to render for a tag row. */
|
||||
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
|
||||
|
||||
export interface KnownTag {
|
||||
key: string;
|
||||
label: string;
|
||||
type: TagFieldType;
|
||||
}
|
||||
|
||||
const humanizeKey = (key: string): string =>
|
||||
key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
|
||||
const resolveFieldType = (key: string): TagFieldType =>
|
||||
FIELD_TYPE_OVERRIDES[key as EditorFieldKey] ?? 'string';
|
||||
|
||||
/** Field definitions used to render the tag editor table and "Add field" dropdown. */
|
||||
export const KNOWN_TAGS: KnownTag[] = EDITOR_FIELD_KEYS.map((key) => ({
|
||||
key,
|
||||
label: FIELD_LABEL_OVERRIDES[key] ?? humanizeKey(key),
|
||||
type: resolveFieldType(key),
|
||||
}));
|
||||
|
||||
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BatchFileError } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { KNOWN_TAG_MAP } from './known-tags';
|
||||
|
||||
export const base64ToBytes = (base64: string): Uint8Array => {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const formatBatchFileErrors = (failed: BatchFileError[], summary: string): string => {
|
||||
const details = failed
|
||||
.slice(0, 3)
|
||||
.map((f) => f.path.split(/[/\\]/).pop() ?? f.path)
|
||||
.join(', ');
|
||||
const suffix = failed.length > 3 ? '…' : '';
|
||||
return `${summary} ${details}${suffix}`;
|
||||
};
|
||||
|
||||
export const filterTagSummary = (
|
||||
tagSummary: Record<string, null | string>,
|
||||
): Record<string, null | string> => {
|
||||
const filtered: Record<string, null | string> = {};
|
||||
for (const [k, v] of Object.entries(tagSummary)) {
|
||||
if (KNOWN_TAG_MAP.has(k)) filtered[k] = v;
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
@@ -347,6 +347,19 @@ const scrobbleParameters = z.object({
|
||||
|
||||
const scrobble = z.null();
|
||||
|
||||
const scanStatusBody = z.object({
|
||||
count: z.number(),
|
||||
folderCount: z.number(),
|
||||
lastScan: z.string().optional(),
|
||||
scanning: z.boolean(),
|
||||
});
|
||||
|
||||
const startScanParameters = z.object({});
|
||||
const startScan = z.object({ scanStatus: scanStatusBody });
|
||||
|
||||
const getScanStatusParameters = z.object({});
|
||||
const getScanStatus = z.object({ scanStatus: scanStatusBody });
|
||||
|
||||
const search3 = z.object({
|
||||
searchResult3: z
|
||||
.object({
|
||||
@@ -839,8 +852,10 @@ export const ssType = {
|
||||
reportPlayback: reportPlaybackParameters,
|
||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||
saveQueue: saveQueueParameters,
|
||||
getScanStatus: getScanStatusParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
search3: search3Parameters,
|
||||
startScan: startScanParameters,
|
||||
setRating: setRatingParameters,
|
||||
similarSongs: similarSongsParameters,
|
||||
similarSongs2: similarSongs2Parameters,
|
||||
@@ -892,8 +907,10 @@ export const ssType = {
|
||||
removeFavorite,
|
||||
reportPlayback,
|
||||
saveQueue,
|
||||
getScanStatus,
|
||||
scrobble,
|
||||
search3,
|
||||
startScan,
|
||||
serverInfo,
|
||||
setRating,
|
||||
similarSongs,
|
||||
|
||||
@@ -1376,6 +1376,16 @@ export type ScrobbleQuery = {
|
||||
// Scrobble
|
||||
export type ScrobbleResponse = null;
|
||||
|
||||
export type StartScanArgs = BaseEndpointArgs;
|
||||
|
||||
export type StartScanResponse = {
|
||||
count: number;
|
||||
folderCount: number;
|
||||
lastScan?: string;
|
||||
scanning: boolean;
|
||||
} | null;
|
||||
|
||||
|
||||
export type SearchAlbumArtistsQuery = {
|
||||
albumArtistLimit?: number;
|
||||
albumArtistStartIndex?: number;
|
||||
@@ -1540,6 +1550,7 @@ export type ControllerEndpoint = {
|
||||
args: UploadInternetRadioStationImageArgs,
|
||||
) => Promise<UploadInternetRadioStationImageResponse>;
|
||||
uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>;
|
||||
startScan: (args: StartScanArgs) => Promise<StartScanResponse>;
|
||||
};
|
||||
|
||||
export type DownloadArgs = BaseEndpointArgs & {
|
||||
@@ -1714,6 +1725,7 @@ export type InternalControllerEndpoint = {
|
||||
uploadPlaylistImage?: (
|
||||
args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
|
||||
) => Promise<UploadPlaylistImageResponse>;
|
||||
startScan: (args: ReplaceApiClientProps<StartScanArgs>) => Promise<StartScanResponse>;
|
||||
};
|
||||
|
||||
export type LyricGetQuery = {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Editor field keys in taglib-wasm camelCase. Shared between main and renderer. */
|
||||
export const EDITOR_FIELD_KEYS = [
|
||||
'title',
|
||||
'artist',
|
||||
'albumArtist',
|
||||
'album',
|
||||
'subtitle',
|
||||
'genre',
|
||||
'comment',
|
||||
'trackNumber',
|
||||
'totalTracks',
|
||||
'discNumber',
|
||||
'totalDiscs',
|
||||
'date',
|
||||
'originalDate',
|
||||
'bpm',
|
||||
'language',
|
||||
'media',
|
||||
'script',
|
||||
'grouping',
|
||||
'titleSort',
|
||||
'albumSort',
|
||||
'artistSort',
|
||||
'albumArtistSort',
|
||||
'composerSort',
|
||||
'composer',
|
||||
'producer',
|
||||
'lyricist',
|
||||
'conductor',
|
||||
'remixedBy',
|
||||
'isrc',
|
||||
'asin',
|
||||
'barcode',
|
||||
'catalogNumber',
|
||||
'label',
|
||||
'copyright',
|
||||
'mood',
|
||||
'originalAlbum',
|
||||
'originalArtist',
|
||||
'lyrics',
|
||||
'musicbrainzTrackId',
|
||||
'musicbrainzReleaseId',
|
||||
'musicbrainzReleaseGroupId',
|
||||
'musicbrainzReleaseTrackId',
|
||||
'musicbrainzWorkId',
|
||||
'musicbrainzArtistId',
|
||||
'musicbrainzReleaseArtistId',
|
||||
'acoustidId',
|
||||
] as const;
|
||||
|
||||
export type EditorFieldKey = (typeof EDITOR_FIELD_KEYS)[number];
|
||||
|
||||
export type ArtworkKind = 'common' | 'mixed' | 'none';
|
||||
|
||||
export type ArtworkOp = { bytes: Uint8Array; mimeType: string; type: 'set' } | { type: 'clear' };
|
||||
|
||||
export interface BatchFileError {
|
||||
error: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface BatchProgress {
|
||||
processed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface IpcResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface ReadLocalImageResult extends IpcResult {
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface ReadSongMetadataBatchResult extends IpcResult {
|
||||
artworkData?: string;
|
||||
artworkKind: ArtworkKind;
|
||||
artworkMimeType?: string;
|
||||
failedFiles?: BatchFileError[];
|
||||
readCount?: number;
|
||||
tagSummary?: Record<string, null | string>;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export interface WriteSongTagsBatchResult extends IpcResult {
|
||||
failedFiles?: BatchFileError[];
|
||||
}
|
||||
|
||||
/** Subset of `window.api.utils` consumed by the metadata editor. */
|
||||
export interface TagEditorUtils {
|
||||
cancelReadSongMetadata: () => void;
|
||||
offBatchProgress: (cb: (event: unknown, data: BatchProgress) => void) => void;
|
||||
onBatchProgress: (cb: (event: unknown, data: BatchProgress) => void) => void;
|
||||
readLocalImage: (filePath: string) => Promise<ReadLocalImageResult>;
|
||||
readSongMetadataBatch: (filePaths: string[]) => Promise<ReadSongMetadataBatchResult>;
|
||||
writeSongTagsBatch: (
|
||||
filePaths: string[],
|
||||
edits: Record<string, string>,
|
||||
removed: string[],
|
||||
artworkOp?: ArtworkOp,
|
||||
) => Promise<WriteSongTagsBatchResult>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Guesses image MIME type from a file path or filename extension. */
|
||||
export const getImageMimeTypeFromPath = (filePath: string): string => {
|
||||
if (/\.png$/i.test(filePath)) return 'image/png';
|
||||
if (/\.gif$/i.test(filePath)) return 'image/gif';
|
||||
if (/\.webp$/i.test(filePath)) return 'image/webp';
|
||||
return 'image/jpeg';
|
||||
};
|
||||
Reference in New Issue
Block a user