From 48ff8ed6ffa11ccf15f71da76e2e228106a815e7 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 16 Jul 2026 20:55:08 -0700 Subject: [PATCH] support parsing and saving multi-valued tags --- src/i18n/locales/en.json | 5 + src/main/features/core/tag-editor/index.ts | 4 +- .../core/tag-editor/taglib-service.ts | 97 ++++++++++---- src/preload/utils.ts | 3 +- .../tag-editor/components/song-edit-modal.tsx | 16 +++ .../components/tag-editor-settings.tsx | 119 ++++++++++++++++++ .../tag-editor/components/tag-field-row.tsx | 117 +++++++++++++++-- .../tag-editor/hooks/use-metadata-editor.ts | 84 +++++++++---- src/renderer/store/settings.store.ts | 12 ++ src/shared/types/tag-editor.ts | 7 +- 10 files changed, 401 insertions(+), 63 deletions(-) create mode 100644 src/renderer/features/tag-editor/components/tag-editor-settings.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3ec9efd9a..8069f9c02 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -603,6 +603,11 @@ "title": "$t(common.home)" }, "itemDetail": { + "multiValueFields": "Multi-value fields", + "multiValueFieldsDescription": "Fields in this list use a tag input and are saved as separate metadata values. Parsed multivalue fields use it automatically. Lyrics are always handled separately.", + "addMultiValueField": "Add multivalue field…", + "addFavoriteValue": "Add favorite value…", + "addFavoriteValueOption": "Add \"{{value}}\" as a favorite…", "copyPath": "Copy path to clipboard", "copiedPath": "Path copied successfully", "openFile": "Show track in file manager", diff --git a/src/main/features/core/tag-editor/index.ts b/src/main/features/core/tag-editor/index.ts index 7fdf67ff8..659c6b4a5 100644 --- a/src/main/features/core/tag-editor/index.ts +++ b/src/main/features/core/tag-editor/index.ts @@ -1,6 +1,7 @@ import type { ArtworkOp, ReadSongMetadataBatchResult, + TagValue, WriteSongTagsBatchResult, } from '/@/shared/types/tag-editor'; @@ -51,6 +52,7 @@ ipcMain.handle( return { artworkKind: result.artworkKind, failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined, + multiValueKeys: result.multiValueKeys, readCount: result.readCount, success: true, tagSummary: result.tagSummary, @@ -75,7 +77,7 @@ ipcMain.handle( async ( event, filePaths: string[], - edits: Record, + edits: Record, removed: string[], artworkOp?: ArtworkOp, ): Promise => { diff --git a/src/main/features/core/tag-editor/taglib-service.ts b/src/main/features/core/tag-editor/taglib-service.ts index e03906af3..450625d33 100644 --- a/src/main/features/core/tag-editor/taglib-service.ts +++ b/src/main/features/core/tag-editor/taglib-service.ts @@ -1,4 +1,4 @@ -import type { ArtworkKind, ArtworkOp, BatchFileError } from '/@/shared/types/tag-editor'; +import type { ArtworkKind, ArtworkOp, BatchFileError, TagValue } from '/@/shared/types/tag-editor'; import { constants, promises as fsPromises } from 'fs'; import { PROPERTIES, TagLib } from 'taglib-wasm'; @@ -41,27 +41,41 @@ export async function readLocalImageFile(filePath: string) { }; } -/** Flattens a taglib-wasm PropertyMap to a string record, dropping ALL_CAPS alias keys already covered by a camelCase equivalent. */ -function flattenProperties(props: Record): Record { - const flat: Record = {}; +/** Normalizes a taglib-wasm PropertyMap while preserving true multivalue arrays. */ +function normalizeProperties( + props: Record, +): Record { + const normalized: Record = {}; for (const [key, values] of Object.entries(props)) { if (values && values.length > 0 && values[0] !== '') { - flat[key] = values.join('; '); + normalized[key] = values.length === 1 ? values[0] : [...values]; } } const coveredByUpperCase = new Set( - Object.keys(flat) + Object.keys(normalized) .filter((k) => k !== k.toUpperCase()) .map((k) => k.toUpperCase()), ); - for (const key of Object.keys(flat)) { + for (const key of Object.keys(normalized)) { if (key === key.toUpperCase() && coveredByUpperCase.has(key)) { - delete flat[key]; + delete normalized[key]; } } - return flat; + 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 ( items: T[], @@ -96,14 +110,16 @@ export async function readFilesMetadataBatch( artworkKind: ArtworkKind; artworkMimeType?: string; failedFiles: BatchFileError[]; + multiValueKeys: string[]; readCount: number; success: boolean; - tagSummary: Record; + tagSummary: Record; totalCount: number; }> { const totalCount = filePaths.length; const failedFiles: BatchFileError[] = []; - const tagSummary: Record = {}; + const multiValueKeys = new Set(); + const tagSummary: Record = {}; let artworkKind = 'none' as ArtworkKind; let artworkByteSize: number | undefined; let artworkData: string | undefined; @@ -127,14 +143,17 @@ export async function readFilesMetadataBatch( embeddedLyrics.map(({ text }) => text).join('\n\n'), ]; } - const flat = flattenProperties(rawProperties); + const normalized = normalizeProperties(rawProperties); + 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 (readCount === 0) { - Object.assign(tagSummary, flat); + Object.assign(tagSummary, normalized); artworkKind = hasCoverArt ? 'common' : 'none'; artworkByteSize = picSize; if (frontCover) { @@ -143,10 +162,13 @@ export async function readFilesMetadataBatch( } } else { for (const k of Object.keys(tagSummary)) { - if (tagSummary[k] !== null && flat[k] !== tagSummary[k]) + if ( + tagSummary[k] !== null && + !tagValuesEqual(tagSummary[k], normalized[k]) + ) tagSummary[k] = null; } - for (const k of Object.keys(flat)) { + for (const k of Object.keys(normalized)) { if (!(k in tagSummary)) tagSummary[k] = null; } if (artworkKind !== 'mixed') { @@ -178,6 +200,7 @@ export async function readFilesMetadataBatch( return { artworkKind, failedFiles, + multiValueKeys: [...multiValueKeys], readCount, success: readCount > 0, tagSummary, @@ -189,11 +212,12 @@ export async function readFilesMetadataBatch( /** * Writes tag edits and/or artwork to a batch of audio files in-place via taglib-wasm (WASI). * Only the fields present in `edits`/`removed` are touched — all other existing tags are preserved. - * Fields in `removed` are written as empty strings, which clears them in TagLib. + * 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, + edits: Record, removed: string[], artworkOp?: ArtworkOp, onProgress?: (processed: number, total: number) => void, @@ -208,12 +232,7 @@ export async function writeFilesTags( return { failedFiles, success: false }; } - const mergedEdits: Record = { ...edits }; - for (const key of removed) { - mergedEdits[key] = ''; - } - - const hasEdits = Object.keys(mergedEdits).length > 0; + const hasEdits = Object.keys(edits).length > 0 || removed.length > 0; if (!hasEdits && !artworkOp) { return { failedFiles, success: failedFiles.length === 0 }; @@ -226,9 +245,37 @@ export async function writeFilesTags( await mapWithConcurrency(writablePaths, BATCH_CONCURRENCY, async (path) => { try { await taglib.edit(path, (file) => { - for (const [k, v] of Object.entries(mergedEdits)) { - file.setProperty(k in PROPERTIES ? k : k.toUpperCase(), v); + 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) { + const propertyKey = key in PROPERTIES ? key : key.toUpperCase(); + properties[propertyKey] = Array.isArray(value) ? value : [value]; + } + + for (const key of propertyRemovals) { + const propertyKey = key in PROPERTIES ? key : key.toUpperCase(); + delete properties[propertyKey]; + } + + 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') { diff --git a/src/preload/utils.ts b/src/preload/utils.ts index a6c344d7d..a530e7e8d 100644 --- a/src/preload/utils.ts +++ b/src/preload/utils.ts @@ -5,6 +5,7 @@ import type { BatchProgress, ReadLocalImageResult, ReadSongMetadataBatchResult, + TagValue, WriteSongTagsBatchResult, } from '../shared/types/tag-editor'; @@ -24,7 +25,7 @@ const readSongMetadataBatch = (filePaths: string[]): Promise, + edits: Record, removed: string[], artworkOp?: ArtworkOp, ): Promise => { diff --git a/src/renderer/features/tag-editor/components/song-edit-modal.tsx b/src/renderer/features/tag-editor/components/song-edit-modal.tsx index aeff3f773..f8aadec6d 100644 --- a/src/renderer/features/tag-editor/components/song-edit-modal.tsx +++ b/src/renderer/features/tag-editor/components/song-edit-modal.tsx @@ -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[] }) => { {t('page.itemDetail.artworkTab', 'Artwork')} + + {t('common.settings', 'Settings')} + @@ -175,8 +179,13 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => { {editor.sortedFieldEntries.map(([key, value]) => ( { ? 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} /> + + + + { + 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 ( + + + {t('page.itemDetail.multiValueFields')} + + + {t('page.itemDetail.multiValueFieldsDescription')} + + { + if (event.key === 'Enter') { + event.preventDefault(); + addField(input); + } + }} + onOptionSubmit={addField} + placeholder={t('page.itemDetail.addMultiValueField')} + py="md" + rightSection={ + addField(input)} variant="filled"> + + + } + rightSectionPointerEvents="all" + value={input} + /> + {multiValueFields.map((key) => { + const label = KNOWN_TAG_MAP.get(key)?.label ?? key; + return ( +
+ + +
+ {label} + + {key} + +
+ + updateFields( + multiValueFields.filter((field) => field !== key), + ) + } + variant="subtle" + > + + +
+ updateFavoriteValues(key, values)} + placeholder={t('page.itemDetail.addFavoriteValue')} + splitChars={[]} + value={favoriteValues[key] ?? []} + /> +
+
+ ); + })} +
+ ); +}; diff --git a/src/renderer/features/tag-editor/components/tag-field-row.tsx b/src/renderer/features/tag-editor/components/tag-field-row.tsx index 51bcd616f..1c63d5bf7 100644 --- a/src/renderer/features/tag-editor/components/tag-field-row.tsx +++ b/src/renderer/features/tag-editor/components/tag-field-row.tsx @@ -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 ( + { + 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 = ({ {meta.label} - {meta.type === 'textarea' ? ( + {isMultiValue && tagKey !== 'lyrics' ? ( + + ) : meta.type === 'textarea' ? (