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
+2
View File
@@ -120,6 +120,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",
@@ -139,6 +140,7 @@
"react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.8.2",
"string-to-color": "^2.2.2",
"taglib-wasm": "^1.5.1",
"wavesurfer.js": "^7.12.7",
"ws": "^8.21.0",
"zod": "^3.25.76",
+755 -647
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,6 +4,8 @@ allowBuilds:
electron-winstaller: true
esbuild: true
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- 'taglib-wasm@1.5.1'
overrides:
'xml2js': '0.5.0'
'react-router': '7.14.0'
+25 -2
View File
@@ -146,6 +146,7 @@
"right": "Right",
"sampleRate": "Sample rate",
"save": "Save",
"saveAndClose": "Save and close",
"saveAndReplace": "Save and replace",
"saveAs": "Save as",
"search": "Search",
@@ -162,6 +163,7 @@
"trackGain": "Track gain",
"trackPeak": "Track peak",
"translation": "Translation",
"undo": "Undo",
"unknown": "Unknown",
"version": "Version",
"year": "Year",
@@ -545,7 +547,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": {
@@ -606,9 +609,29 @@
"title": "$t(common.home)"
},
"itemDetail": {
"tagConfiguration": "Tag configuration",
"addTagConfig": "Add tag…",
"multiValueToggle": "Multi-value",
"autocompleteSource": "Autocomplete source",
"customValues": "Custom values",
"addCustomValue": "Add custom value…",
"serverSuggestions": "Server",
"copyPath": "Copy path to clipboard",
"copiedPath": "Path copied successfully",
"openFile": "Show track in file manager"
"openFile": "Show track in file manager",
"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"
+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 };
}
+45 -1
View File
@@ -1,4 +1,13 @@
import { ipcRenderer, webFrame } from 'electron';
import { ipcRenderer, type IpcRendererEvent, webFrame } from 'electron';
import type {
ArtworkOp,
BatchProgress,
ReadLocalImageResult,
ReadSongMetadataBatchResult,
TagValue,
WriteSongTagsBatchResult,
} from '../shared/types/tag-editor';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
@@ -6,6 +15,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, TagValue>,
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 +155,7 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
};
export const utils = {
cancelReadSongMetadata,
checkForUpdates,
customCssUpdatedListener,
disableAutoUpdates,
@@ -127,10 +166,14 @@ export const utils = {
isMacOS,
isWindows,
mainMessageListener,
offBatchProgress,
onBatchProgress,
openApplicationDirectory,
openCustomCssFolder,
openItem,
playerErrorListener,
readLocalImage,
readSongMetadataBatch,
rendererOpenCommandPalette,
rendererOpenManageServers,
rendererOpenReleaseNotes,
@@ -143,6 +186,7 @@ export const utils = {
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
writeSongTagsBatch,
};
export type Utils = typeof utils;
+12
View File
@@ -786,6 +786,18 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
refreshItems(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(`${i18n.t('error.apiRouteError')}: refreshItems`);
}
return apiController(
'refreshItems',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
removeFromPlaylist(args) {
const server = getServerById(args.apiClientProps.serverId);
+12
View File
@@ -301,6 +301,18 @@ export const contract = c.router({
400: jfType._response.error,
},
},
refreshItem: {
body: z.null(),
method: 'POST',
path: 'Items/:id/Refresh',
query: z.object({
MetadataRefreshMode: z.string().optional(),
}),
responses: {
204: z.null(),
400: jfType._response.error,
},
},
removeFavorite: {
body: jfType._parameters.favorite,
method: 'DELETE',
@@ -1556,6 +1556,21 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('Failed to move item in playlist');
}
},
refreshItems: async (args) => {
const { apiClientProps, query } = args;
await Promise.all(
query.ids.map((id) =>
jfApiClient(apiClientProps).refreshItem({
body: null,
params: { id },
query: { MetadataRefreshMode: 'FullRefresh' },
}),
),
);
return null;
},
removeFromPlaylist: async (args) => {
const { apiClientProps, query } = args;
@@ -1027,6 +1027,7 @@ export const NavidromeController: InternalControllerEndpoint = {
throw new Error('Failed to move item in playlist');
}
},
refreshItems: SubsonicController.refreshItems,
removeFromPlaylist: async (args) => {
const { apiClientProps, query } = args;
+16
View File
@@ -186,6 +186,14 @@ export const contract = c.router({
200: ssType._response.randomSongList,
},
},
getScanStatus: {
method: 'GET',
path: 'getScanStatus.view',
query: ssType._parameters.getScanStatus,
responses: {
200: ssType._response.getScanStatus,
},
},
getServerInfo: {
method: 'GET',
path: 'getOpenSubsonicExtensions.view',
@@ -345,6 +353,14 @@ export const contract = c.router({
200: ssType._response.setRating,
},
},
startScan: {
method: 'GET',
path: 'startScan.view',
query: ssType._parameters.startScan,
responses: {
200: ssType._response.startScan,
},
},
updateInternetRadioStation: {
method: 'GET',
path: 'updateInternetRadioStation.view',
@@ -2082,6 +2082,17 @@ export const SubsonicController: InternalControllerEndpoint = {
return res.body;
},
refreshItems: async (args) => {
const { apiClientProps } = args;
const res = await ssApiClient(apiClientProps).startScan({ query: {} });
if (res.status !== 200) {
throw new Error('Failed to start scan');
}
return null;
},
removeFromPlaylist: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({
query: {
@@ -0,0 +1,38 @@
import { openContextModal } from '@mantine/modals';
import isElectron from 'is-electron';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
import { Song } from '/@/shared/types/domain-types';
interface EditMetadataActionProps {
albumIds?: string[];
songs?: Song[];
}
const utils = isElectron() ? window.api.utils : null;
export const EditMetadataAction = ({ albumIds, songs: songItems }: EditMetadataActionProps) => {
const { t } = useTranslation();
const songs = useMemo(() => songItems?.filter((s) => s.path) ?? [], [songItems]);
const count = albumIds?.length ?? songs.length;
const onSelect = useCallback(() => {
openContextModal({
innerProps: { albumIds, songs },
modal: 'editMetadata',
size: 'xl',
styles: { body: { paddingBottom: 'var(--theme-spacing-xl)' } },
title: t('page.contextMenu.editMetadata'),
});
}, [albumIds, 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>
);
@@ -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';
@@ -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>
);
@@ -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 { MoveQueueItemsAction } from '/@/renderer/features/context-menu/actions/move-queue-items-action';
@@ -48,6 +49,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
<GoToAction items={items} />
<ShowInFileExplorerAction items={items} />
<ContextMenu.Divider />
<EditMetadataAction songs={items} />
<GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content>
);
@@ -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';
@@ -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>
);
@@ -14,7 +14,13 @@ import { Text } from '/@/shared/components/text/text';
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
import { Played } from '/@/shared/types/domain-types';
export const PathSettings = memo(() => {
interface PathSettingsProps {
persistImmediately?: boolean;
previewPath?: null | string;
}
export const PathSettings = memo(
({ persistImmediately = false, previewPath }: PathSettingsProps) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const randomSong = useQuery({
@@ -22,13 +28,16 @@ export const PathSettings = memo(() => {
query: { limit: 1, played: Played.All },
serverId,
}),
enabled: previewPath === undefined,
gcTime: Infinity,
staleTime: Infinity,
});
const { pathReplace, pathReplaceWith } = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const resolvedPreviewPath = useResolvedSongPath(randomSong.data?.items[0]?.path);
const resolvedPreviewPath = useResolvedSongPath(
previewPath === undefined ? randomSong.data?.items[0]?.path : previewPath,
);
const [localPathReplace, setLocalPathReplace] = useState(pathReplace);
const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);
@@ -61,6 +70,7 @@ export const PathSettings = memo(() => {
<Stack>
<Group>
<Text>{t('setting.pathReplace')}</Text>
{previewPath === undefined && (
<ActionIcon
icon="refresh"
loading={randomSong.isFetching}
@@ -68,6 +78,7 @@ export const PathSettings = memo(() => {
size="xs"
variant="transparent"
/>
)}
</Group>
<Code>
<Text isMuted size="md">
@@ -79,7 +90,11 @@ export const PathSettings = memo(() => {
onChange={(e) => {
const value = e.currentTarget.value;
setLocalPathReplace(value);
if (persistImmediately) {
setSettings({ general: { pathReplace: value } });
} else {
debouncedSetPathReplace(value);
}
}}
placeholder={t('setting.pathReplace_optionRemovePrefix')}
value={localPathReplace}
@@ -88,7 +103,11 @@ export const PathSettings = memo(() => {
onChange={(e) => {
const value = e.currentTarget.value;
setLocalPathReplaceWith(value);
if (persistImmediately) {
setSettings({ general: { pathReplaceWith: value } });
} else {
debouncedSetPathReplaceWith(value);
}
}}
placeholder={t('setting.pathReplace_optionAddPrefix')}
value={localPathReplaceWith}
@@ -96,4 +115,5 @@ export const PathSettings = memo(() => {
</Group>
</Stack>
);
});
},
);
@@ -0,0 +1,112 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
import { Group } from '/@/shared/components/group/group';
interface AddFieldInputProps {
availableFields: Array<{ label: string; value: string }>;
existingFieldKeys: string[];
onAddField: (key: string) => void;
}
export const AddFieldInput = ({
availableFields,
existingFieldKeys,
onAddField,
}: AddFieldInputProps) => {
const { t } = useTranslation();
const skipNextEnterRef = useRef(false);
const skipNextOnChangeResetRef = useRef(false);
const [input, setInput] = useState('');
const [duplicateAttempted, setDuplicateAttempted] = useState(false);
const trimmedInput = input.trim();
const resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
const isKnownTag = Boolean(resolvedInputKey && KNOWN_TAG_MAP.has(resolvedInputKey));
const customKeyError =
trimmedInput && !isKnownTag
? trimmedInput.includes('=')
? "Tag key cannot contain '='"
: // eslint-disable-next-line no-control-regex
/[^\x00-\x7F]/.test(trimmedInput)
? 'Tag key must use ASCII characters only'
: null
: null;
const duplicateError =
trimmedInput && !customKeyError && existingFieldKeys.includes(resolvedInputKey)
? 'Field already exists'
: null;
const fieldError = customKeyError ?? (duplicateAttempted ? duplicateError : null);
const addField = (key: string): boolean => {
const trimmed = key.trim();
if (!trimmed) return false;
const normalizedKey = resolveTagKey(trimmed);
const isKnown = KNOWN_TAG_MAP.has(normalizedKey);
if (
!isKnown &&
(trimmed.includes('=') || // eslint-disable-next-line no-control-regex
/[^\x00-\x7F]/.test(trimmed))
)
return false;
if (existingFieldKeys.includes(normalizedKey)) {
setDuplicateAttempted(true);
return false;
}
setDuplicateAttempted(false);
setInput('');
onAddField(normalizedKey);
return true;
};
return (
<Group>
<Autocomplete
data={availableFields}
error={fieldError}
flex={1}
onChange={(value) => {
setInput(value);
if (skipNextOnChangeResetRef.current) {
skipNextOnChangeResetRef.current = false;
} else {
setDuplicateAttempted(false);
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (skipNextEnterRef.current) {
skipNextEnterRef.current = false;
return;
}
addField(input);
}
}}
onOptionSubmit={(value) => {
skipNextEnterRef.current = true;
skipNextOnChangeResetRef.current = true;
const added = addField(value);
if (added) {
queueMicrotask(() => setInput(''));
}
}}
placeholder={t('page.itemDetail.addField', 'Add field…')}
value={input}
/>
<ActionIcon
disabled={!!fieldError}
icon="add"
onClick={() => addField(input)}
variant="filled"
/>
</Group>
);
};
@@ -0,0 +1,38 @@
.artwork-box {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
background: var(--mantine-color-dark-6);
border-radius: var(--mantine-radius-md);
}
.artwork-image {
display: block;
width: 100%;
height: auto;
}
.placeholder {
min-height: 200px;
aspect-ratio: 1;
opacity: 0.4;
}
.placeholder-text {
font-size: 5cqw;
}
.icon-controls {
position: absolute;
right: 6px;
bottom: 6px;
z-index: 2;
padding: 4px;
pointer-events: none;
background: rgb(0 0 0 / 55%);
}
.icon-controls button {
pointer-events: auto;
}
@@ -0,0 +1,73 @@
import styles from './artwork-panel.module.css';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
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 gap="md" pt="md">
<DragDropZone
accept="image/*"
className={styles.artworkBox}
mode="file"
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>
)}
<Group className={styles.iconControls} gap={4} wrap="nowrap">
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
onClick={onBrowse}
radius="xl"
size="sm"
variant="default"
/>
<ActionIcon
aria-label={removeArtworkLabel}
disabled={!showRemoveButton}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={onRemove}
radius="xl"
size="sm"
variant="default"
/>
</Group>
</DragDropZone>
</Stack>
);
@@ -0,0 +1,52 @@
import { ContextModalProps } from '@mantine/modals';
import { useEffect, useState } from 'react';
import { controller } from '/@/renderer/api/controller';
import { SongEditModal } from '/@/renderer/features/tag-editor/components/song-edit-modal';
import { useCurrentServer } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { Song } from '/@/shared/types/domain-types';
type SongEditInnerProps = {
albumIds?: string[];
songs?: Song[];
};
export const SongEditContextModal = ({ innerProps }: ContextModalProps<SongEditInnerProps>) => {
const server = useCurrentServer();
const [resolvedSongs, setResolvedSongs] = useState<null | Song[]>(null);
useEffect(() => {
if (innerProps.albumIds) {
if (!server?.id) {
setResolvedSongs([]);
return;
}
Promise.all(
innerProps.albumIds.map((id) =>
controller.getAlbumDetail({
apiClientProps: { serverId: server.id },
query: { id },
}),
),
).then((albums) => {
setResolvedSongs(
albums.flatMap((album) => album?.songs ?? []).filter((s) => s.path),
);
});
} else {
setResolvedSongs((innerProps.songs ?? []).filter((s) => s.path));
}
}, [innerProps.albumIds, innerProps.songs, server?.id]);
if (resolvedSongs === null) {
return (
<Stack align="center" p="xl">
<Spinner />
</Stack>
);
}
return <SongEditModal songs={resolvedSongs} />;
};
@@ -0,0 +1,61 @@
.scope-select-option {
padding-block: var(--theme-spacing-xs);
}
.scope-option {
width: 100%;
min-width: 0;
}
.scope-option-label {
padding-block: var(--theme-spacing-xs);
}
.scope-option-image {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: var(--mantine-radius-sm);
}
.scope-option-meta {
flex: 1;
min-width: 0;
}
.table-scroller {
max-height: 55vh;
padding-top: var(--theme-spacing-sm);
padding-bottom: var(--theme-spacing-sm);
margin-top: var(--theme-spacing-sm);
border-top: 1px solid var(--theme-colors-border);
}
.table {
width: 100%;
}
.table-cell {
min-width: 0;
padding: var(--theme-spacing-xs);
vertical-align: middle;
}
.table-cell:nth-child(2) {
overflow: hidden;
}
.table-cell:last-child {
width: 1%;
}
.table-header {
width: 25%;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
vertical-align: middle;
color: var(--theme-colors-foreground-muted);
white-space: nowrap;
}
@@ -0,0 +1,303 @@
import { closeAllModals } from '@mantine/modals';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EDIT_SCOPE_ALL, useMetadataEditor } from '../hooks/use-metadata-editor';
import { AddFieldInput } from './add-field-input';
import { ArtworkPanel } from './artwork-panel';
import styles from './song-edit-modal.module.css';
import { TagEditorSettings } from './tag-editor-settings';
import { TagFieldRow } from './tag-field-row';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { PathSettings } from '/@/renderer/features/settings/components/general/path-settings';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
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 { LibraryItem, Song } from '/@/shared/types/domain-types';
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
const { t } = useTranslation();
const tableContainerRef = useRef<HTMLDivElement>(null);
const editor = useMetadataEditor({
browser: window.api.browser,
songs,
utils: window.api.utils,
});
const [tab, setTab] = useState<'artwork' | 'settings' | 'tags'>('tags');
const songsById = useMemo(
() => new Map(editor.resolvedSongs.map((song) => [song.id, song])),
[editor.resolvedSongs],
);
const scopeOptions = useMemo(
() => [
{
label: t('common.countSelected', {
count: editor.resolvedSongs.length,
}),
value: EDIT_SCOPE_ALL,
},
...editor.resolvedSongs.map((song) => ({
label: song.name,
value: song.id,
})),
],
[editor.resolvedSongs, t],
);
const renderScopeOption = useCallback(
({ option }: { option: { label: string; value: string } }) => {
if (option.value === EDIT_SCOPE_ALL) {
return (
<Text className={styles.scopeOptionLabel} fw={500}>
{option.label}
</Text>
);
}
const song = songsById.get(option.value);
if (!song) {
return option.label;
}
return (
<Group className={styles.scopeOption} gap="sm" wrap="nowrap">
<ItemImage
containerClassName={styles.scopeOptionImage}
enableViewport={false}
explicitStatus={song.explicitStatus}
id={song.imageId}
itemType={LibraryItem.SONG}
serverId={song._serverId}
src={song.imageUrl}
type="table"
/>
<Stack className={styles.scopeOptionMeta} gap={2}>
<Text fw={500} lineClamp={1}>
{song.name || '—'}
</Text>
<Text c="dimmed" lineClamp={1} size="sm">
{song.artistName || '—'}
</Text>
<Text c="dimmed" lineClamp={1} size="sm">
{song.album || '—'}
</Text>
</Stack>
</Group>
);
},
[songsById],
);
const handleAddField = (key: string) => {
editor.handleAddField(key);
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>
{editor.isFileNotFound && (
<>
<PathSettings persistImmediately previewPath={songs[0]?.path} />
<Group justify="flex-end">
<Button onClick={editor.reload} variant="filled">
{t('common.reload', 'Reload')}
</Button>
</Group>
</>
)}
</Stack>
);
}
return (
<Stack gap="xs">
{editor.resolvedSongs.length > 1 ? (
<Select
allowDeselect={false}
classNames={{
option: styles.scopeSelectOption,
}}
data={scopeOptions}
onChange={(value) => {
if (value) editor.setEditScope(value);
}}
renderOption={renderScopeOption}
searchable
value={editor.editScope}
/>
) : null}
<Tabs
keepMounted={false}
onChange={(value) => setTab(value as 'artwork' | 'settings' | 'tags')}
value={tab}
>
<Tabs.List>
<Tabs.Tab value="tags">{t('page.itemDetail.tagsTab', 'Tags')}</Tabs.Tab>
<Tabs.Tab value="artwork">
{t('page.itemDetail.artworkTab', 'Artwork')}
</Tabs.Tab>
<Tabs.Tab ml="auto" value="settings">
{t('common.settings', 'Settings')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tags">
<Stack gap="xs" pt="xs">
{editor.readWarning && (
<Text c="orange" size="sm">
{editor.readWarning}
</Text>
)}
<AddFieldInput
availableFields={editor.availableToAdd}
existingFieldKeys={editor.sortedFieldEntries.map(([key]) => key)}
onAddField={handleAddField}
/>
<ScrollArea className={styles.tableScroller} ref={tableContainerRef}>
<Table
classNames={{
table: styles.table,
td: styles.tableCell,
th: styles.tableHeader,
}}
highlightOnHover={false}
withRowBorders
>
<Table.Tbody>
{editor.sortedFieldEntries.map(([key, value]) => {
const tagConfig = editor.getTagConfig(key);
return (
<TagFieldRow
autocompleteSource={tagConfig.autocompleteSource}
customValues={tagConfig.customValues}
hasTagConfig={editor.hasTagConfig(key)}
isDirty={
key in editor.editedFields ||
editor.removedKeys.has(key)
}
isMixed={editor.mixedKeys.has(key)}
isMultiValue={
editor.multiValueKeys.has(key) ||
Array.isArray(value)
}
isRemoved={editor.removedKeys.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)}
onReset={() => editor.handleResetField(key)}
onRevert={() => editor.handleRevertField(key)}
tagKey={key}
value={value}
/>
);
})}
</Table.Tbody>
</Table>
</ScrollArea>
</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.Panel value="settings">
<TagEditorSettings />
</Tabs.Panel>
</Tabs>
{tab !== 'settings' && (
<>
<Checkbox
checked={editor.rescan}
label={t('page.itemDetail.triggerRescan')}
onChange={(e) => editor.setRescan(e.currentTarget.checked)}
/>
<Group justify="flex-end">
<Button
disabled={editor.isSaving}
onClick={() => closeAllModals()}
variant="subtle"
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
disabled={editor.isSaving}
loading={editor.isSaving}
onClick={() => editor.handleSave({ close: false })}
variant="default"
>
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
? `${t('common.save', 'Save')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
: t('common.save', 'Save')}
</Button>
<Button
loading={editor.isSaving}
onClick={() => editor.handleSave({ close: true })}
variant="filled"
>
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
? `${t('common.saveAndClose', 'Save and close')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
: t('common.saveAndClose', 'Save and close')}
</Button>
</Group>
</>
)}
</Stack>
);
};
@@ -0,0 +1,252 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiAddLine, RiCloseLine } from 'react-icons/ri';
import { KNOWN_TAG_MAP, KNOWN_TAGS, resolveTagKey } from '../utils/known-tags';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import {
type TagAutocompleteSource,
type TagConfig,
toServerTagAutocompleteSource,
useCurrentServerId,
useSettingsStore,
useSettingsStoreActions,
useTagEditorSettings,
} from '/@/renderer/store';
import { titleCase } from '/@/renderer/utils';
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
import { Accordion } from '/@/shared/components/accordion/accordion';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
import { Group } from '/@/shared/components/group/group';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem } from '/@/shared/types/domain-types';
const DEFAULT_TAG_CONFIG: TagConfig = {
autocompleteSource: 'none',
customValues: [],
multiValue: false,
};
export const TagEditorSettings = () => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const { tagConfigs } = useTagEditorSettings();
const { setSettings } = useSettingsStoreActions();
const [input, setInput] = useState('');
const tagsQuery = useQuery({
...sharedQueries.tagList({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: { type: LibraryItem.SONG },
serverId,
}),
enabled: Boolean(serverId),
});
const configuredKeys = Object.keys(tagConfigs).sort((a, b) => {
const labelA = KNOWN_TAG_MAP.get(a)?.tagName ?? a;
const labelB = KNOWN_TAG_MAP.get(b)?.tagName ?? b;
return labelA.localeCompare(labelB);
});
const updateTagConfig = (key: string, patch: Partial<TagConfig>) => {
const nextPatch =
patch.customValues !== undefined
? {
...patch,
customValues: [...patch.customValues].sort((a, b) => a.localeCompare(b)),
}
: patch;
setSettings({
tagEditor: {
tagConfigs: {
[key]: {
...DEFAULT_TAG_CONFIG,
...tagConfigs[key],
...nextPatch,
},
},
},
});
};
const setTagConfigs = (next: Record<string, TagConfig>) => {
useSettingsStore.setState((state) => {
state.tagEditor.tagConfigs = next;
});
};
const addField = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
const key = resolveTagKey(trimmed);
if (key === 'lyrics' || key in tagConfigs) {
setInput('');
return;
}
updateTagConfig(key, DEFAULT_TAG_CONFIG);
setInput('');
};
const removeField = (key: string) => {
const rest = { ...tagConfigs };
delete rest[key];
setTagConfigs(rest);
};
const availableFields = KNOWN_TAGS.filter(({ key }) => key !== 'lyrics' && !(key in tagConfigs))
.map(({ key, tagName }) => ({ label: tagName, value: key }))
.sort((a, b) => a.label.localeCompare(b.label));
const autocompleteSourceOptions = useMemo(() => {
const excluded = new Set(tagsQuery.data?.excluded.song ?? []);
const serverTagOptions =
tagsQuery.data?.tags
?.filter((tag) => !excluded.has(tag.name))
.map((tag) => ({
label: NDSongQueryFieldsLabelMap[tag.name] ?? titleCase(tag.name),
value: toServerTagAutocompleteSource(tag.name),
}))
.sort((a, b) => a.label.localeCompare(b.label)) ?? [];
return [
{ label: t('common.none', 'None'), value: 'none' },
{
label: t('entity.artist_other'),
value: 'serverArtists',
},
{
label: t('entity.genre_other', 'Genres'),
value: 'serverGenres',
},
...serverTagOptions,
];
}, [t, tagsQuery.data?.excluded.song, tagsQuery.data?.tags]);
const multiValueToggleData = [
{
label: t('common.filter_single'),
value: 'single',
},
{
label: t('common.filter_multiple'),
value: 'multi',
},
];
return (
<Stack gap="xs">
<Text fw={500} size="md">
{t('page.itemDetail.tagConfiguration', 'Tag configuration')}
</Text>
<Group>
<Autocomplete
data={availableFields}
flex={1}
onChange={setInput}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addField(input);
}
}}
onOptionSubmit={addField}
placeholder={t('page.itemDetail.addTagConfig', 'Add tag…')}
py="md"
value={input}
/>
<ActionIcon onClick={() => addField(input)} variant="filled">
<RiAddLine size={16} />
</ActionIcon>
</Group>
{configuredKeys.length > 0 && (
<Accordion multiple variant="separated">
{configuredKeys.map((key) => {
const tagName = KNOWN_TAG_MAP.get(key)?.tagName ?? key;
const config = tagConfigs[key];
return (
<Accordion.Item key={key} value={key}>
<Accordion.Control
component="div"
role="button"
style={{ userSelect: 'none' }}
>
<Group justify="space-between" wrap="nowrap">
<Text>{tagName}</Text>
<Group
gap="xs"
onClick={(event) => event.stopPropagation()}
wrap="nowrap"
>
<SegmentedControl
data={multiValueToggleData}
onChange={(value) =>
updateTagConfig(key, {
multiValue: value === 'multi',
})
}
size="xs"
value={config.multiValue ? 'multi' : 'single'}
/>
<ActionIcon
aria-label={t('common.remove', 'Remove')}
onClick={() => removeField(key)}
variant="subtle"
>
<RiCloseLine size={16} />
</ActionIcon>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Select
data={autocompleteSourceOptions}
label={t('page.itemDetail.autocompleteSource')}
onChange={(value) => {
if (!value) return;
updateTagConfig(key, {
autocompleteSource:
value as TagAutocompleteSource,
});
}}
searchable
value={config.autocompleteSource}
/>
<TagsInput
aria-label={`${t('page.itemDetail.customValues', 'Custom values')} - ${tagName}`}
onChange={(values) =>
updateTagConfig(key, { customValues: values })
}
placeholder={t(
'page.itemDetail.addCustomValue',
'Add custom value…',
)}
splitChars={[]}
value={[...config.customValues].sort((a, b) =>
a.localeCompare(b),
)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
)}
</Stack>
);
};
@@ -0,0 +1,29 @@
.remove-cell {
white-space: nowrap;
}
.remove-button {
font-size: 1.05rem;
line-height: 1;
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
tr:hover .remove-button,
.remove-button:focus-visible,
.remove-button-visible {
opacity: 1;
}
.dirty-label {
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
}
.removed-row > :not(:last-child) {
opacity: 0.55;
transition: opacity 0.15s ease-in-out;
}
.removed-row > :first-child {
text-decoration: line-through;
}
@@ -0,0 +1,237 @@
import type { TagValue } from '/@/shared/types/tag-editor';
import clsx from 'clsx';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { KnownTag } from '../utils/known-tags';
import { useTagAutocompleteSuggestions } from '../hooks/use-tag-autocomplete-suggestions';
import styles from './tag-field-row.module.css';
import { type TagAutocompleteSource } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Table } from '/@/shared/components/table/table';
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Textarea } from '/@/shared/components/textarea/textarea';
interface CustomTagsInputProps {
autocompleteSource: TagAutocompleteSource;
customValues: string[];
disabled: boolean;
mixedPlaceholder?: string;
onChange: (value: string[]) => void;
value: string[];
}
interface StringAutocompleteInputProps {
autocompleteSource: TagAutocompleteSource;
customValues: string[];
disabled: boolean;
mixedPlaceholder?: string;
onChange: (value: string) => void;
value: string;
}
interface TagFieldRowProps {
autocompleteSource: TagAutocompleteSource;
customValues: string[];
hasTagConfig: boolean;
isDirty?: boolean;
isMixed: boolean;
isMultiValue: boolean;
isRemoved: boolean;
meta: KnownTag;
mixedPlaceholder?: string;
onChange: (value: TagValue) => void;
onRemove: () => void;
onReset: () => void;
onRevert: () => void;
tagKey: string;
value: TagValue;
}
const useSuggestionData = (
autocompleteSource: TagAutocompleteSource,
customValues: string[],
searchValue: string,
) => {
const { groups, isLoading } = useTagAutocompleteSuggestions({
customValues,
search: searchValue,
source: autocompleteSource,
});
return { data: groups, isLoading };
};
const CustomTagsInput = ({
autocompleteSource,
customValues,
disabled,
mixedPlaceholder,
onChange,
value,
}: CustomTagsInputProps) => {
const [searchValue, setSearchValue] = useState('');
const { data, isLoading } = useSuggestionData(autocompleteSource, customValues, searchValue);
return (
<TagsInput
clearable
data={data}
disabled={disabled}
loading={isLoading}
onChange={onChange}
onSearchChange={setSearchValue}
placeholder={mixedPlaceholder}
searchValue={searchValue}
size="sm"
splitChars={[]}
value={value}
/>
);
};
const StringAutocompleteInput = ({
autocompleteSource,
customValues,
disabled,
mixedPlaceholder,
onChange,
value,
}: StringAutocompleteInputProps) => {
const { data, isLoading } = useSuggestionData(autocompleteSource, customValues, value);
return (
<Autocomplete
data={data}
disabled={disabled}
limit={100}
loading={isLoading}
onChange={onChange}
placeholder={mixedPlaceholder}
size="sm"
value={value}
/>
);
};
export const TagFieldRow = ({
autocompleteSource,
customValues,
hasTagConfig,
isDirty,
isMixed,
isMultiValue,
isRemoved,
meta,
mixedPlaceholder,
onChange,
onRemove,
onReset,
onRevert,
tagKey,
value,
}: TagFieldRowProps) => {
const { t } = useTranslation();
const stringValue = Array.isArray(value) ? value.join('; ') : value;
const useStringAutocomplete =
!isMultiValue && meta.type === 'string' && tagKey !== 'lyrics' && hasTagConfig;
return (
<Table.Tr
className={clsx({
[styles.removedRow]: isRemoved,
})}
data-field-key={tagKey}
key={tagKey}
>
<Table.Th className={clsx({ [styles.dirtyLabel]: isDirty })}>{meta.tagName}</Table.Th>
<Table.Td>
{isMultiValue && tagKey !== 'lyrics' ? (
<CustomTagsInput
autocompleteSource={autocompleteSource}
customValues={customValues}
disabled={isRemoved}
mixedPlaceholder={mixedPlaceholder}
onChange={onChange}
value={Array.isArray(value) ? value : value ? [value] : []}
/>
) : meta.type === 'textarea' ? (
<Textarea
autosize
disabled={isRemoved}
maxRows={6}
minRows={2}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={Array.isArray(value) ? value.join('\n\n') : value}
/>
) : meta.type === 'number' ? (
<NumberInput
disabled={isRemoved}
onChange={(v) => onChange(v === undefined ? '' : String(v))}
placeholder={mixedPlaceholder}
size="sm"
value={
isMixed || value === '' || Array.isArray(value)
? undefined
: Number(value)
}
/>
) : meta.type === 'boolean' ? (
<Checkbox
checked={!isMixed && value === '1'}
disabled={isRemoved}
indeterminate={isMixed}
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
size="sm"
/>
) : useStringAutocomplete ? (
<StringAutocompleteInput
autocompleteSource={autocompleteSource}
customValues={customValues}
disabled={isRemoved}
mixedPlaceholder={mixedPlaceholder}
onChange={onChange}
value={stringValue}
/>
) : (
<TextInput
disabled={isRemoved}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={stringValue}
/>
)}
</Table.Td>
<Table.Td className={styles.removeCell}>
<ActionIcon
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
className={clsx(styles.removeButton, {
[styles.removeButtonVisible]: isRemoved || isDirty,
})}
icon={isRemoved || isDirty ? 'undo' : 'x'}
iconProps={{
color: isRemoved || isDirty ? 'default' : 'error',
size: 'lg',
}}
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
size="sm"
tooltip={{
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
openDelay: 0,
}}
variant="subtle"
/>
</Table.Td>
</Table.Tr>
);
};
@@ -0,0 +1,679 @@
import type {
ArtworkKind,
ArtworkOp,
BatchProgress,
FileArtworkData,
TagEditorUtils,
TagValue,
} from '/@/shared/types/tag-editor';
import { closeAllModals } from '@mantine/modals';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
import { base64ToBytes, bytesToBase64, formatBatchFileErrors } from '../utils/utils';
import { controller } from '/@/renderer/api/controller';
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
import { toast } from '/@/shared/components/toast/toast';
import { Song } from '/@/shared/types/domain-types';
export const EDIT_SCOPE_ALL = '__all__';
/**
* 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);
}
};
const toArtworkDataUrl = (artwork: FileArtworkData): string =>
`data:${artwork.mimeType};base64,${artwork.data}`;
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;
};
const rebuildMergedTagSummary = (
tagsByPath: Record<string, Record<string, TagValue>>,
): Record<string, null | TagValue> => {
const paths = Object.keys(tagsByPath);
if (paths.length === 0) return {};
const summary: Record<string, null | TagValue> = { ...tagsByPath[paths[0]] };
for (let index = 1; index < paths.length; index++) {
const tags = tagsByPath[paths[index]];
for (const key of Object.keys(summary)) {
if (summary[key] !== null && !tagValuesEqual(summary[key] as TagValue, tags[key])) {
summary[key] = null;
}
}
for (const key of Object.keys(tags)) {
if (!(key in summary)) summary[key] = null;
}
}
return summary;
};
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 { tagConfigs, triggerRescan } = useTagEditorSettings();
const { setSettings } = useSettingsStoreActions();
const multiValueFields = useMemo(
() =>
Object.entries(tagConfigs)
.filter(([, config]) => config.multiValue)
.map(([key]) => key),
[tagConfigs],
);
const [isLoading, setIsLoading] = useState(true);
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
const [error, setError] = useState<null | string>(null);
const [isFileNotFound, setIsFileNotFound] = useState(false);
const [readWarning, setReadWarning] = useState<null | string>(null);
const [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [editScope, setEditScopeState] = useState<string>(EDIT_SCOPE_ALL);
const [mergedTagSummary, setMergedTagSummary] = useState<Record<string, null | TagValue>>({});
const [fileTags, setFileTags] = useState<Record<string, Record<string, TagValue>>>({});
const [fileArtwork, setFileArtwork] = useState<Record<string, FileArtworkData>>({});
const [mergedArtworkKind, setMergedArtworkKind] = useState<ArtworkKind>('none');
const [mergedArtwork, setMergedArtwork] = useState<FileArtworkData | null>(null);
const [batchMultiValueKeys, setBatchMultiValueKeys] = useState<Set<string>>(new Set());
const [editedFields, setEditedFields] = useState<Record<string, TagValue>>({});
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
const [artworkOp, setArtworkOp] = useState<ArtworkOp | null>(null);
const setRescan = useCallback(
(value: boolean) => setSettings({ tagEditor: { triggerRescan: value } }),
[setSettings],
);
const scopedSong = useMemo(
() =>
editScope === EDIT_SCOPE_ALL
? null
: (resolvedSongs.find((song) => song.id === editScope) ?? null),
[editScope, resolvedSongs],
);
const scopedPath = useMemo(() => {
if (!scopedSong?.path) return null;
return resolveSongPath(scopedSong.path);
}, [scopedSong]);
const tagSummary = useMemo((): Record<string, null | TagValue> => {
if (editScope === EDIT_SCOPE_ALL) return mergedTagSummary;
if (!scopedPath) return {};
return fileTags[scopedPath] ?? {};
}, [editScope, fileTags, mergedTagSummary, scopedPath]);
const parsedMultiValueKeys = useMemo(() => {
if (editScope === EDIT_SCOPE_ALL) return batchMultiValueKeys;
if (!scopedPath) return new Set<string>();
const tags = fileTags[scopedPath] ?? {};
return new Set(
Object.entries(tags)
.filter(([, value]) => Array.isArray(value))
.map(([key]) => key),
);
}, [batchMultiValueKeys, editScope, fileTags, scopedPath]);
const loadedArtworkKind = useMemo((): ArtworkKind => {
if (editScope === EDIT_SCOPE_ALL) return mergedArtworkKind;
if (!scopedPath) return 'none';
return fileArtwork[scopedPath] ? 'common' : 'none';
}, [editScope, fileArtwork, mergedArtworkKind, scopedPath]);
const applyBaselineArtwork = useCallback(
(scope: string, songs: Song[]) => {
if (scope === EDIT_SCOPE_ALL) {
if (mergedArtworkKind === 'common' && mergedArtwork) {
setArtworkDisplayUrl(toArtworkDataUrl(mergedArtwork));
} else {
setArtworkDisplayUrl(null);
}
return;
}
const song = songs.find((item) => item.id === scope);
const path = song?.path ? resolveSongPath(song.path) : null;
const artwork = path ? fileArtwork[path] : undefined;
setArtworkDisplayUrl(artwork ? toArtworkDataUrl(artwork) : null);
},
[fileArtwork, mergedArtwork, mergedArtworkKind],
);
const setEditScope = useCallback(
(scope: string) => {
setEditScopeState(scope);
setEditedFields({});
setRemovedKeys(new Set());
setArtworkOp(null);
applyBaselineArtwork(scope, resolvedSongs);
},
[applyBaselineArtwork, resolvedSongs],
);
/**
* 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 multiValueKeys = useMemo(
() => new Set([...multiValueFields, ...parsedMultiValueKeys]),
[multiValueFields, parsedMultiValueKeys],
);
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);
const displayFields: Record<string, TagValue> = {};
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] = multiValueKeys.has(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 tagNameA = KNOWN_TAG_MAP.get(a)?.tagName ?? a;
const tagNameB = KNOWN_TAG_MAP.get(b)?.tagName ?? b;
return tagNameA.localeCompare(tagNameB);
});
return { displayFields, mixedKeys, sortedFieldEntries };
}, [tagSummary, editedFields, multiValueKeys]);
/** Reads metadata for all songs and populates the editor state. */
const initialize = useCallback(async () => {
setError(null);
setIsFileNotFound(false);
setIsLoading(true);
setLoadProgress(null);
setReadWarning(null);
setEditScopeState(EDIT_SCOPE_ALL);
setEditedFields({});
setRemovedKeys(new Set());
setArtworkOp(null);
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) => resolveSongPath(s.path)).filter(Boolean) as string[];
const batchResult = await withBatchProgress(utils, setLoadProgress, () =>
utils.readSongMetadataBatch(paths),
);
if (!batchResult.success || !batchResult.tagSummary) {
const failedFiles = batchResult.failedFiles ?? [];
setIsFileNotFound(
failedFiles.length === paths.length &&
failedFiles.every((file) => file.code === 'ENOENT'),
);
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,
}),
);
}
setMergedTagSummary(batchResult.tagSummary);
setFileTags(batchResult.fileTags ?? {});
setFileArtwork(batchResult.fileArtwork ?? {});
setBatchMultiValueKeys(new Set(batchResult.multiValueKeys ?? []));
setMergedArtworkKind(batchResult.artworkKind);
const nextMergedArtwork =
batchResult.artworkKind === 'common' &&
batchResult.artworkData &&
batchResult.artworkMimeType
? { data: batchResult.artworkData, mimeType: batchResult.artworkMimeType }
: null;
setMergedArtwork(nextMergedArtwork);
setArtworkDisplayUrl(nextMergedArtwork ? toArtworkDataUrl(nextMergedArtwork) : null);
setIsLoading(false);
}, [songsProp, t, utils]);
const reload = useCallback(() => {
initialize().catch((err) => {
setIsFileNotFound(false);
setError(String(err));
setIsLoading(false);
});
}, [initialize]);
/** Loads on mount and cancels an in-flight read when the editor unmounts. */
useEffect(() => {
reload();
return () => utils.cancelReadSongMetadata();
}, [reload, utils]);
/** Records an edited value for `key`, overriding the on-disk summary. */
const handleFieldChange = useCallback((key: string, value: TagValue) => {
setEditedFields((prev) => ({ ...prev, [key]: value }));
}, []);
/** Marks `key` for deletion while preserving its displayed value for undo. */
const handleRemoveField = useCallback((key: string) => {
setRemovedKeys((prev) => new Set(prev).add(key));
}, []);
/** Re-enables a field that was marked for deletion. */
const handleResetField = useCallback((key: string) => {
setRemovedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
/** Restores a field to its original on-disk value and removal state. */
const handleRevertField = useCallback((key: string) => {
setEditedFields((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
setRemovedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
// Refs so the functional updater inside handleAddField can see latest values without needing them in the dependency array.
const tagSummaryRef = useRef(tagSummary);
tagSummaryRef.current = tagSummary;
const removedKeysRef = useRef(removedKeys);
removedKeysRef.current = removedKeys;
/** 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) => {
const wasRemoved = removedKeysRef.current.has(key);
if (wasRemoved) return prev;
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
if (alreadyVisible) return prev;
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
});
setRemovedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
},
[multiValueKeys],
);
/** 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, tagName: key, type: 'string' },
[],
);
const songsInScope = useMemo(
() =>
editScope === EDIT_SCOPE_ALL
? resolvedSongs
: resolvedSongs.filter((song) => song.id === editScope),
[editScope, resolvedSongs],
);
/**
* Validates that no editable fields are empty, writes tag and artwork changes
* to disk for the current scope, optionally triggers a server rescan, then
* either closes the modal or refreshes in-memory baselines so editing can continue.
*/
const handleSave = useCallback(
async ({ close = true }: { close?: boolean } = {}) => {
if (songsInScope.length === 0) return;
const activeEdits = Object.fromEntries(
Object.entries(editedFields).filter(([key]) => !removedKeys.has(key)),
) as Record<string, TagValue>;
const emptyFields = Object.entries(activeEdits)
.filter(([key, value]) => {
const meta = KNOWN_TAG_MAP.get(key);
if (meta?.type === 'boolean') return false;
return Array.isArray(value)
? value.length === 0 || value.some((part) => part.trim() === '')
: value.trim() === '';
})
.map(([key]) => KNOWN_TAG_MAP.get(key)?.tagName ?? 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 = songsInScope
.map((s) => resolveSongPath(s.path))
.filter(Boolean) as string[];
const removedKeyList = [...removedKeys];
try {
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
utils.writeSongTagsBatch(
paths,
activeEdits,
removedKeyList,
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;
}
if (artworkOp) {
await browser?.clearCache();
}
if (triggerRescan && server) {
try {
await controller.refreshItems({
apiClientProps: { serverId: server.id },
query: { ids: songsInScope.map((s) => s.id) },
});
} catch {
// non-fatal
}
}
if (close) {
closeAllModals();
return;
}
const nextFileTags = { ...fileTags };
for (const path of paths) {
const tags = { ...(nextFileTags[path] ?? {}) };
for (const [key, value] of Object.entries(activeEdits)) {
tags[key] = value;
}
for (const key of removedKeyList) {
delete tags[key];
}
nextFileTags[path] = tags;
}
setFileTags(nextFileTags);
setMergedTagSummary(rebuildMergedTagSummary(nextFileTags));
const nextFileArtwork = { ...fileArtwork };
if (artworkOp?.type === 'set') {
const nextArtwork = {
data: bytesToBase64(artworkOp.bytes),
mimeType: artworkOp.mimeType,
};
for (const path of paths) {
nextFileArtwork[path] = nextArtwork;
}
setFileArtwork(nextFileArtwork);
if (editScope === EDIT_SCOPE_ALL || Object.keys(nextFileTags).length === 1) {
setMergedArtwork(nextArtwork);
setMergedArtworkKind('common');
} else {
const artworkEntries = Object.values(nextFileArtwork);
const allHaveArt = Object.keys(nextFileTags).every(
(path) => nextFileArtwork[path],
);
const sameArt =
allHaveArt &&
artworkEntries.length > 0 &&
artworkEntries.every(
(entry) =>
entry.data === artworkEntries[0].data &&
entry.mimeType === artworkEntries[0].mimeType,
);
setMergedArtwork(sameArt ? artworkEntries[0] : null);
setMergedArtworkKind(sameArt ? 'common' : 'mixed');
}
setArtworkDisplayUrl(toArtworkDataUrl(nextArtwork));
} else if (artworkOp?.type === 'clear') {
for (const path of paths) {
delete nextFileArtwork[path];
}
setFileArtwork(nextFileArtwork);
const remaining = Object.values(nextFileArtwork);
if (remaining.length === 0) {
setMergedArtwork(null);
setMergedArtworkKind('none');
} else if (
remaining.every(
(entry) =>
entry.data === remaining[0].data &&
entry.mimeType === remaining[0].mimeType,
)
) {
setMergedArtwork(remaining[0]);
setMergedArtworkKind('common');
} else {
setMergedArtwork(null);
setMergedArtworkKind('mixed');
}
setArtworkDisplayUrl(null);
}
const nextMultiValueKeys = new Set(batchMultiValueKeys);
for (const [key, value] of Object.entries(activeEdits)) {
if (Array.isArray(value)) nextMultiValueKeys.add(key);
else nextMultiValueKeys.delete(key);
}
for (const key of removedKeyList) {
nextMultiValueKeys.delete(key);
}
setBatchMultiValueKeys(nextMultiValueKeys);
setEditedFields({});
setRemovedKeys(new Set());
setArtworkOp(null);
} finally {
setLoadProgress(null);
setIsSaving(false);
}
},
[
artworkOp,
batchMultiValueKeys,
browser,
editScope,
editedFields,
fileArtwork,
fileTags,
removedKeys,
server,
songsInScope,
t,
triggerRescan,
utils,
],
);
/** Tags not yet present in `displayFields`: known tags plus configured custom tags. */
const availableToAdd = useMemo(() => {
const options = new Map<string, { label: string; value: string }>();
for (const tag of KNOWN_TAGS) {
if (tag.key in displayFields) continue;
options.set(tag.key, { label: tag.tagName, value: tag.key });
}
for (const key of Object.keys(tagConfigs)) {
if (key in displayFields || options.has(key)) continue;
options.set(key, {
label: KNOWN_TAG_MAP.get(key)?.tagName ?? key,
value: key,
});
}
return [...options.values()].sort((a, b) => a.label.localeCompare(b.label));
}, [displayFields, tagConfigs]);
const artworkIsMixed = artworkOp === null && loadedArtworkKind === 'mixed';
const showRemoveArtworkButton =
artworkOp?.type !== 'clear' && (artworkOp?.type === 'set' || loadedArtworkKind !== 'none');
const mixedPlaceholder = t('page.itemDetail.multipleValues', '(Multiple Values)');
const getTagConfig = useCallback(
(key: string) =>
tagConfigs[key] ?? {
autocompleteSource: 'none' as const,
customValues: [] as string[],
multiValue: false,
},
[tagConfigs],
);
const hasTagConfig = useCallback((key: string) => key in tagConfigs, [tagConfigs]);
return {
applyArtworkBytes,
artworkDisplayUrl,
artworkIsMixed,
availableToAdd,
editedFields,
editScope,
error,
getFieldMeta,
getTagConfig,
handleAddField,
handleChangeArtwork,
handleFieldChange,
handleRemoveArtwork,
handleRemoveField,
handleResetField,
handleRevertField,
handleSave,
hasTagConfig,
isFileNotFound,
isLoading,
isSaving,
loadProgress,
mixedKeys,
mixedPlaceholder,
multiValueKeys,
readWarning,
reload,
removedKeys,
rescan: triggerRescan,
resolvedSongs,
setEditScope,
setRescan,
showRemoveArtworkButton,
sortedFieldEntries,
};
};
@@ -0,0 +1,169 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import {
getServerTagAutocompleteName,
isServerTagAutocompleteSource,
type TagAutocompleteSource,
useCurrentServerId,
} from '/@/renderer/store';
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
import {
AlbumArtistListSort,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
const SUGGESTION_LIMIT = 25;
export type TagAutocompleteGroup = {
group: string;
items: string[];
};
interface UseTagAutocompleteSuggestionsArgs {
customValues?: string[];
search: string;
source: TagAutocompleteSource;
}
interface UseTagAutocompleteSuggestionsResult {
groups: TagAutocompleteGroup[];
isLoading: boolean;
}
export const useTagAutocompleteSuggestions = ({
customValues = [],
search,
source,
}: UseTagAutocompleteSuggestionsArgs): UseTagAutocompleteSuggestionsResult => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const trimmedSearch = search.trim();
const [debouncedSearch = ''] = useDebouncedValue(trimmedSearch, 300);
const canSearch = debouncedSearch.length >= 1;
const isDebouncing = trimmedSearch !== debouncedSearch;
const serverTagName = getServerTagAutocompleteName(source);
const usesServerSearch =
source === 'serverArtists' ||
source === 'serverGenres' ||
isServerTagAutocompleteSource(source);
const artistQuery = useQuery({
...artistsQueries.albumArtistList({
options: {
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60,
},
query: {
limit: SUGGESTION_LIMIT,
searchTerm: debouncedSearch,
sortBy: AlbumArtistListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
}),
enabled: source === 'serverArtists' && canSearch && Boolean(serverId),
});
const genreQuery = useQuery({
...genresQueries.list({
options: {
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60,
},
query: {
limit: SUGGESTION_LIMIT,
searchTerm: debouncedSearch,
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
}),
enabled: source === 'serverGenres' && canSearch && Boolean(serverId),
});
const tagListQuery = useQuery({
...sharedQueries.tagList({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: { type: LibraryItem.SONG },
serverId,
}),
enabled: isServerTagAutocompleteSource(source) && canSearch && Boolean(serverId),
});
const isFetching =
(source === 'serverArtists' && artistQuery.isFetching) ||
(source === 'serverGenres' && genreQuery.isFetching) ||
(isServerTagAutocompleteSource(source) && tagListQuery.isFetching);
const isLoading = usesServerSearch && trimmedSearch.length >= 1 && (isDebouncing || isFetching);
const groups = useMemo(() => {
const nextGroups: TagAutocompleteGroup[] = [];
const customLower = new Set(customValues.map((value) => value.toLowerCase()));
const searchLower = debouncedSearch.toLowerCase();
if (customValues.length > 0) {
nextGroups.push({
group: t('page.itemDetail.customValues', 'Custom values'),
items: [...customValues].sort((a, b) => a.localeCompare(b)),
});
}
let serverNames: string[] = [];
if (source === 'serverArtists') {
serverNames =
artistQuery.data?.items.map((artist) => artist.name).filter(Boolean) ?? [];
} else if (source === 'serverGenres') {
serverNames = genreQuery.data?.items.map((genre) => genre.name).filter(Boolean) ?? [];
} else if (serverTagName) {
const tag = tagListQuery.data?.tags?.find((item) => item.name === serverTagName);
serverNames =
tag?.options
.map((option) => option.name)
.filter(
(name) => name && (!canSearch || name.toLowerCase().includes(searchLower)),
)
.slice(0, SUGGESTION_LIMIT) ?? [];
}
if (source === 'serverArtists' || source === 'serverGenres' || serverTagName) {
const serverItems = [...new Set(serverNames)].filter(
(name) => !customLower.has(name.toLowerCase()),
);
if (serverItems.length > 0) {
nextGroups.push({
group: t('page.itemDetail.serverSuggestions', 'Server'),
items: serverItems,
});
}
}
return nextGroups;
}, [
artistQuery.data?.items,
canSearch,
customValues,
debouncedSearch,
genreQuery.data?.items,
serverTagName,
source,
t,
tagListQuery.data?.tags,
]);
return { groups, isLoading };
};
@@ -0,0 +1,98 @@
import { PROPERTIES } from 'taglib-wasm';
/** 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',
];
export interface KnownTag {
key: string;
tagName: string;
type: TagFieldType;
}
/** Which form control to render for a tag row. */
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
/**
* Per-key type overrides and extras. Keys absent from PROPERTIES are appended as
* extra entries.
*/
const TAG_CONFIG: Record<string, { type?: TagFieldType }> = {
acoustidFingerprint: { type: 'textarea' },
// extras not in PROPERTIES (common MusicBrainz Picard tags)
ARTISTS: {},
artistSort: {},
bpm: { type: 'number' },
catalogNumber: {},
comment: { type: 'textarea' },
composerSort: {},
discNumber: {},
lyrics: { type: 'textarea' },
musicbrainzArtistId: {},
musicbrainzReleaseArtistId: {},
musicbrainzReleaseGroupId: {},
musicbrainzReleaseId: {},
musicbrainzReleaseTrackId: {},
musicbrainzTrackId: {},
musicbrainzWorkId: {},
originalAlbum: {},
originalArtist: {},
originalDate: {},
ORIGINALYEAR: { type: 'number' },
RELEASECOUNTRY: { type: 'string' },
RELEASESTATUS: { type: 'string' },
RELEASETYPE: { type: 'string' },
remixedBy: {},
titleSort: {},
totalDiscs: {},
totalTracks: {},
trackNumber: {},
};
/** Field definitions derived from taglib-wasm's PROPERTIES plus any extras in TAG_CONFIG. */
export const KNOWN_TAGS: KnownTag[] = [
...Object.entries(PROPERTIES).map(([key, prop]) => {
const cfg = TAG_CONFIG[key];
return {
key,
tagName: prop.key,
type: cfg?.type ?? (prop.type as TagFieldType),
};
}),
...Object.entries(TAG_CONFIG)
.filter(([key]) => !(key in PROPERTIES))
.map(([key, cfg]) => ({
key,
tagName: key,
type: cfg.type ?? ('string' as TagFieldType),
})),
];
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
/**
* Resolves a raw tag name to its TagLib property key. Known tags return their
* canonical key; unknown names are uppercased to match TagLib's wire format.
*/
export const resolveTagKey = (input: string): string => {
const trimmed = input.trim();
if (!trimmed) return trimmed;
const exact = KNOWN_TAGS.find((tag) => tag.tagName === trimmed || tag.key === trimmed);
if (exact) return exact.key;
const lower = trimmed.toLowerCase();
const insensitive = KNOWN_TAGS.find(
(tag) => tag.tagName.toLowerCase() === lower || tag.key.toLowerCase() === lower,
);
if (insensitive) return insensitive.key;
return trimmed.toUpperCase();
};
@@ -0,0 +1,23 @@
import type { BatchFileError } from '/@/shared/types/tag-editor';
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 bytesToBase64 = (bytes: Uint8Array): string => {
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
};
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}`;
};
+13
View File
@@ -175,9 +175,22 @@ const VisualizerSettingsContextModal = (props: any) => (
</Suspense>
);
const LazySongEditContextModal = lazy(() =>
import('/@/renderer/features/tag-editor/components/song-edit-context-modal').then((module) => ({
default: module.SongEditContextModal,
})),
);
const SongEditContextModal = (props: any) => (
<Suspense fallback={<Spinner container />}>
<LazySongEditContextModal {...props} />
</Suspense>
);
const appRouterModals = {
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
editMetadata: SongEditContextModal,
lyricsSettings: LyricsSettingsContextModal,
saveAndReplace: SaveAndReplaceContextModal,
settings: SettingsContextModal,
+82
View File
@@ -748,6 +748,35 @@ const AutoDJSettingsSchema = z.object({
timing: z.number(),
});
const TagAutocompleteSourceSchema = z.string();
const TagConfigSchema = z.object({
autocompleteSource: TagAutocompleteSourceSchema,
customValues: z.array(z.string()),
multiValue: z.boolean(),
});
const TagEditorSettingsSchema = z.object({
tagConfigs: z.record(z.string(), TagConfigSchema),
triggerRescan: z.boolean(),
});
export type TagAutocompleteSource = string;
export type TagConfig = z.infer<typeof TagConfigSchema>;
export const SERVER_TAG_AUTOCOMPLETE_PREFIX = 'tag:';
export const isServerTagAutocompleteSource = (source: string): boolean =>
source.startsWith(SERVER_TAG_AUTOCOMPLETE_PREFIX);
export const getServerTagAutocompleteName = (source: string): null | string =>
isServerTagAutocompleteSource(source)
? source.slice(SERVER_TAG_AUTOCOMPLETE_PREFIX.length)
: null;
export const toServerTagAutocompleteSource = (tagName: string): string =>
`${SERVER_TAG_AUTOCOMPLETE_PREFIX}${tagName}`;
/**
* This schema is used for validation of the imported settings json
*/
@@ -771,6 +800,7 @@ export const ValidationSettingsStateSchema = z.object({
z.literal('window'),
z.string(),
]),
tagEditor: TagEditorSettingsSchema,
visualizer: VisualizerSettingsSchema,
window: WindowSettingsSchema,
});
@@ -1997,6 +2027,56 @@ const initialState: SettingsState = {
username: 'feishin',
},
tab: 'general',
tagEditor: {
tagConfigs: {
albumArtist: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: false,
},
ALBUMARTISTS: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
},
albumArtistSort: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: false,
},
ALBUMARTISTSSORT: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
},
artist: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
},
ARTISTS: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
},
artistSort: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
},
ARTISTSSORT: {
autocompleteSource: 'serverArtists',
customValues: [],
multiValue: true,
},
genre: {
autocompleteSource: 'serverGenres',
customValues: [],
multiValue: true,
},
},
triggerRescan: true,
},
visualizer: {
audiomotionanalyzer: {
alphaBars: false,
@@ -2692,6 +2772,8 @@ export const useCssSettings = () => useSettingsStore((state) => state.css, shall
export const useQueryBuilderSettings = () =>
useSettingsStore((state) => state.queryBuilder, shallow);
export const useTagEditorSettings = () => useSettingsStore((state) => state.tagEditor, shallow);
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
export const useSettingsForExport = (): SettingsState & { version: number } =>
+17
View File
@@ -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({
@@ -907,6 +920,7 @@ export const ssType = {
getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getScanStatus: getScanStatusParameters,
getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters,
@@ -923,6 +937,7 @@ export const ssType = {
setRating: setRatingParameters,
similarSongs: similarSongsParameters,
similarSongs2: similarSongs2Parameters,
startScan: startScanParameters,
structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters,
updateInternetRadioStation: updateInternetRadioStationParameters,
@@ -956,6 +971,7 @@ export const ssType = {
getMusicDirectory,
getPlaylist,
getPlaylists,
getScanStatus,
getSong,
getSongsByGenre,
getStarred,
@@ -981,6 +997,7 @@ export const ssType = {
similarSongs,
similarSongs2,
song,
startScan,
structuredLyrics,
topSongsList,
updateInternetRadioStation,
@@ -0,0 +1,49 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.section {
color: var(--theme-colors-foreground-muted);
}
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
}
.option {
position: relative;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.option:hover {
background: lighten(var(--theme-colors-surface), 5%);
}
@@ -0,0 +1,46 @@
import type { AutocompleteProps as MantineAutocompleteProps } from '@mantine/core';
import { Autocomplete as MantineAutocomplete } from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './autocomplete.module.css';
export interface AutocompleteProps extends MantineAutocompleteProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
(
{
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
}: AutocompleteProps,
ref,
) => {
return (
<MantineAutocomplete
classNames={{
dropdown: styles.dropdown,
input: styles.input,
label: styles.label,
option: styles.option,
root: styles.root,
section: styles.section,
...classNames,
}}
ref={ref}
size={size}
spellCheck={false}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
/>
);
},
);
+2
View File
@@ -125,6 +125,7 @@ import {
LuTimerOff,
LuTrash,
LuTriangleAlert,
LuUndo2,
LuUpload,
LuUser,
LuUserPen,
@@ -361,6 +362,7 @@ export const AppIcon = {
themeDark: LuMoon,
themeLight: LuSun,
track: LuMusic2,
undo: LuUndo2,
unfavorite: LuHeartCrack,
unpin: LuPinOff,
upload: LuUpload,
@@ -0,0 +1,66 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
& .pill {
background: var(--theme-colors-surface);
}
}
}
.input:focus-within {
border-color: lighten(var(--theme-colors-border), 10%);
}
.input-field {
color: inherit;
}
.option {
position: relative;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.option:hover {
background: lighten(var(--theme-colors-surface), 5%);
}
.pill {
font-size: var(--theme-font-size-sm);
background: var(--theme-colors-background);
border-radius: var(--theme-radius-sm);
}
.pills-list {
padding-right: var(--theme-spacing-lg);
}
.clear-button {
background: transparent !important;
}
@@ -0,0 +1,58 @@
import {
TagsInput as MantineTagsInput,
TagsInputProps as MantineTagsInputProps,
} from '@mantine/core';
import { CSSProperties, useMemo } from 'react';
import styles from './tags-input.module.css';
export interface TagsInputProps extends MantineTagsInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
const defaultClassNames = {
dropdown: styles.dropdown,
input: styles.input,
inputField: styles.inputField,
label: styles.label,
option: styles.option,
pill: styles.pill,
pillsList: styles.pillsList,
root: styles.root,
};
const defaultClearButtonProps = {
classNames: {
root: styles.clearButton,
},
variant: 'transparent' as const,
};
export const TagsInput = ({
classNames,
maxWidth,
variant = 'default',
width,
...props
}: TagsInputProps) => {
const mergedClassNames = useMemo(
() => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),
[classNames],
);
const style = useMemo(
() => (maxWidth || width ? { maxWidth, width } : undefined),
[maxWidth, width],
);
return (
<MantineTagsInput
classNames={mergedClassNames}
clearButtonProps={defaultClearButtonProps}
style={style}
variant={variant}
{...props}
/>
);
};
@@ -1,15 +1,20 @@
.root {
transition: width 0.3s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid transparent;
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
@@ -20,3 +25,7 @@
.label {
margin-bottom: var(--theme-spacing-sm);
}
.required {
color: var(--theme-colors-state-error);
}
+16 -1
View File
@@ -9,7 +9,19 @@ export interface TextareaProps extends MantineTextareaProps {
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ children, classNames, maxWidth, style, width, ...props }: TextareaProps, ref) => {
(
{
children,
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
}: TextareaProps,
ref,
) => {
return (
<MantineTextarea
classNames={{
@@ -21,7 +33,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
...classNames,
}}
ref={ref}
size={size}
spellCheck={false}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
+6
View File
@@ -1384,6 +1384,10 @@ export type RandomSongListQuery = {
export type RandomSongListResponse = SongListResponse;
export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
export type RefreshItemsResponse = null;
export type ScrobbleArgs = BaseEndpointArgs & {
query: ScrobbleQuery;
};
@@ -1577,6 +1581,7 @@ export type ControllerEndpoint = {
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
refreshItems: (args: RefreshItemsArgs) => Promise<RefreshItemsResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
@@ -1745,6 +1750,7 @@ export type InternalControllerEndpoint = {
args: ReplaceApiClientProps<JukeboxControlArgs>,
) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
refreshItems: (args: ReplaceApiClientProps<RefreshItemsArgs>) => Promise<RefreshItemsResponse>;
removeFromPlaylist: (
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
) => Promise<RemoveFromPlaylistResponse>;
+63
View File
@@ -0,0 +1,63 @@
export type ArtworkKind = 'common' | 'mixed' | 'none';
export type ArtworkOp = { bytes: Uint8Array; mimeType: string; type: 'set' } | { type: 'clear' };
export interface BatchFileError {
code?: string;
error: string;
path: string;
}
export interface BatchProgress {
processed: number;
total: number;
}
export interface FileArtworkData {
data: string;
mimeType: string;
}
export interface ReadLocalImageResult extends IpcResult {
data?: string;
mimeType?: string;
}
export interface ReadSongMetadataBatchResult extends IpcResult {
artworkData?: string;
artworkKind: ArtworkKind;
artworkMimeType?: string;
failedFiles?: BatchFileError[];
fileArtwork?: Record<string, FileArtworkData>;
fileTags?: Record<string, Record<string, TagValue>>;
multiValueKeys?: string[];
readCount?: number;
tagSummary?: Record<string, null | TagValue>;
totalCount?: number;
}
/** 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, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
) => Promise<WriteSongTagsBatchResult>;
}
export type TagValue = string | string[];
export interface WriteSongTagsBatchResult extends IpcResult {
failedFiles?: BatchFileError[];
}
interface IpcResult {
error?: string;
success: boolean;
}
+7
View File
@@ -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';
};
+6 -1
View File
@@ -1,7 +1,8 @@
import { ActionIconSize, PillVariant } from '@mantine/core';
import { ActionIconSize, PillVariant, TagsInputVariant } from '@mantine/core';
type ExtendedActionIconSize = 'compact-md' | 'compact-sm' | 'compact-xs' | ActionIconSize;
type ExtendedPillVariant = 'outline' | PillVariant;
type ExtendedTagsInputVariant = 'default' | 'filled' | TagsInputVariant;
declare module '@mantine/core' {
export interface ActionIconProps {
@@ -11,4 +12,8 @@ declare module '@mantine/core' {
export interface PillProps {
variant?: ExtendedPillVariant;
}
export interface TagsInputProps {
variant?: ExtendedTagsInputVariant;
}
}