mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-23 19:06:32 +02:00
support parsing and saving multi-valued tags
This commit is contained in:
@@ -603,6 +603,11 @@
|
|||||||
"title": "$t(common.home)"
|
"title": "$t(common.home)"
|
||||||
},
|
},
|
||||||
"itemDetail": {
|
"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",
|
"copyPath": "Copy path to clipboard",
|
||||||
"copiedPath": "Path copied successfully",
|
"copiedPath": "Path copied successfully",
|
||||||
"openFile": "Show track in file manager",
|
"openFile": "Show track in file manager",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ArtworkOp,
|
ArtworkOp,
|
||||||
ReadSongMetadataBatchResult,
|
ReadSongMetadataBatchResult,
|
||||||
|
TagValue,
|
||||||
WriteSongTagsBatchResult,
|
WriteSongTagsBatchResult,
|
||||||
} from '/@/shared/types/tag-editor';
|
} from '/@/shared/types/tag-editor';
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ ipcMain.handle(
|
|||||||
return {
|
return {
|
||||||
artworkKind: result.artworkKind,
|
artworkKind: result.artworkKind,
|
||||||
failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined,
|
failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined,
|
||||||
|
multiValueKeys: result.multiValueKeys,
|
||||||
readCount: result.readCount,
|
readCount: result.readCount,
|
||||||
success: true,
|
success: true,
|
||||||
tagSummary: result.tagSummary,
|
tagSummary: result.tagSummary,
|
||||||
@@ -75,7 +77,7 @@ ipcMain.handle(
|
|||||||
async (
|
async (
|
||||||
event,
|
event,
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
edits: Record<string, string>,
|
edits: Record<string, TagValue>,
|
||||||
removed: string[],
|
removed: string[],
|
||||||
artworkOp?: ArtworkOp,
|
artworkOp?: ArtworkOp,
|
||||||
): Promise<WriteSongTagsBatchResult> => {
|
): Promise<WriteSongTagsBatchResult> => {
|
||||||
|
|||||||
@@ -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 { constants, promises as fsPromises } from 'fs';
|
||||||
import { PROPERTIES, TagLib } from 'taglib-wasm';
|
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. */
|
/** Normalizes a taglib-wasm PropertyMap while preserving true multivalue arrays. */
|
||||||
function flattenProperties(props: Record<string, string[] | undefined>): Record<string, string> {
|
function normalizeProperties(
|
||||||
const flat: Record<string, string> = {};
|
props: Record<string, string[] | undefined>,
|
||||||
|
): Record<string, TagValue> {
|
||||||
|
const normalized: Record<string, TagValue> = {};
|
||||||
for (const [key, values] of Object.entries(props)) {
|
for (const [key, values] of Object.entries(props)) {
|
||||||
if (values && values.length > 0 && values[0] !== '') {
|
if (values && values.length > 0 && values[0] !== '') {
|
||||||
flat[key] = values.join('; ');
|
normalized[key] = values.length === 1 ? values[0] : [...values];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const coveredByUpperCase = new Set(
|
const coveredByUpperCase = new Set(
|
||||||
Object.keys(flat)
|
Object.keys(normalized)
|
||||||
.filter((k) => k !== k.toUpperCase())
|
.filter((k) => k !== k.toUpperCase())
|
||||||
.map((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)) {
|
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. */
|
/** Runs `fn` over `items` with at most `concurrency` tasks in flight at once. Stops early if `signal` is aborted. */
|
||||||
const mapWithConcurrency = async <T>(
|
const mapWithConcurrency = async <T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
@@ -96,14 +110,16 @@ export async function readFilesMetadataBatch(
|
|||||||
artworkKind: ArtworkKind;
|
artworkKind: ArtworkKind;
|
||||||
artworkMimeType?: string;
|
artworkMimeType?: string;
|
||||||
failedFiles: BatchFileError[];
|
failedFiles: BatchFileError[];
|
||||||
|
multiValueKeys: string[];
|
||||||
readCount: number;
|
readCount: number;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
tagSummary: Record<string, null | string>;
|
tagSummary: Record<string, null | TagValue>;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
}> {
|
}> {
|
||||||
const totalCount = filePaths.length;
|
const totalCount = filePaths.length;
|
||||||
const failedFiles: BatchFileError[] = [];
|
const failedFiles: BatchFileError[] = [];
|
||||||
const tagSummary: Record<string, null | string> = {};
|
const multiValueKeys = new Set<string>();
|
||||||
|
const tagSummary: Record<string, null | TagValue> = {};
|
||||||
let artworkKind = 'none' as ArtworkKind;
|
let artworkKind = 'none' as ArtworkKind;
|
||||||
let artworkByteSize: number | undefined;
|
let artworkByteSize: number | undefined;
|
||||||
let artworkData: string | undefined;
|
let artworkData: string | undefined;
|
||||||
@@ -127,14 +143,17 @@ export async function readFilesMetadataBatch(
|
|||||||
embeddedLyrics.map(({ text }) => text).join('\n\n'),
|
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 pictures = file.getPictures();
|
||||||
const frontCover = pictures.find((p) => p.type === 'FrontCover') ?? pictures[0];
|
const frontCover = pictures.find((p) => p.type === 'FrontCover') ?? pictures[0];
|
||||||
const hasCoverArt = frontCover !== undefined;
|
const hasCoverArt = frontCover !== undefined;
|
||||||
const picSize = hasCoverArt ? frontCover.data.length : undefined;
|
const picSize = hasCoverArt ? frontCover.data.length : undefined;
|
||||||
|
|
||||||
if (readCount === 0) {
|
if (readCount === 0) {
|
||||||
Object.assign(tagSummary, flat);
|
Object.assign(tagSummary, normalized);
|
||||||
artworkKind = hasCoverArt ? 'common' : 'none';
|
artworkKind = hasCoverArt ? 'common' : 'none';
|
||||||
artworkByteSize = picSize;
|
artworkByteSize = picSize;
|
||||||
if (frontCover) {
|
if (frontCover) {
|
||||||
@@ -143,10 +162,13 @@ export async function readFilesMetadataBatch(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const k of Object.keys(tagSummary)) {
|
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;
|
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 (!(k in tagSummary)) tagSummary[k] = null;
|
||||||
}
|
}
|
||||||
if (artworkKind !== 'mixed') {
|
if (artworkKind !== 'mixed') {
|
||||||
@@ -178,6 +200,7 @@ export async function readFilesMetadataBatch(
|
|||||||
return {
|
return {
|
||||||
artworkKind,
|
artworkKind,
|
||||||
failedFiles,
|
failedFiles,
|
||||||
|
multiValueKeys: [...multiValueKeys],
|
||||||
readCount,
|
readCount,
|
||||||
success: readCount > 0,
|
success: readCount > 0,
|
||||||
tagSummary,
|
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).
|
* 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.
|
* 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(
|
export async function writeFilesTags(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
edits: Record<string, string>,
|
edits: Record<string, TagValue>,
|
||||||
removed: string[],
|
removed: string[],
|
||||||
artworkOp?: ArtworkOp,
|
artworkOp?: ArtworkOp,
|
||||||
onProgress?: (processed: number, total: number) => void,
|
onProgress?: (processed: number, total: number) => void,
|
||||||
@@ -208,12 +232,7 @@ export async function writeFilesTags(
|
|||||||
return { failedFiles, success: false };
|
return { failedFiles, success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedEdits: Record<string, string> = { ...edits };
|
const hasEdits = Object.keys(edits).length > 0 || removed.length > 0;
|
||||||
for (const key of removed) {
|
|
||||||
mergedEdits[key] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasEdits = Object.keys(mergedEdits).length > 0;
|
|
||||||
|
|
||||||
if (!hasEdits && !artworkOp) {
|
if (!hasEdits && !artworkOp) {
|
||||||
return { failedFiles, success: failedFiles.length === 0 };
|
return { failedFiles, success: failedFiles.length === 0 };
|
||||||
@@ -226,9 +245,37 @@ export async function writeFilesTags(
|
|||||||
await mapWithConcurrency(writablePaths, BATCH_CONCURRENCY, async (path) => {
|
await mapWithConcurrency(writablePaths, BATCH_CONCURRENCY, async (path) => {
|
||||||
try {
|
try {
|
||||||
await taglib.edit(path, (file) => {
|
await taglib.edit(path, (file) => {
|
||||||
for (const [k, v] of Object.entries(mergedEdits)) {
|
const propertyEdits = Object.entries(edits).filter(([key]) => key !== 'lyrics');
|
||||||
file.setProperty(k in PROPERTIES ? k : k.toUpperCase(), v);
|
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') {
|
if (artworkOp?.type === 'clear') {
|
||||||
file.removePictures();
|
file.removePictures();
|
||||||
} else if (artworkOp?.type === 'set') {
|
} else if (artworkOp?.type === 'set') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
BatchProgress,
|
BatchProgress,
|
||||||
ReadLocalImageResult,
|
ReadLocalImageResult,
|
||||||
ReadSongMetadataBatchResult,
|
ReadSongMetadataBatchResult,
|
||||||
|
TagValue,
|
||||||
WriteSongTagsBatchResult,
|
WriteSongTagsBatchResult,
|
||||||
} from '../shared/types/tag-editor';
|
} from '../shared/types/tag-editor';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ const readSongMetadataBatch = (filePaths: string[]): Promise<ReadSongMetadataBat
|
|||||||
|
|
||||||
const writeSongTagsBatch = (
|
const writeSongTagsBatch = (
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
edits: Record<string, string>,
|
edits: Record<string, TagValue>,
|
||||||
removed: string[],
|
removed: string[],
|
||||||
artworkOp?: ArtworkOp,
|
artworkOp?: ArtworkOp,
|
||||||
): Promise<WriteSongTagsBatchResult> => {
|
): Promise<WriteSongTagsBatchResult> => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
|||||||
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
|
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
|
||||||
import { ArtworkPanel } from './artwork-panel';
|
import { ArtworkPanel } from './artwork-panel';
|
||||||
import styles from './song-edit-modal.module.css';
|
import styles from './song-edit-modal.module.css';
|
||||||
|
import { TagEditorSettings } from './tag-editor-settings';
|
||||||
import { TagFieldRow } from './tag-field-row';
|
import { TagFieldRow } from './tag-field-row';
|
||||||
|
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
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">
|
<Tabs.Tab className={styles.tabLabel} value="artwork">
|
||||||
{t('page.itemDetail.artworkTab', 'Artwork')}
|
{t('page.itemDetail.artworkTab', 'Artwork')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab className={styles.tabLabel} value="settings">
|
||||||
|
{t('common.settings', 'Settings')}
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="tags">
|
<Tabs.Panel value="tags">
|
||||||
@@ -175,8 +179,13 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{editor.sortedFieldEntries.map(([key, value]) => (
|
{editor.sortedFieldEntries.map(([key, value]) => (
|
||||||
<TagFieldRow
|
<TagFieldRow
|
||||||
|
favoriteValues={editor.favoriteValues[key] ?? []}
|
||||||
isDirty={key in editor.editedFields}
|
isDirty={key in editor.editedFields}
|
||||||
isMixed={editor.mixedKeys.has(key)}
|
isMixed={editor.mixedKeys.has(key)}
|
||||||
|
isMultiValue={
|
||||||
|
editor.multiValueKeys.has(key) ||
|
||||||
|
Array.isArray(value)
|
||||||
|
}
|
||||||
key={key}
|
key={key}
|
||||||
meta={editor.getFieldMeta(key)}
|
meta={editor.getFieldMeta(key)}
|
||||||
mixedPlaceholder={
|
mixedPlaceholder={
|
||||||
@@ -184,6 +193,9 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
? editor.mixedPlaceholder
|
? editor.mixedPlaceholder
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onAddFavorite={(value) =>
|
||||||
|
editor.handleAddFavoriteValue(key, value)
|
||||||
|
}
|
||||||
onChange={(v) => editor.handleFieldChange(key, v)}
|
onChange={(v) => editor.handleFieldChange(key, v)}
|
||||||
onRemove={() => editor.handleRemoveField(key)}
|
onRemove={() => editor.handleRemoveField(key)}
|
||||||
tagKey={key}
|
tagKey={key}
|
||||||
@@ -212,6 +224,10 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
showRemoveButton={editor.showRemoveArtworkButton}
|
showRemoveButton={editor.showRemoveArtworkButton}
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="settings">
|
||||||
|
<TagEditorSettings />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Checkbox
|
<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 { RiCloseLine } from 'react-icons/ri';
|
||||||
|
|
||||||
import type { KnownTag } from '../utils/known-tags';
|
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 { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||||
import { Table } from '/@/shared/components/table/table';
|
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 { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||||
|
|
||||||
interface TagFieldRowProps {
|
interface FavoriteTagsInputProps {
|
||||||
isDirty?: boolean;
|
favoriteValues: string[];
|
||||||
isMixed: boolean;
|
|
||||||
meta: KnownTag;
|
|
||||||
mixedPlaceholder?: string;
|
mixedPlaceholder?: string;
|
||||||
onChange: (value: string) => void;
|
onAddFavorite: (value: string) => void;
|
||||||
onRemove: () => void;
|
onChange: (value: string[]) => void;
|
||||||
tagKey: string;
|
value: string[];
|
||||||
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 = ({
|
export const TagFieldRow = ({
|
||||||
|
favoriteValues,
|
||||||
isDirty,
|
isDirty,
|
||||||
isMixed,
|
isMixed,
|
||||||
|
isMultiValue,
|
||||||
meta,
|
meta,
|
||||||
mixedPlaceholder,
|
mixedPlaceholder,
|
||||||
|
onAddFavorite,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
tagKey,
|
tagKey,
|
||||||
@@ -35,7 +118,15 @@ export const TagFieldRow = ({
|
|||||||
<Table.Tr data-field-key={tagKey} key={tagKey}>
|
<Table.Tr data-field-key={tagKey} key={tagKey}>
|
||||||
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
|
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
|
||||||
<Table.Td>
|
<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
|
<Textarea
|
||||||
autosize
|
autosize
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
@@ -43,14 +134,16 @@ export const TagFieldRow = ({
|
|||||||
onChange={(e) => onChange(e.currentTarget.value)}
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
placeholder={mixedPlaceholder}
|
placeholder={mixedPlaceholder}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={value}
|
value={Array.isArray(value) ? value.join('\n\n') : value}
|
||||||
/>
|
/>
|
||||||
) : meta.type === 'number' ? (
|
) : meta.type === 'number' ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||||
placeholder={mixedPlaceholder}
|
placeholder={mixedPlaceholder}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={isMixed || value === '' ? undefined : Number(value)}
|
value={
|
||||||
|
isMixed || value === '' || Array.isArray(value) ? undefined : Number(value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : meta.type === 'boolean' ? (
|
) : meta.type === 'boolean' ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -64,7 +157,7 @@ export const TagFieldRow = ({
|
|||||||
onChange={(e) => onChange(e.currentTarget.value)}
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
placeholder={mixedPlaceholder}
|
placeholder={mixedPlaceholder}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={value}
|
value={Array.isArray(value) ? value.join('; ') : value}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ArtworkOp,
|
ArtworkOp,
|
||||||
BatchProgress,
|
BatchProgress,
|
||||||
TagEditorUtils,
|
TagEditorUtils,
|
||||||
|
TagValue,
|
||||||
} from '/@/shared/types/tag-editor';
|
} from '/@/shared/types/tag-editor';
|
||||||
|
|
||||||
import { closeAllModals } from '@mantine/modals';
|
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 { base64ToBytes, formatBatchFileErrors } from '../utils/utils';
|
||||||
|
|
||||||
import { controller } from '/@/renderer/api/controller';
|
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 { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { Song } from '/@/shared/types/domain-types';
|
import { Song } from '/@/shared/types/domain-types';
|
||||||
@@ -49,6 +50,8 @@ interface UseMetadataEditorArgs {
|
|||||||
export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetadataEditorArgs) => {
|
export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetadataEditorArgs) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
|
const { favoriteValues, multiValueFields } = useTagEditorSettings();
|
||||||
|
const { setSettings } = useSettingsStoreActions();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
|
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 [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
|
||||||
const [rescan, setRescan] = useState(true);
|
const [rescan, setRescan] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [tagSummary, setTagSummary] = useState<Record<string, null | string>>({});
|
const [tagSummary, setTagSummary] = useState<Record<string, null | TagValue>>({});
|
||||||
const [editedFields, setEditedFields] = useState<Record<string, string>>({});
|
const [editedFields, setEditedFields] = useState<Record<string, TagValue>>({});
|
||||||
|
const [parsedMultiValueKeys, setParsedMultiValueKeys] = useState<Set<string>>(new Set());
|
||||||
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
|
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
|
||||||
const [loadedArtwork, setLoadedArtwork] = useState<{ kind: ArtworkKind }>({ kind: 'none' });
|
const [loadedArtwork, setLoadedArtwork] = useState<{ kind: ArtworkKind }>({ kind: 'none' });
|
||||||
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
|
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
|
* across files) are added to `mixedKeys`. `sortedFieldEntries` applies
|
||||||
* `FIELD_PRIORITY` ordering, then alphabetical by label for unlisted keys.
|
* `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 { displayFields, mixedKeys, sortedFieldEntries } = useMemo(() => {
|
||||||
const allKeys = new Set<string>();
|
const allKeys = new Set<string>();
|
||||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||||
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
||||||
for (const k of removedKeys) allKeys.delete(k);
|
for (const k of removedKeys) allKeys.delete(k);
|
||||||
|
|
||||||
const displayFields: Record<string, string> = {};
|
const displayFields: Record<string, TagValue> = {};
|
||||||
const mixedKeys = new Set<string>();
|
const mixedKeys = new Set<string>();
|
||||||
|
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
@@ -87,7 +96,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
const summaryVal = tagSummary[key];
|
const summaryVal = tagSummary[key];
|
||||||
if (summaryVal === null) {
|
if (summaryVal === null) {
|
||||||
mixedKeys.add(key);
|
mixedKeys.add(key);
|
||||||
displayFields[key] = '';
|
displayFields[key] = multiValueKeys.has(key) ? [] : '';
|
||||||
} else if (summaryVal !== undefined) {
|
} else if (summaryVal !== undefined) {
|
||||||
displayFields[key] = summaryVal;
|
displayFields[key] = summaryVal;
|
||||||
}
|
}
|
||||||
@@ -105,7 +114,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||||
}, [tagSummary, editedFields, removedKeys]);
|
}, [tagSummary, editedFields, multiValueKeys, removedKeys]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs once on mount: reads metadata for all songs in batch and populates
|
* 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);
|
setTagSummary(batchResult.tagSummary);
|
||||||
|
setParsedMultiValueKeys(new Set(batchResult.multiValueKeys ?? []));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
batchResult.artworkKind === 'common' &&
|
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. */
|
/** 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 }));
|
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. */
|
/** Removes `key` from both `editedFields` and the display, marking it for deletion on save. */
|
||||||
const handleRemoveField = useCallback((key: string) => {
|
const handleRemoveField = useCallback((key: string) => {
|
||||||
setEditedFields((prev) => {
|
setEditedFields((prev) => {
|
||||||
@@ -192,20 +223,23 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
removedKeysRef.current = removedKeys;
|
removedKeysRef.current = removedKeys;
|
||||||
|
|
||||||
/** Adds `key` to `editedFields` with an empty value and un-marks it from removal. */
|
/** Adds `key` to `editedFields` with an empty value and un-marks it from removal. */
|
||||||
const handleAddField = useCallback((key: null | string) => {
|
const handleAddField = useCallback(
|
||||||
if (!key) return;
|
(key: null | string) => {
|
||||||
setEditedFields((prev) => {
|
if (!key) return;
|
||||||
const wasRemoved = removedKeysRef.current.has(key);
|
setEditedFields((prev) => {
|
||||||
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
const wasRemoved = removedKeysRef.current.has(key);
|
||||||
if (alreadyVisible) return prev;
|
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
||||||
return { ...prev, [key]: '' };
|
if (alreadyVisible) return prev;
|
||||||
});
|
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
|
||||||
setRemovedKeys((prev) => {
|
});
|
||||||
const next = new Set(prev);
|
setRemovedKeys((prev) => {
|
||||||
next.delete(key);
|
const next = new Set(prev);
|
||||||
return next;
|
next.delete(key);
|
||||||
});
|
return next;
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[multiValueKeys],
|
||||||
|
);
|
||||||
|
|
||||||
/** Creates a blob URL from raw image bytes and queues a `set` artwork operation. */
|
/** Creates a blob URL from raw image bytes and queues a `set` artwork operation. */
|
||||||
const applyArtworkBytes = useCallback((bytes: Uint8Array, mimeType: string) => {
|
const applyArtworkBytes = useCallback((bytes: Uint8Array, mimeType: string) => {
|
||||||
@@ -249,7 +283,10 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
const emptyFields = Object.entries(editedFields)
|
const emptyFields = Object.entries(editedFields)
|
||||||
.filter(([key, value]) => {
|
.filter(([key, value]) => {
|
||||||
const meta = KNOWN_TAG_MAP.get(key);
|
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);
|
.map(([key]) => KNOWN_TAG_MAP.get(key)?.label ?? key);
|
||||||
|
|
||||||
@@ -337,7 +374,9 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
availableToAdd,
|
availableToAdd,
|
||||||
editedFields,
|
editedFields,
|
||||||
error,
|
error,
|
||||||
|
favoriteValues,
|
||||||
getFieldMeta,
|
getFieldMeta,
|
||||||
|
handleAddFavoriteValue,
|
||||||
handleAddField,
|
handleAddField,
|
||||||
handleChangeArtwork,
|
handleChangeArtwork,
|
||||||
handleFieldChange,
|
handleFieldChange,
|
||||||
@@ -349,6 +388,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
loadProgress,
|
loadProgress,
|
||||||
mixedKeys,
|
mixedKeys,
|
||||||
mixedPlaceholder,
|
mixedPlaceholder,
|
||||||
|
multiValueKeys,
|
||||||
readWarning,
|
readWarning,
|
||||||
rescan,
|
rescan,
|
||||||
setRescan,
|
setRescan,
|
||||||
|
|||||||
@@ -744,6 +744,11 @@ const AutoDJSettingsSchema = z.object({
|
|||||||
timing: z.number(),
|
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
|
* This schema is used for validation of the imported settings json
|
||||||
*/
|
*/
|
||||||
@@ -767,6 +772,7 @@ export const ValidationSettingsStateSchema = z.object({
|
|||||||
z.literal('window'),
|
z.literal('window'),
|
||||||
z.string(),
|
z.string(),
|
||||||
]),
|
]),
|
||||||
|
tagEditor: TagEditorSettingsSchema,
|
||||||
visualizer: VisualizerSettingsSchema,
|
visualizer: VisualizerSettingsSchema,
|
||||||
window: WindowSettingsSchema,
|
window: WindowSettingsSchema,
|
||||||
});
|
});
|
||||||
@@ -1988,6 +1994,10 @@ const initialState: SettingsState = {
|
|||||||
username: 'feishin',
|
username: 'feishin',
|
||||||
},
|
},
|
||||||
tab: 'general',
|
tab: 'general',
|
||||||
|
tagEditor: {
|
||||||
|
favoriteValues: {},
|
||||||
|
multiValueFields: ['artist', 'albumArtist', 'genre', 'composer', 'lyricist'],
|
||||||
|
},
|
||||||
visualizer: {
|
visualizer: {
|
||||||
audiomotionanalyzer: {
|
audiomotionanalyzer: {
|
||||||
alphaBars: false,
|
alphaBars: false,
|
||||||
@@ -2677,6 +2687,8 @@ export const useCssSettings = () => useSettingsStore((state) => state.css, shall
|
|||||||
export const useQueryBuilderSettings = () =>
|
export const useQueryBuilderSettings = () =>
|
||||||
useSettingsStore((state) => state.queryBuilder, shallow);
|
useSettingsStore((state) => state.queryBuilder, shallow);
|
||||||
|
|
||||||
|
export const useTagEditorSettings = () => useSettingsStore((state) => state.tagEditor, shallow);
|
||||||
|
|
||||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||||
|
|
||||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export interface ReadSongMetadataBatchResult extends IpcResult {
|
|||||||
artworkKind: ArtworkKind;
|
artworkKind: ArtworkKind;
|
||||||
artworkMimeType?: string;
|
artworkMimeType?: string;
|
||||||
failedFiles?: BatchFileError[];
|
failedFiles?: BatchFileError[];
|
||||||
|
multiValueKeys?: string[];
|
||||||
readCount?: number;
|
readCount?: number;
|
||||||
tagSummary?: Record<string, null | string>;
|
tagSummary?: Record<string, null | TagValue>;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +37,14 @@ export interface TagEditorUtils {
|
|||||||
readSongMetadataBatch: (filePaths: string[]) => Promise<ReadSongMetadataBatchResult>;
|
readSongMetadataBatch: (filePaths: string[]) => Promise<ReadSongMetadataBatchResult>;
|
||||||
writeSongTagsBatch: (
|
writeSongTagsBatch: (
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
edits: Record<string, string>,
|
edits: Record<string, TagValue>,
|
||||||
removed: string[],
|
removed: string[],
|
||||||
artworkOp?: ArtworkOp,
|
artworkOp?: ArtworkOp,
|
||||||
) => Promise<WriteSongTagsBatchResult>;
|
) => Promise<WriteSongTagsBatchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TagValue = string | string[];
|
||||||
|
|
||||||
export interface WriteSongTagsBatchResult extends IpcResult {
|
export interface WriteSongTagsBatchResult extends IpcResult {
|
||||||
failedFiles?: BatchFileError[];
|
failedFiles?: BatchFileError[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user