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
+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,86 +14,106 @@ 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(() => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const randomSong = useQuery({
...songsQueries.random({
query: { limit: 1, played: Played.All },
serverId,
}),
gcTime: Infinity,
staleTime: Infinity,
});
interface PathSettingsProps {
persistImmediately?: boolean;
previewPath?: null | string;
}
const { pathReplace, pathReplaceWith } = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const resolvedPreviewPath = useResolvedSongPath(randomSong.data?.items[0]?.path);
const [localPathReplace, setLocalPathReplace] = useState(pathReplace);
const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);
useEffect(() => {
setLocalPathReplace(pathReplace);
}, [pathReplace]);
useEffect(() => {
setLocalPathReplaceWith(pathReplaceWith);
}, [pathReplaceWith]);
const debouncedSetPathReplace = useDebouncedCallback((value: string) => {
setSettings({
general: {
pathReplace: value,
},
export const PathSettings = memo(
({ persistImmediately = false, previewPath }: PathSettingsProps) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const randomSong = useQuery({
...songsQueries.random({
query: { limit: 1, played: Played.All },
serverId,
}),
enabled: previewPath === undefined,
gcTime: Infinity,
staleTime: Infinity,
});
}, 500);
const debouncedSetPathReplaceWith = useDebouncedCallback((value: string) => {
setSettings({
general: {
pathReplaceWith: value,
},
});
}, 500);
const { pathReplace, pathReplaceWith } = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const resolvedPreviewPath = useResolvedSongPath(
previewPath === undefined ? randomSong.data?.items[0]?.path : previewPath,
);
return (
<Stack>
<Group>
<Text>{t('setting.pathReplace')}</Text>
<ActionIcon
icon="refresh"
loading={randomSong.isFetching}
onClick={() => randomSong.refetch()}
size="xs"
variant="transparent"
/>
</Group>
<Code>
<Text isMuted size="md">
{resolvedPreviewPath || ''}
</Text>
</Code>
<Group grow>
<TextInput
onChange={(e) => {
const value = e.currentTarget.value;
setLocalPathReplace(value);
debouncedSetPathReplace(value);
}}
placeholder={t('setting.pathReplace_optionRemovePrefix')}
value={localPathReplace}
/>
<TextInput
onChange={(e) => {
const value = e.currentTarget.value;
setLocalPathReplaceWith(value);
debouncedSetPathReplaceWith(value);
}}
placeholder={t('setting.pathReplace_optionAddPrefix')}
value={localPathReplaceWith}
/>
</Group>
</Stack>
);
});
const [localPathReplace, setLocalPathReplace] = useState(pathReplace);
const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);
useEffect(() => {
setLocalPathReplace(pathReplace);
}, [pathReplace]);
useEffect(() => {
setLocalPathReplaceWith(pathReplaceWith);
}, [pathReplaceWith]);
const debouncedSetPathReplace = useDebouncedCallback((value: string) => {
setSettings({
general: {
pathReplace: value,
},
});
}, 500);
const debouncedSetPathReplaceWith = useDebouncedCallback((value: string) => {
setSettings({
general: {
pathReplaceWith: value,
},
});
}, 500);
return (
<Stack>
<Group>
<Text>{t('setting.pathReplace')}</Text>
{previewPath === undefined && (
<ActionIcon
icon="refresh"
loading={randomSong.isFetching}
onClick={() => randomSong.refetch()}
size="xs"
variant="transparent"
/>
)}
</Group>
<Code>
<Text isMuted size="md">
{resolvedPreviewPath || ''}
</Text>
</Code>
<Group grow>
<TextInput
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}
/>
<TextInput
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}
/>
</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 } =>