mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-22 10:26:33 +02:00
Improve tag editor add-field UX and fix custom tag writes
- Fix custom tag keys being silently dropped by TagLib — unknown keys are now uppercased before write since TagLib requires all-caps Vorbis field names - Add real-time validation for invalid characters (= and non-ASCII) in custom tag keys - Show 'Field already exists' error on Enter/+ attempt rather than silently doing nothing - Resolve typed tag labels to canonical keys (e.g. 'Album Sort' → albumSort) to prevent duplicate entries under a different key - Atomic duplicate check in hook functional updater prevents race conditions from rapid adds - Scroll to and focus newly added field after it is inserted - Consolidate FIELD_LABEL_OVERRIDES, FIELD_TYPE_OVERRIDES, and PICARD_EXTRAS into a single TAG_CONFIG object; extract resolveTagKey helper
This commit is contained in:
@@ -1,11 +1,8 @@
|
|||||||
import type { ArtworkKind, ArtworkOp, BatchFileError } from '/@/shared/types/tag-editor';
|
import type { ArtworkKind, ArtworkOp, BatchFileError } from '/@/shared/types/tag-editor';
|
||||||
import type { ICommonTagsResult } from 'music-metadata';
|
|
||||||
|
|
||||||
import { constants, promises as fsPromises } from 'fs';
|
import { constants, promises as fsPromises } from 'fs';
|
||||||
import * as mm from 'music-metadata';
|
import { PROPERTIES, TagLib } from 'taglib-wasm';
|
||||||
import { TagLib } from 'taglib-wasm';
|
|
||||||
|
|
||||||
import { EDITOR_FIELD_KEYS } from '/@/shared/types/tag-editor';
|
|
||||||
import { getImageMimeTypeFromPath } from '/@/shared/utils/image-mime';
|
import { getImageMimeTypeFromPath } from '/@/shared/utils/image-mime';
|
||||||
|
|
||||||
let _taglib: null | TagLib = null;
|
let _taglib: null | TagLib = null;
|
||||||
@@ -26,44 +23,6 @@ export async function readLocalImageFile(filePath: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickFrontCover = <T extends { type?: string }>(pictures: T[]): T | undefined =>
|
|
||||||
pictures.find((p) => p.type === 'Front Cover') ?? pictures[0];
|
|
||||||
|
|
||||||
type TagAccessor = (c: ICommonTagsResult) => null | number | string | undefined;
|
|
||||||
|
|
||||||
// Only fields where the editor key differs from the music-metadata key.
|
|
||||||
const MM_RENAMES: Partial<Record<string, keyof ICommonTagsResult>> = {
|
|
||||||
acoustidId: 'acoustid_id',
|
|
||||||
albumArtist: 'albumartist',
|
|
||||||
albumArtistSort: 'albumartistsort',
|
|
||||||
albumSort: 'albumsort',
|
|
||||||
artistSort: 'artistsort',
|
|
||||||
catalogNumber: 'catalognumber',
|
|
||||||
composerSort: 'composersort',
|
|
||||||
musicbrainzArtistId: 'musicbrainz_artistid',
|
|
||||||
musicbrainzReleaseArtistId: 'musicbrainz_albumartistid',
|
|
||||||
musicbrainzReleaseGroupId: 'musicbrainz_releasegroupid',
|
|
||||||
musicbrainzReleaseId: 'musicbrainz_albumid',
|
|
||||||
musicbrainzReleaseTrackId: 'musicbrainz_trackid',
|
|
||||||
musicbrainzTrackId: 'musicbrainz_recordingid',
|
|
||||||
musicbrainzWorkId: 'musicbrainz_workid',
|
|
||||||
originalAlbum: 'originalalbum',
|
|
||||||
originalArtist: 'originalartist',
|
|
||||||
originalDate: 'originaldate',
|
|
||||||
remixedBy: 'remixer',
|
|
||||||
titleSort: 'titlesort',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only fields that need sub-property access or live on a nested object.
|
|
||||||
const MM_CUSTOM: Partial<Record<string, TagAccessor>> = {
|
|
||||||
comment: (c) => c.comment?.[0]?.text,
|
|
||||||
discNumber: (c) => c.disk.no,
|
|
||||||
lyrics: (c) => c.lyrics?.[0]?.text,
|
|
||||||
totalDiscs: (c) => c.disk.of,
|
|
||||||
totalTracks: (c) => c.track.of,
|
|
||||||
trackNumber: (c) => c.track.no,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Returns an error entry for each path that is missing or not writable by the current process. */
|
/** Returns an error entry for each path that is missing or not writable by the current process. */
|
||||||
export async function checkPathsWritable(paths: string[]): Promise<BatchFileError[]> {
|
export async function checkPathsWritable(paths: string[]): Promise<BatchFileError[]> {
|
||||||
const failed: BatchFileError[] = [];
|
const failed: BatchFileError[] = [];
|
||||||
@@ -82,16 +41,23 @@ export async function checkPathsWritable(paths: string[]): Promise<BatchFileErro
|
|||||||
return failed;
|
return failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps a music-metadata `ICommonTagsResult` to the flat camelCase key map used by the editor. */
|
/** Flattens a taglib-wasm PropertyMap to a string record, dropping ALL_CAPS alias keys already covered by a camelCase equivalent. */
|
||||||
export function flattenMusicMetadata(common: ICommonTagsResult): Record<string, string> {
|
function flattenProperties(props: Record<string, string[] | undefined>): Record<string, string> {
|
||||||
const flat: Record<string, string> = {};
|
const flat: Record<string, string> = {};
|
||||||
for (const key of EDITOR_FIELD_KEYS) {
|
for (const [key, values] of Object.entries(props)) {
|
||||||
const custom = MM_CUSTOM[key];
|
if (values && values.length > 0 && values[0] !== '') {
|
||||||
const raw: unknown = custom
|
flat[key] = values[0];
|
||||||
? custom(common)
|
}
|
||||||
: common[(MM_RENAMES[key] ?? key) as keyof ICommonTagsResult];
|
}
|
||||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
const coveredByUpperCase = new Set(
|
||||||
if (value != null && value !== '') flat[key] = String(value);
|
Object.keys(flat)
|
||||||
|
.filter((k) => k !== k.toUpperCase())
|
||||||
|
.map((k) => k.toUpperCase()),
|
||||||
|
);
|
||||||
|
for (const key of Object.keys(flat)) {
|
||||||
|
if (key === key.toUpperCase() && coveredByUpperCase.has(key)) {
|
||||||
|
delete flat[key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return flat;
|
return flat;
|
||||||
}
|
}
|
||||||
@@ -116,9 +82,10 @@ const mapWithConcurrency = async <T>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads tags and artwork from a batch of audio files via music-metadata (which preserves full ISO dates).
|
* Reads all tags and artwork from a batch of audio files via taglib-wasm.
|
||||||
|
* Returns every tag present on disk — not limited to any known-field list.
|
||||||
* Values that differ across files are merged to `null` and shown as "(Multiple Values)" in the editor.
|
* Values that differ across files are merged to `null` and shown as "(Multiple Values)" in the editor.
|
||||||
* Artwork bytes are only loaded for one representative file, after confirming all files share the same cover.
|
* Artwork bytes are captured from the first successful file; subsequent files only contribute their size for comparison.
|
||||||
*/
|
*/
|
||||||
export async function readFilesMetadataBatch(
|
export async function readFilesMetadataBatch(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
@@ -136,38 +103,38 @@ export async function readFilesMetadataBatch(
|
|||||||
}> {
|
}> {
|
||||||
const totalCount = filePaths.length;
|
const totalCount = filePaths.length;
|
||||||
const failedFiles: BatchFileError[] = [];
|
const failedFiles: BatchFileError[] = [];
|
||||||
|
|
||||||
// Merged incrementally; safe because JS is single-threaded between awaits.
|
|
||||||
const tagSummary: Record<string, null | string> = {};
|
const tagSummary: Record<string, null | string> = {};
|
||||||
let artworkKind = 'none' as ArtworkKind;
|
let artworkKind = 'none' as ArtworkKind;
|
||||||
let artworkByteSize: number | undefined;
|
let artworkByteSize: number | undefined;
|
||||||
let firstSuccessPath: string | undefined;
|
let artworkData: string | undefined;
|
||||||
|
let artworkMimeType: string | undefined;
|
||||||
let readCount = 0;
|
let readCount = 0;
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
|
||||||
|
const taglib = await getTagLib();
|
||||||
|
|
||||||
await mapWithConcurrency(
|
await mapWithConcurrency(
|
||||||
filePaths,
|
filePaths,
|
||||||
BATCH_CONCURRENCY,
|
BATCH_CONCURRENCY,
|
||||||
async (filePath) => {
|
async (filePath) => {
|
||||||
try {
|
try {
|
||||||
const { common } = await mm.parseFile(filePath);
|
const file = await taglib.open(filePath);
|
||||||
const flat = flattenMusicMetadata(common);
|
try {
|
||||||
const hasCoverArt = (common.picture?.length ?? 0) > 0;
|
const flat = flattenProperties(file.properties());
|
||||||
// Only access artwork bytes if we still need them for comparison.
|
const pictures = file.getPictures();
|
||||||
const picSize =
|
const frontCover = pictures.find((p) => p.type === 'FrontCover') ?? pictures[0];
|
||||||
artworkKind !== 'mixed' && hasCoverArt
|
const hasCoverArt = frontCover !== undefined;
|
||||||
? pickFrontCover(common.picture!)?.data.length
|
const picSize = hasCoverArt ? frontCover.data.length : undefined;
|
||||||
: undefined;
|
|
||||||
// common.picture's Uint8Array is not stored anywhere — eligible for GC here.
|
|
||||||
|
|
||||||
if (readCount === 0) {
|
if (readCount === 0) {
|
||||||
// First success: seed the summary.
|
|
||||||
Object.assign(tagSummary, flat);
|
Object.assign(tagSummary, flat);
|
||||||
firstSuccessPath = filePath;
|
|
||||||
artworkKind = hasCoverArt ? 'common' : 'none';
|
artworkKind = hasCoverArt ? 'common' : 'none';
|
||||||
artworkByteSize = picSize;
|
artworkByteSize = picSize;
|
||||||
|
if (frontCover) {
|
||||||
|
artworkData = Buffer.from(frontCover.data).toString('base64');
|
||||||
|
artworkMimeType = frontCover.mimeType;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Merge tags: keys already null stay null; new divergences become null.
|
|
||||||
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 && flat[k] !== tagSummary[k])
|
||||||
tagSummary[k] = null;
|
tagSummary[k] = null;
|
||||||
@@ -175,7 +142,6 @@ export async function readFilesMetadataBatch(
|
|||||||
for (const k of Object.keys(flat)) {
|
for (const k of Object.keys(flat)) {
|
||||||
if (!(k in tagSummary)) tagSummary[k] = null;
|
if (!(k in tagSummary)) tagSummary[k] = null;
|
||||||
}
|
}
|
||||||
// Merge artwork.
|
|
||||||
if (artworkKind !== 'mixed') {
|
if (artworkKind !== 'mixed') {
|
||||||
if (
|
if (
|
||||||
hasCoverArt !== (artworkKind === 'common') ||
|
hasCoverArt !== (artworkKind === 'common') ||
|
||||||
@@ -187,6 +153,9 @@ export async function readFilesMetadataBatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
readCount += 1;
|
readCount += 1;
|
||||||
|
} finally {
|
||||||
|
file.dispose();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failedFiles.push({
|
failedFiles.push({
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
@@ -199,19 +168,6 @@ export async function readFilesMetadataBatch(
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
let artworkData: string | undefined;
|
|
||||||
let artworkMimeType: string | undefined;
|
|
||||||
|
|
||||||
// Re-read artwork from one file only after confirming all files share the same cover.
|
|
||||||
if (artworkKind === 'common' && firstSuccessPath) {
|
|
||||||
const { common } = await mm.parseFile(firstSuccessPath);
|
|
||||||
const pic = pickFrontCover(common.picture ?? []);
|
|
||||||
if (pic) {
|
|
||||||
artworkData = Buffer.from(pic.data).toString('base64');
|
|
||||||
artworkMimeType = pic.format;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
artworkKind,
|
artworkKind,
|
||||||
failedFiles,
|
failedFiles,
|
||||||
@@ -264,7 +220,7 @@ export async function writeFilesTags(
|
|||||||
try {
|
try {
|
||||||
await taglib.edit(path, (file) => {
|
await taglib.edit(path, (file) => {
|
||||||
for (const [k, v] of Object.entries(mergedEdits)) {
|
for (const [k, v] of Object.entries(mergedEdits)) {
|
||||||
file.setProperty(k.toUpperCase(), v);
|
file.setProperty(k in PROPERTIES ? k : k.toUpperCase(), v);
|
||||||
}
|
}
|
||||||
if (artworkOp?.type === 'clear') {
|
if (artworkOp?.type === 'clear') {
|
||||||
file.removePictures();
|
file.removePictures();
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
|
import { Autocomplete } from '@mantine/core';
|
||||||
import { closeAllModals } from '@mantine/modals';
|
import { closeAllModals } from '@mantine/modals';
|
||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { RiAddLine } from 'react-icons/ri';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
||||||
|
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 { TagFieldRow } from './tag-field-row';
|
import { TagFieldRow } from './tag-field-row';
|
||||||
|
|
||||||
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Select } from '/@/shared/components/select/select';
|
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Table } from '/@/shared/components/table/table';
|
import { Table } from '/@/shared/components/table/table';
|
||||||
@@ -21,27 +24,55 @@ import { Song } from '/@/shared/types/domain-types';
|
|||||||
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skipNextEnterRef = useRef(false);
|
||||||
|
const skipNextOnChangeResetRef = useRef(false);
|
||||||
|
const [addFieldInput, setAddFieldInput] = useState('');
|
||||||
|
const [duplicateAttempted, setDuplicateAttempted] = useState(false);
|
||||||
|
|
||||||
// Opens the metadata editor modal that displays a table of editable fields for the songs, as well as an artwork panel for changing the songs' artwork
|
|
||||||
const editor = useMetadataEditor({
|
const editor = useMetadataEditor({
|
||||||
browser: window.api.browser,
|
browser: window.api.browser,
|
||||||
songs,
|
songs,
|
||||||
utils: window.api.utils,
|
utils: window.api.utils,
|
||||||
});
|
});
|
||||||
|
|
||||||
// After adding the field, scrolls to and focuses the new field's row in the table.
|
const trimmedInput = addFieldInput.trim();
|
||||||
const handleAddField = (key: null | string) => {
|
const customKeyError =
|
||||||
editor.handleAddField(key);
|
trimmedInput && !KNOWN_TAG_MAP.has(trimmedInput)
|
||||||
if (!key) return;
|
? trimmedInput.includes('=')
|
||||||
|
? "Tag key cannot contain '='"
|
||||||
|
: /[^\x00-\x7F]/.test(trimmedInput)
|
||||||
|
? 'Tag key must use ASCII characters only'
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
|
||||||
|
const duplicateError =
|
||||||
|
trimmedInput && !customKeyError && editor.sortedFieldEntries.some(([k]) => k === resolvedInputKey)
|
||||||
|
? 'Field already exists'
|
||||||
|
: null;
|
||||||
|
const fieldError = customKeyError ?? (duplicateAttempted ? duplicateError : null);
|
||||||
|
|
||||||
|
const handleAddField = (key: string): boolean => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
if (!KNOWN_TAG_MAP.has(trimmed) && (trimmed.includes('=') || /[^\x00-\x7F]/.test(trimmed))) return false;
|
||||||
|
const normalizedKey = resolveTagKey(trimmed);
|
||||||
|
if (editor.sortedFieldEntries.some(([k]) => k === normalizedKey)) {
|
||||||
|
setDuplicateAttempted(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDuplicateAttempted(false);
|
||||||
|
editor.handleAddField(normalizedKey);
|
||||||
|
setAddFieldInput('');
|
||||||
|
|
||||||
// Scroll to focus on the newly added field
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
||||||
`[data-field-key="${key}"]`,
|
`[data-field-key="${normalizedKey}"]`,
|
||||||
);
|
);
|
||||||
row?.scrollIntoView({ block: 'nearest' });
|
row?.scrollIntoView({ block: 'nearest' });
|
||||||
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// While loading, shows a spinner
|
// While loading, shows a spinner
|
||||||
@@ -86,15 +117,47 @@ export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
|||||||
{editor.readWarning}
|
{editor.readWarning}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{editor.availableToAdd.length > 0 && (
|
<Autocomplete
|
||||||
<Select
|
|
||||||
clearable
|
|
||||||
data={editor.availableToAdd}
|
data={editor.availableToAdd}
|
||||||
onChange={handleAddField}
|
error={fieldError}
|
||||||
|
onChange={(v) => {
|
||||||
|
setAddFieldInput(v);
|
||||||
|
if (skipNextOnChangeResetRef.current) {
|
||||||
|
skipNextOnChangeResetRef.current = false;
|
||||||
|
} else {
|
||||||
|
setDuplicateAttempted(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (skipNextEnterRef.current) {
|
||||||
|
skipNextEnterRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleAddField(addFieldInput);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOptionSubmit={(value) => {
|
||||||
|
skipNextEnterRef.current = true;
|
||||||
|
skipNextOnChangeResetRef.current = true;
|
||||||
|
const added = handleAddField(value);
|
||||||
|
if (added) {
|
||||||
|
queueMicrotask(() => setAddFieldInput(''));
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
||||||
value={null}
|
rightSection={
|
||||||
|
<ActionIcon
|
||||||
|
disabled={!!fieldError}
|
||||||
|
onClick={() => handleAddField(addFieldInput)}
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
<RiAddLine size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
rightSectionPointerEvents="all"
|
||||||
|
value={addFieldInput}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className={styles.tableScroller} ref={tableContainerRef}>
|
<div className={styles.tableScroller} ref={tableContainerRef}>
|
||||||
<Table
|
<Table
|
||||||
classNames={{ td: styles.tableCell, th: styles.tableHeader }}
|
classNames={{ td: styles.tableCell, th: styles.tableHeader }}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import type {
|
|||||||
} from '/@/shared/types/tag-editor';
|
} from '/@/shared/types/tag-editor';
|
||||||
|
|
||||||
import { closeAllModals } from '@mantine/modals';
|
import { closeAllModals } from '@mantine/modals';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
|
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
|
||||||
import { base64ToBytes, filterTagSummary, 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 } from '/@/renderer/store';
|
||||||
@@ -145,7 +145,7 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTagSummary(filterTagSummary(batchResult.tagSummary));
|
setTagSummary(batchResult.tagSummary);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
batchResult.artworkKind === 'common' &&
|
batchResult.artworkKind === 'common' &&
|
||||||
@@ -184,10 +184,21 @@ export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetad
|
|||||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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. */
|
/** Adds `key` to `editedFields` with an empty value and un-marks it from removal. */
|
||||||
const handleAddField = useCallback((key: null | string) => {
|
const handleAddField = useCallback((key: null | string) => {
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
setEditedFields((prev) => ({ ...prev, [key]: '' }));
|
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) => {
|
setRemovedKeys((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(key);
|
next.delete(key);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { EditorFieldKey } from '/@/shared/types/tag-editor';
|
import { PROPERTIES } from 'taglib-wasm';
|
||||||
|
|
||||||
import { EDITOR_FIELD_KEYS } from '/@/shared/types/tag-editor';
|
|
||||||
|
|
||||||
/** Tags pinned to the top of the editor table (most frequently edited). */
|
/** Tags pinned to the top of the editor table (most frequently edited). */
|
||||||
export const FIELD_PRIORITY: readonly string[] = [
|
export const FIELD_PRIORITY: readonly string[] = [
|
||||||
@@ -13,42 +11,6 @@ export const FIELD_PRIORITY: readonly string[] = [
|
|||||||
'date',
|
'date',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Human-readable labels where auto-generated text is awkward. */
|
|
||||||
const FIELD_LABEL_OVERRIDES: Partial<Record<EditorFieldKey, string>> = {
|
|
||||||
acoustidId: 'AcoustID',
|
|
||||||
albumArtist: 'Album Artist',
|
|
||||||
albumArtistSort: 'Album Artist Sort',
|
|
||||||
albumSort: 'Album Sort',
|
|
||||||
artistSort: 'Artist Sort',
|
|
||||||
catalogNumber: 'Catalog Number',
|
|
||||||
composerSort: 'Composer Sort',
|
|
||||||
discNumber: 'Disc Number',
|
|
||||||
musicbrainzArtistId: 'MusicBrainz Artist ID',
|
|
||||||
musicbrainzReleaseArtistId: 'MusicBrainz Album Artist ID',
|
|
||||||
musicbrainzReleaseGroupId: 'MusicBrainz Release Group ID',
|
|
||||||
musicbrainzReleaseId: 'MusicBrainz Album ID',
|
|
||||||
musicbrainzReleaseTrackId: 'MusicBrainz Release Track ID',
|
|
||||||
musicbrainzTrackId: 'MusicBrainz Track ID',
|
|
||||||
musicbrainzWorkId: 'MusicBrainz Work ID',
|
|
||||||
originalAlbum: 'Original Album',
|
|
||||||
originalArtist: 'Original Artist',
|
|
||||||
originalDate: 'Original Date',
|
|
||||||
remixedBy: 'Remixer',
|
|
||||||
titleSort: 'Title Sort',
|
|
||||||
totalDiscs: 'Total Discs',
|
|
||||||
totalTracks: 'Total Tracks',
|
|
||||||
trackNumber: 'Track Number',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Input widget overrides (taglib metadata type is not always specific enough). */
|
|
||||||
const FIELD_TYPE_OVERRIDES: Partial<
|
|
||||||
Record<EditorFieldKey, 'boolean' | 'number' | 'string' | 'textarea'>
|
|
||||||
> = {
|
|
||||||
bpm: 'number',
|
|
||||||
comment: 'textarea',
|
|
||||||
lyrics: 'textarea',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface KnownTag {
|
export interface KnownTag {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -58,20 +20,81 @@ export interface KnownTag {
|
|||||||
/** Which form control to render for a tag row. */
|
/** Which form control to render for a tag row. */
|
||||||
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
|
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-key overrides and extras. Keys present in taglib-wasm PROPERTIES get their
|
||||||
|
* label/type overridden; keys absent from PROPERTIES are appended as extra entries.
|
||||||
|
*/
|
||||||
|
const TAG_CONFIG: Record<string, { label?: string; type?: TagFieldType }> = {
|
||||||
|
acoustidId: { label: 'AcoustID' },
|
||||||
|
albumArtist: { label: 'Album Artist' },
|
||||||
|
albumArtistSort: { label: 'Album Artist Sort' },
|
||||||
|
albumSort: { label: 'Album Sort' },
|
||||||
|
artistSort: { label: 'Artist Sort' },
|
||||||
|
catalogNumber: { label: 'Catalog Number' },
|
||||||
|
composerSort: { label: 'Composer Sort' },
|
||||||
|
discNumber: { label: 'Disc Number' },
|
||||||
|
musicbrainzArtistId: { label: 'MusicBrainz Artist ID' },
|
||||||
|
musicbrainzReleaseArtistId: { label: 'MusicBrainz Album Artist ID' },
|
||||||
|
musicbrainzReleaseGroupId: { label: 'MusicBrainz Release Group ID' },
|
||||||
|
musicbrainzReleaseId: { label: 'MusicBrainz Album ID' },
|
||||||
|
musicbrainzReleaseTrackId: { label: 'MusicBrainz Release Track ID' },
|
||||||
|
musicbrainzTrackId: { label: 'MusicBrainz Track ID' },
|
||||||
|
musicbrainzWorkId: { label: 'MusicBrainz Work ID' },
|
||||||
|
originalAlbum: { label: 'Original Album' },
|
||||||
|
originalArtist: { label: 'Original Artist' },
|
||||||
|
originalDate: { label: 'Original Date' },
|
||||||
|
remixedBy: { label: 'Remixer' },
|
||||||
|
titleSort: { label: 'Title Sort' },
|
||||||
|
totalDiscs: { label: 'Total Discs' },
|
||||||
|
totalTracks: { label: 'Total Tracks' },
|
||||||
|
trackNumber: { label: 'Track Number' },
|
||||||
|
acoustidFingerprint: { type: 'textarea' },
|
||||||
|
bpm: { type: 'number' },
|
||||||
|
comment: { type: 'textarea' },
|
||||||
|
lyrics: { type: 'textarea' },
|
||||||
|
// extras not in PROPERTIES (common MusicBrainz Picard tags)
|
||||||
|
ARTISTS: { label: 'Artists', type: 'string' },
|
||||||
|
ORIGINALYEAR: { label: 'Original Year', type: 'number' },
|
||||||
|
RELEASECOUNTRY: { label: 'Release Country', type: 'string' },
|
||||||
|
RELEASESTATUS: { label: 'Release Status', type: 'string' },
|
||||||
|
RELEASETYPE: { label: 'Release Type', type: 'string' },
|
||||||
|
};
|
||||||
|
|
||||||
const humanizeKey = (key: string): string =>
|
const humanizeKey = (key: string): string =>
|
||||||
key
|
key
|
||||||
.replace(/([A-Z])/g, ' $1')
|
.replace(/([A-Z])/g, ' $1')
|
||||||
.replace(/^./, (c) => c.toUpperCase())
|
.replace(/^./, (c) => c.toUpperCase())
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const resolveFieldType = (key: string): TagFieldType =>
|
/** Field definitions derived from taglib-wasm's PROPERTIES plus any extras in TAG_CONFIG. */
|
||||||
FIELD_TYPE_OVERRIDES[key as EditorFieldKey] ?? 'string';
|
export const KNOWN_TAGS: KnownTag[] = [
|
||||||
|
...Object.entries(PROPERTIES).map(([key, prop]) => {
|
||||||
/** Field definitions used to render the tag editor table and "Add field" dropdown. */
|
const cfg = TAG_CONFIG[key];
|
||||||
export const KNOWN_TAGS: KnownTag[] = EDITOR_FIELD_KEYS.map((key) => ({
|
return {
|
||||||
key,
|
key,
|
||||||
label: FIELD_LABEL_OVERRIDES[key] ?? humanizeKey(key),
|
label: cfg?.label ?? humanizeKey(key),
|
||||||
type: resolveFieldType(key),
|
type: cfg?.type ?? (prop.type as TagFieldType),
|
||||||
}));
|
};
|
||||||
|
}),
|
||||||
|
...Object.entries(TAG_CONFIG)
|
||||||
|
.filter(([key]) => !(key in PROPERTIES))
|
||||||
|
.map(([key, cfg]) => ({
|
||||||
|
key,
|
||||||
|
label: cfg.label ?? humanizeKey(key),
|
||||||
|
type: cfg.type ?? ('string' as TagFieldType),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
|
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a user-typed string to a canonical tag key.
|
||||||
|
* Matches by label first (e.g. "Album Sort" → "albumSort"), then falls back to
|
||||||
|
* the raw input. Unknown keys are uppercased so TagLib writes them as valid
|
||||||
|
* Vorbis comment field names.
|
||||||
|
*/
|
||||||
|
export const resolveTagKey = (input: string): string => {
|
||||||
|
const byLabel = KNOWN_TAGS.find((t) => t.label.toLowerCase() === input.toLowerCase());
|
||||||
|
const key = byLabel?.key ?? input;
|
||||||
|
return KNOWN_TAG_MAP.has(key) ? key : key.toUpperCase();
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { BatchFileError } from '/@/shared/types/tag-editor';
|
import type { BatchFileError } from '/@/shared/types/tag-editor';
|
||||||
|
|
||||||
import { KNOWN_TAG_MAP } from './known-tags';
|
|
||||||
|
|
||||||
export const base64ToBytes = (base64: string): Uint8Array => {
|
export const base64ToBytes = (base64: string): Uint8Array => {
|
||||||
const binary = atob(base64);
|
const binary = atob(base64);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
@@ -17,13 +15,3 @@ export const formatBatchFileErrors = (failed: BatchFileError[], summary: string)
|
|||||||
const suffix = failed.length > 3 ? '…' : '';
|
const suffix = failed.length > 3 ? '…' : '';
|
||||||
return `${summary} ${details}${suffix}`;
|
return `${summary} ${details}${suffix}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterTagSummary = (
|
|
||||||
tagSummary: Record<string, null | string>,
|
|
||||||
): Record<string, null | string> => {
|
|
||||||
const filtered: Record<string, null | string> = {};
|
|
||||||
for (const [k, v] of Object.entries(tagSummary)) {
|
|
||||||
if (KNOWN_TAG_MAP.has(k)) filtered[k] = v;
|
|
||||||
}
|
|
||||||
return filtered;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,53 +1,3 @@
|
|||||||
/** Editor field keys in taglib-wasm camelCase. Shared between main and renderer. */
|
|
||||||
export const EDITOR_FIELD_KEYS = [
|
|
||||||
'title',
|
|
||||||
'artist',
|
|
||||||
'albumArtist',
|
|
||||||
'album',
|
|
||||||
'subtitle',
|
|
||||||
'genre',
|
|
||||||
'comment',
|
|
||||||
'trackNumber',
|
|
||||||
'totalTracks',
|
|
||||||
'discNumber',
|
|
||||||
'totalDiscs',
|
|
||||||
'date',
|
|
||||||
'originalDate',
|
|
||||||
'bpm',
|
|
||||||
'language',
|
|
||||||
'media',
|
|
||||||
'script',
|
|
||||||
'grouping',
|
|
||||||
'titleSort',
|
|
||||||
'albumSort',
|
|
||||||
'artistSort',
|
|
||||||
'albumArtistSort',
|
|
||||||
'composerSort',
|
|
||||||
'composer',
|
|
||||||
'producer',
|
|
||||||
'lyricist',
|
|
||||||
'conductor',
|
|
||||||
'remixedBy',
|
|
||||||
'isrc',
|
|
||||||
'asin',
|
|
||||||
'barcode',
|
|
||||||
'catalogNumber',
|
|
||||||
'label',
|
|
||||||
'copyright',
|
|
||||||
'mood',
|
|
||||||
'originalAlbum',
|
|
||||||
'originalArtist',
|
|
||||||
'lyrics',
|
|
||||||
'musicbrainzTrackId',
|
|
||||||
'musicbrainzReleaseId',
|
|
||||||
'musicbrainzReleaseGroupId',
|
|
||||||
'musicbrainzReleaseTrackId',
|
|
||||||
'musicbrainzWorkId',
|
|
||||||
'musicbrainzArtistId',
|
|
||||||
'musicbrainzReleaseArtistId',
|
|
||||||
'acoustidId',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ArtworkKind = 'common' | 'mixed' | 'none';
|
export type ArtworkKind = 'common' | 'mixed' | 'none';
|
||||||
|
|
||||||
export type ArtworkOp = { bytes: Uint8Array; mimeType: string; type: 'set' } | { type: 'clear' };
|
export type ArtworkOp = { bytes: Uint8Array; mimeType: string; type: 'set' } | { type: 'clear' };
|
||||||
@@ -62,8 +12,6 @@ export interface BatchProgress {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditorFieldKey = (typeof EDITOR_FIELD_KEYS)[number];
|
|
||||||
|
|
||||||
export interface ReadLocalImageResult extends IpcResult {
|
export interface ReadLocalImageResult extends IpcResult {
|
||||||
data?: string;
|
data?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user