support parsing and saving multi-valued tags

This commit is contained in:
jeffvli
2026-07-16 20:55:08 -07:00
parent 38ad3e5d5a
commit 48ff8ed6ff
10 changed files with 401 additions and 63 deletions
@@ -8,6 +8,7 @@ import { useMetadataEditor } from '../hooks/use-metadata-editor';
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
@@ -116,6 +117,9 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
<Tabs.Tab className={styles.tabLabel} value="artwork">
{t('page.itemDetail.artworkTab', 'Artwork')}
</Tabs.Tab>
<Tabs.Tab className={styles.tabLabel} value="settings">
{t('common.settings', 'Settings')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tags">
@@ -175,8 +179,13 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
<Table.Tbody>
{editor.sortedFieldEntries.map(([key, value]) => (
<TagFieldRow
favoriteValues={editor.favoriteValues[key] ?? []}
isDirty={key in editor.editedFields}
isMixed={editor.mixedKeys.has(key)}
isMultiValue={
editor.multiValueKeys.has(key) ||
Array.isArray(value)
}
key={key}
meta={editor.getFieldMeta(key)}
mixedPlaceholder={
@@ -184,6 +193,9 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
? editor.mixedPlaceholder
: undefined
}
onAddFavorite={(value) =>
editor.handleAddFavoriteValue(key, value)
}
onChange={(v) => editor.handleFieldChange(key, v)}
onRemove={() => editor.handleRemoveField(key)}
tagKey={key}
@@ -212,6 +224,10 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
showRemoveButton={editor.showRemoveArtworkButton}
/>
</Tabs.Panel>
<Tabs.Panel value="settings">
<TagEditorSettings />
</Tabs.Panel>
</Tabs>
<Checkbox
@@ -0,0 +1,119 @@
import { Autocomplete } from '@mantine/core';
import { 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 { useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Fieldset } from '/@/shared/components/fieldset/fieldset';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
import { Text } from '/@/shared/components/text/text';
export const TagEditorSettings = () => {
const { t } = useTranslation();
const { favoriteValues, multiValueFields } = useTagEditorSettings();
const { setSettings } = useSettingsStoreActions();
const [input, setInput] = useState('');
const updateFields = (fields: string[]) => {
setSettings({ tagEditor: { multiValueFields: fields } });
};
const updateFavoriteValues = (key: string, values: string[]) => {
setSettings({
tagEditor: {
favoriteValues: { ...favoriteValues, [key]: values },
},
});
};
const addField = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
const key = resolveTagKey(trimmed);
if (key === 'lyrics' || multiValueFields.includes(key)) {
setInput('');
return;
}
updateFields([...multiValueFields, key]);
setInput('');
};
const availableFields = KNOWN_TAGS.filter(
({ key }) => key !== 'lyrics' && !multiValueFields.includes(key),
)
.map(({ key, label }) => ({ label, value: key }))
.sort((a, b) => a.label.localeCompare(b.label));
return (
<Stack gap="xs">
<Text fw={500} size="md">
{t('page.itemDetail.multiValueFields')}
</Text>
<Text isMuted size="sm">
{t('page.itemDetail.multiValueFieldsDescription')}
</Text>
<Autocomplete
data={availableFields}
onChange={setInput}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addField(input);
}
}}
onOptionSubmit={addField}
placeholder={t('page.itemDetail.addMultiValueField')}
py="md"
rightSection={
<ActionIcon onClick={() => addField(input)} variant="filled">
<RiAddLine size={16} />
</ActionIcon>
}
rightSectionPointerEvents="all"
value={input}
/>
{multiValueFields.map((key) => {
const label = KNOWN_TAG_MAP.get(key)?.label ?? key;
return (
<Fieldset key={key}>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="sm">{label}</Text>
<Text ff="monospace" isMuted size="xs">
{key}
</Text>
</div>
<ActionIcon
aria-label={t('common.remove', 'Remove')}
onClick={() =>
updateFields(
multiValueFields.filter((field) => field !== key),
)
}
variant="subtle"
>
<RiCloseLine size={16} />
</ActionIcon>
</Group>
<TagsInput
aria-label={`${t('page.itemDetail.favoriteValues')} - ${label}`}
onChange={(values) => updateFavoriteValues(key, values)}
placeholder={t('page.itemDetail.addFavoriteValue')}
splitChars={[]}
value={favoriteValues[key] ?? []}
/>
</Stack>
</Fieldset>
);
})}
</Stack>
);
};
@@ -1,3 +1,7 @@
import type { TagValue } from '/@/shared/types/tag-editor';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiCloseLine } from 'react-icons/ri';
import type { KnownTag } from '../utils/known-tags';
@@ -8,25 +12,104 @@ import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Table } from '/@/shared/components/table/table';
import { 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 TagFieldRowProps {
isDirty?: boolean;
isMixed: boolean;
meta: KnownTag;
interface FavoriteTagsInputProps {
favoriteValues: string[];
mixedPlaceholder?: string;
onChange: (value: string) => void;
onRemove: () => void;
tagKey: string;
value: string;
onAddFavorite: (value: string) => void;
onChange: (value: string[]) => void;
value: string[];
}
interface TagFieldRowProps {
favoriteValues: string[];
isDirty?: boolean;
isMixed: boolean;
isMultiValue: boolean;
meta: KnownTag;
mixedPlaceholder?: string;
onAddFavorite: (value: string) => void;
onChange: (value: TagValue) => void;
onRemove: () => void;
tagKey: string;
value: TagValue;
}
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
const FavoriteTagsInput = ({
favoriteValues,
mixedPlaceholder,
onAddFavorite,
onChange,
value,
}: FavoriteTagsInputProps) => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
const candidate = searchValue.trim();
const canAddFavorite =
candidate.length > 0 &&
!favoriteValues.some((favorite) => favorite.toLowerCase() === candidate.toLowerCase());
const data = useMemo(
() =>
canAddFavorite
? [
...favoriteValues,
{
label: t('page.itemDetail.addFavoriteValueOption', {
value: candidate,
}),
value: `${ADD_FAVORITE_PREFIX}${candidate}`,
},
]
: favoriteValues,
[canAddFavorite, candidate, favoriteValues, t],
);
return (
<TagsInput
clearable
data={data}
onChange={(values) => {
const normalizedValues = values.map((item) =>
item.startsWith(ADD_FAVORITE_PREFIX)
? item.slice(ADD_FAVORITE_PREFIX.length)
: item,
);
onChange(
normalizedValues.filter(
(item, index) =>
normalizedValues.findIndex(
(other) => other.toLowerCase() === item.toLowerCase(),
) === index,
),
);
}}
onOptionSubmit={(submittedValue) => {
if (submittedValue.startsWith(ADD_FAVORITE_PREFIX))
onAddFavorite(submittedValue.slice(ADD_FAVORITE_PREFIX.length));
}}
onSearchChange={setSearchValue}
placeholder={mixedPlaceholder}
searchValue={searchValue}
size="sm"
splitChars={[]}
value={value}
/>
);
};
export const TagFieldRow = ({
favoriteValues,
isDirty,
isMixed,
isMultiValue,
meta,
mixedPlaceholder,
onAddFavorite,
onChange,
onRemove,
tagKey,
@@ -35,7 +118,15 @@ export const TagFieldRow = ({
<Table.Tr data-field-key={tagKey} key={tagKey}>
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
<Table.Td>
{meta.type === 'textarea' ? (
{isMultiValue && tagKey !== 'lyrics' ? (
<FavoriteTagsInput
favoriteValues={favoriteValues}
mixedPlaceholder={mixedPlaceholder}
onAddFavorite={onAddFavorite}
onChange={onChange}
value={Array.isArray(value) ? value : value ? [value] : []}
/>
) : meta.type === 'textarea' ? (
<Textarea
autosize
maxRows={6}
@@ -43,14 +134,16 @@ export const TagFieldRow = ({
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={value}
value={Array.isArray(value) ? value.join('\n\n') : value}
/>
) : meta.type === 'number' ? (
<NumberInput
onChange={(v) => onChange(v === undefined ? '' : String(v))}
placeholder={mixedPlaceholder}
size="sm"
value={isMixed || value === '' ? undefined : Number(value)}
value={
isMixed || value === '' || Array.isArray(value) ? undefined : Number(value)
}
/>
) : meta.type === 'boolean' ? (
<Checkbox
@@ -64,7 +157,7 @@ export const TagFieldRow = ({
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={value}
value={Array.isArray(value) ? value.join('; ') : value}
/>
)}
</Table.Td>
@@ -3,6 +3,7 @@ import type {
ArtworkOp,
BatchProgress,
TagEditorUtils,
TagValue,
} from '/@/shared/types/tag-editor';
import { closeAllModals } from '@mantine/modals';
@@ -13,7 +14,7 @@ import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../uti
import { base64ToBytes, formatBatchFileErrors } from '../utils/utils';
import { controller } from '/@/renderer/api/controller';
import { useCurrentServer } from '/@/renderer/store';
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';
@@ -49,6 +50,8 @@ interface UseMetadataEditorArgs {
export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetadataEditorArgs) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { favoriteValues, multiValueFields } = useTagEditorSettings();
const { setSettings } = useSettingsStoreActions();
const [isLoading, setIsLoading] = useState(true);
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
@@ -57,8 +60,9 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
const [rescan, setRescan] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [tagSummary, setTagSummary] = useState<Record<string, null | string>>({});
const [editedFields, setEditedFields] = useState<Record<string, string>>({});
const [tagSummary, setTagSummary] = useState<Record<string, null | TagValue>>({});
const [editedFields, setEditedFields] = useState<Record<string, TagValue>>({});
const [parsedMultiValueKeys, setParsedMultiValueKeys] = useState<Set<string>>(new Set());
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
const [loadedArtwork, setLoadedArtwork] = useState<{ kind: ArtworkKind }>({ kind: 'none' });
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
@@ -70,13 +74,18 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
* 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);
for (const k of removedKeys) allKeys.delete(k);
const displayFields: Record<string, string> = {};
const displayFields: Record<string, TagValue> = {};
const mixedKeys = new Set<string>();
for (const key of allKeys) {
@@ -87,7 +96,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const summaryVal = tagSummary[key];
if (summaryVal === null) {
mixedKeys.add(key);
displayFields[key] = '';
displayFields[key] = multiValueKeys.has(key) ? [] : '';
} else if (summaryVal !== undefined) {
displayFields[key] = summaryVal;
}
@@ -105,7 +114,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
});
return { displayFields, mixedKeys, sortedFieldEntries };
}, [tagSummary, editedFields, removedKeys]);
}, [tagSummary, editedFields, multiValueKeys, removedKeys]);
/**
* Runs once on mount: reads metadata for all songs in batch and populates
@@ -147,6 +156,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
}
setTagSummary(batchResult.tagSummary);
setParsedMultiValueKeys(new Set(batchResult.multiValueKeys ?? []));
if (
batchResult.artworkKind === 'common' &&
@@ -171,10 +181,31 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
}, []);
/** Records an edited value for `key`, overriding the on-disk summary. */
const handleFieldChange = useCallback((key: string, value: string) => {
const handleFieldChange = useCallback((key: string, value: TagValue) => {
setEditedFields((prev) => ({ ...prev, [key]: value }));
}, []);
const handleAddFavoriteValue = useCallback(
(key: string, value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
const currentValues = favoriteValues[key] ?? [];
if (currentValues.some((favorite) => favorite.toLowerCase() === trimmed.toLowerCase()))
return;
setSettings({
tagEditor: {
favoriteValues: {
...favoriteValues,
[key]: [...currentValues, trimmed],
},
},
});
},
[favoriteValues, setSettings],
);
/** Removes `key` from both `editedFields` and the display, marking it for deletion on save. */
const handleRemoveField = useCallback((key: string) => {
setEditedFields((prev) => {
@@ -192,20 +223,23 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
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);
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
if (alreadyVisible) return prev;
return { ...prev, [key]: '' };
});
setRemovedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, []);
const handleAddField = useCallback(
(key: null | string) => {
if (!key) return;
setEditedFields((prev) => {
const wasRemoved = removedKeysRef.current.has(key);
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) => {
@@ -249,7 +283,10 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
const emptyFields = Object.entries(editedFields)
.filter(([key, value]) => {
const meta = KNOWN_TAG_MAP.get(key);
return meta?.type !== 'boolean' && value.trim() === '';
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)?.label ?? key);
@@ -337,7 +374,9 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
availableToAdd,
editedFields,
error,
favoriteValues,
getFieldMeta,
handleAddFavoriteValue,
handleAddField,
handleChangeArtwork,
handleFieldChange,
@@ -349,6 +388,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
loadProgress,
mixedKeys,
mixedPlaceholder,
multiValueKeys,
readWarning,
rescan,
setRescan,
+12
View File
@@ -744,6 +744,11 @@ const AutoDJSettingsSchema = z.object({
timing: z.number(),
});
const TagEditorSettingsSchema = z.object({
favoriteValues: z.record(z.string(), z.array(z.string())),
multiValueFields: z.array(z.string()),
});
/**
* This schema is used for validation of the imported settings json
*/
@@ -767,6 +772,7 @@ export const ValidationSettingsStateSchema = z.object({
z.literal('window'),
z.string(),
]),
tagEditor: TagEditorSettingsSchema,
visualizer: VisualizerSettingsSchema,
window: WindowSettingsSchema,
});
@@ -1988,6 +1994,10 @@ const initialState: SettingsState = {
username: 'feishin',
},
tab: 'general',
tagEditor: {
favoriteValues: {},
multiValueFields: ['artist', 'albumArtist', 'genre', 'composer', 'lyricist'],
},
visualizer: {
audiomotionanalyzer: {
alphaBars: false,
@@ -2677,6 +2687,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 } =>