mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-23 10:56:28 +02:00
feat(tag-editor): add batch metadata editor with artwork support
New metadata editor accessible via "Edit Metadata" in the song, album, and playlist song context menus. Supports editing one or more files at once. Tags: - Displays all metadata fields for the selected files - Fields differing across files are shown as "(Multiple Values)" - Fields can be added from a dropdown or removed individually - Only modified fields are written back to disk Artwork: - Displays shared cover art across all selected files - Supports setting new artwork via file picker or drag-and-drop - Supports clearing existing artwork - Shows a placeholder when selected files have different covers Reading and writing: - Tags are read via music-metadata, preserving full ISO date strings - Writes use taglib-wasm WASI in-place editing via setProperty, only touching fields that were explicitly changed - Writability of all files is checked before any writes begin - Partial read failures show a warning but still allow editing Other: - Per-file batch progress is reported during reads and writes - In-flight reads are cancelled when the modal is closed - An optional server library rescan can be triggered after saving
This commit is contained in:
@@ -924,4 +924,16 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
startScan(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${i18n.t('error.apiRouteError')}: startScan`);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'startScan',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -392,6 +392,16 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'Library/Refresh',
|
||||
query: z.object({}),
|
||||
responses: {
|
||||
204: z.null(),
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -1996,6 +1996,20 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
'Failed to upload playlist image',
|
||||
);
|
||||
},
|
||||
startScan: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).startScan({
|
||||
body: null,
|
||||
query: {},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to start scan');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
function getLibraryId(musicFolderId?: string | string[]) {
|
||||
|
||||
@@ -1411,4 +1411,5 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
return res.data?.status === 'ok';
|
||||
},
|
||||
startScan: SubsonicController.startScan,
|
||||
};
|
||||
|
||||
@@ -353,6 +353,22 @@ export const contract = c.router({
|
||||
200: ssType._response.baseResponse,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
method: 'GET',
|
||||
path: 'startScan.view',
|
||||
query: ssType._parameters.startScan,
|
||||
responses: {
|
||||
200: ssType._response.startScan,
|
||||
},
|
||||
},
|
||||
getScanStatus: {
|
||||
method: 'GET',
|
||||
path: 'getScanStatus.view',
|
||||
query: ssType._parameters.getScanStatus,
|
||||
responses: {
|
||||
200: ssType._response.getScanStatus,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -2497,6 +2497,17 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
startScan: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).startScan({ query: {} });
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to start scan');
|
||||
}
|
||||
|
||||
return res.body.scanStatus;
|
||||
},
|
||||
};
|
||||
|
||||
function getLibraryId(musicFolderId?: string | string[]) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { openModal } from '@mantine/modals';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
import { SongEditModal } from '/@/renderer/features/tag-editor/components/song-edit-modal';
|
||||
|
||||
interface EditMetadataActionProps {
|
||||
albumIds?: string[];
|
||||
songs?: Song[];
|
||||
}
|
||||
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
const getAlbumSongs = async (albumIds: string[], serverId: string): Promise<Song[]> => {
|
||||
const albumDetails = await Promise.all(
|
||||
albumIds.map((id) =>
|
||||
controller.getAlbumDetail({
|
||||
apiClientProps: { serverId },
|
||||
query: { id },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return albumDetails.flatMap((album) => album?.songs ?? []).filter((s) => s.path);
|
||||
};
|
||||
|
||||
export const EditMetadataAction = ({ albumIds, songs: songItems }: EditMetadataActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const songs = songItems?.filter((s) => s.path) ?? [];
|
||||
const count = albumIds?.length ?? songs.length;
|
||||
|
||||
const onSelect = useCallback(async () => {
|
||||
let resolvedSongs: Song[];
|
||||
|
||||
if (albumIds) {
|
||||
resolvedSongs = server?.id ? await getAlbumSongs(albumIds, server.id) : [];
|
||||
} else {
|
||||
resolvedSongs = songs;
|
||||
}
|
||||
|
||||
const trackCount = resolvedSongs.length;
|
||||
openModal({
|
||||
children: <SongEditModal songs={resolvedSongs} />,
|
||||
size: 'xl',
|
||||
styles: { body: { paddingBottom: 'var(--theme-spacing-xl)' } },
|
||||
title:
|
||||
trackCount > 1
|
||||
? `${t('page.contextMenu.editMetadata')} (${trackCount} ${t('common.tracks', 'tracks')})`
|
||||
: t('page.contextMenu.editMetadata'),
|
||||
});
|
||||
}, [albumIds, server, songs, t]);
|
||||
|
||||
if (!utils) return null;
|
||||
|
||||
return (
|
||||
<ContextMenu.Item disabled={count === 0} leftIcon="edit" onSelect={onSelect}>
|
||||
{t('page.contextMenu.editMetadata')}
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
@@ -41,6 +42,7 @@ export const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => {
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction albumIds={ids} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
@@ -46,6 +47,7 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
@@ -43,6 +44,7 @@ export const SongContextMenu = ({ items, type }: SongContextMenuProps) => {
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.artworkBox {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--mantine-color-dark-6);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.artworkImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
height: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
font-size: 5cqw;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
color: var(--theme-colors-state-error);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
import styles from './artwork-panel.module.css';
|
||||
|
||||
interface ArtworkPanelProps {
|
||||
artworkDisplayUrl: null | string;
|
||||
artworkIsMixed: boolean;
|
||||
multipleArtworksLabel: string;
|
||||
noArtworkLabel: string;
|
||||
onApplyBytes: (bytes: Uint8Array, mimeType: string) => void;
|
||||
onBrowse: () => void;
|
||||
onRemove: () => void;
|
||||
removeArtworkLabel: string;
|
||||
showRemoveButton: boolean;
|
||||
}
|
||||
|
||||
export const ArtworkPanel = ({
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
multipleArtworksLabel,
|
||||
noArtworkLabel,
|
||||
onApplyBytes,
|
||||
onBrowse,
|
||||
onRemove,
|
||||
removeArtworkLabel,
|
||||
showRemoveButton,
|
||||
}: ArtworkPanelProps) => (
|
||||
<Stack align="center" gap="md" pt="md">
|
||||
<DragDropZone
|
||||
className={styles.artworkBox}
|
||||
mode="file"
|
||||
onClick={onBrowse}
|
||||
onFileSelected={async (file) => {
|
||||
const buf = await file.arrayBuffer();
|
||||
onApplyBytes(new Uint8Array(buf), file.type);
|
||||
}}
|
||||
>
|
||||
{artworkDisplayUrl ? (
|
||||
<img alt="Cover art" className={styles.artworkImage} src={artworkDisplayUrl} />
|
||||
) : (
|
||||
<Stack align="center" className={styles.placeholder} justify="center">
|
||||
<Text className={styles.placeholderText}>
|
||||
{artworkIsMixed ? multipleArtworksLabel : noArtworkLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</DragDropZone>
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
className={styles.removeButton}
|
||||
onClick={onRemove}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
{removeArtworkLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
.tabLabel {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tableScroller {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--theme-spacing-sm);
|
||||
padding-top: var(--theme-spacing-sm);
|
||||
padding-bottom: var(--theme-spacing-sm);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.tableCell {
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
width: 1%;
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
font-weight: 500;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './song-edit-modal.module.css';
|
||||
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { Tabs } from '/@/shared/components/tabs/tabs';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
||||
import { ArtworkPanel } from './artwork-panel';
|
||||
import { TagFieldRow } from './tag-field-row';
|
||||
|
||||
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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({
|
||||
browser: window.api.browser,
|
||||
songs,
|
||||
utils: window.api.utils,
|
||||
});
|
||||
|
||||
// After adding the field, scrolls to and focuses the new field's row in the table.
|
||||
const handleAddField = (key: null | string) => {
|
||||
editor.handleAddField(key);
|
||||
if (!key) return;
|
||||
|
||||
// Scroll to focus on the newly added field
|
||||
requestAnimationFrame(() => {
|
||||
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-field-key="${key}"]`,
|
||||
);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// While loading, shows a spinner
|
||||
if (editor.isLoading) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="xl">
|
||||
<Spinner />
|
||||
{editor.loadProgress && editor.loadProgress.total > 1 && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{editor.loadProgress.processed} / {editor.loadProgress.total}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// If there was an error loading the metadata, shows the error message
|
||||
if (editor.error) {
|
||||
return (
|
||||
<Stack p="md">
|
||||
<Text c="red">{editor.error}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Tabs defaultValue="tags" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab className={styles.tabLabel} value="tags">
|
||||
{t('page.itemDetail.tagsTab', 'Tags')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab className={styles.tabLabel} value="artwork">
|
||||
{t('page.itemDetail.artworkTab', 'Artwork')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="tags">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{editor.readWarning && (
|
||||
<Text c="orange" size="sm">
|
||||
{editor.readWarning}
|
||||
</Text>
|
||||
)}
|
||||
{editor.availableToAdd.length > 0 && (
|
||||
<Select
|
||||
clearable
|
||||
data={editor.availableToAdd}
|
||||
onChange={handleAddField}
|
||||
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
||||
value={null}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.tableScroller} ref={tableContainerRef}>
|
||||
<Table
|
||||
classNames={{ td: styles.tableCell, th: styles.tableHeader }}
|
||||
highlightOnHover={false}
|
||||
withRowBorders
|
||||
>
|
||||
<Table.Tbody>
|
||||
{editor.sortedFieldEntries.map(([key, value]) => (
|
||||
<TagFieldRow
|
||||
isMixed={editor.mixedKeys.has(key)}
|
||||
key={key}
|
||||
meta={editor.getFieldMeta(key)}
|
||||
mixedPlaceholder={
|
||||
editor.mixedKeys.has(key)
|
||||
? editor.mixedPlaceholder
|
||||
: undefined
|
||||
}
|
||||
onChange={(v) => editor.handleFieldChange(key, v)}
|
||||
onRemove={() => editor.handleRemoveField(key)}
|
||||
tagKey={key}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="artwork">
|
||||
<ArtworkPanel
|
||||
artworkDisplayUrl={editor.artworkDisplayUrl}
|
||||
artworkIsMixed={editor.artworkIsMixed}
|
||||
multipleArtworksLabel={t(
|
||||
'page.itemDetail.multipleArtworks',
|
||||
'Multiple Artworks',
|
||||
)}
|
||||
noArtworkLabel={t('page.itemDetail.noArtwork', 'No Artwork')}
|
||||
onApplyBytes={editor.applyArtworkBytes}
|
||||
onBrowse={editor.handleChangeArtwork}
|
||||
onRemove={editor.handleRemoveArtwork}
|
||||
removeArtworkLabel={t('page.itemDetail.removeArtwork', 'Remove Artwork')}
|
||||
showRemoveButton={editor.showRemoveArtworkButton}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Checkbox
|
||||
checked={editor.rescan}
|
||||
label={t('page.itemDetail.triggerRescan')}
|
||||
onChange={(e) => editor.setRescan(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => closeAllModals()} variant="subtle">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={editor.isSaving} onClick={editor.handleSave} variant="filled">
|
||||
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
|
||||
? `${t('common.save', 'Save')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
|
||||
: t('common.save', 'Save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.removeCell {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { RiCloseLine } from 'react-icons/ri';
|
||||
|
||||
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 { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
|
||||
import type { KnownTag } from '../utils/known-tags';
|
||||
|
||||
import styles from './tag-field-row.module.css';
|
||||
|
||||
interface TagFieldRowProps {
|
||||
isMixed: boolean;
|
||||
meta: KnownTag;
|
||||
mixedPlaceholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
tagKey: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const TagFieldRow = ({
|
||||
isMixed,
|
||||
meta,
|
||||
mixedPlaceholder,
|
||||
onChange,
|
||||
onRemove,
|
||||
tagKey,
|
||||
value,
|
||||
}: TagFieldRowProps) => (
|
||||
<Table.Tr data-field-key={tagKey} key={tagKey}>
|
||||
<Table.Th>{meta.label}</Table.Th>
|
||||
<Table.Td>
|
||||
{meta.type === 'textarea' ? (
|
||||
<Textarea
|
||||
autosize
|
||||
maxRows={6}
|
||||
minRows={2}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={value}
|
||||
/>
|
||||
) : meta.type === 'number' ? (
|
||||
<NumberInput
|
||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={isMixed || value === '' ? undefined : Number(value)}
|
||||
/>
|
||||
) : meta.type === 'boolean' ? (
|
||||
<Checkbox
|
||||
checked={!isMixed && value === '1'}
|
||||
indeterminate={isMixed}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td className={styles.removeCell}>
|
||||
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
|
||||
<RiCloseLine size={16} />
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
ArtworkKind,
|
||||
ArtworkOp,
|
||||
BatchProgress,
|
||||
TagEditorUtils,
|
||||
} from '/@/shared/types/tag-editor';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
|
||||
import { base64ToBytes, filterTagSummary, formatBatchFileErrors } from '../utils/utils';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
/**
|
||||
* Subscribes to batch progress events for the duration of `fn`, then unsubscribes.
|
||||
* Ensures the progress handler is always cleaned up even if `fn` throws.
|
||||
*/
|
||||
const withBatchProgress = async <T>(
|
||||
utils: TagEditorUtils,
|
||||
onProgress: (data: BatchProgress) => void,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const handler = (_e: unknown, data: BatchProgress) => onProgress(data);
|
||||
utils.onBatchProgress(handler);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
utils.offBatchProgress(handler);
|
||||
}
|
||||
};
|
||||
|
||||
interface UseMetadataEditorArgs {
|
||||
browser: null | { clearCache: () => Promise<void> };
|
||||
songs?: Song[];
|
||||
utils: TagEditorUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the metadata editor UI: loads song tags from disk, tracks field edits
|
||||
* and artwork changes, and writes them back on save.
|
||||
*/
|
||||
export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetadataEditorArgs) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [readWarning, setReadWarning] = useState<null | string>(null);
|
||||
const [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
|
||||
const [rescan, setRescan] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tagSummary, setTagSummary] = useState<Record<string, null | string>>({});
|
||||
const [editedFields, setEditedFields] = useState<Record<string, string>>({});
|
||||
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
|
||||
const [loadedArtwork, setLoadedArtwork] = useState<{ kind: ArtworkKind }>({ kind: 'none' });
|
||||
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
|
||||
const [artworkOp, setArtworkOp] = useState<ArtworkOp | null>(null);
|
||||
|
||||
/**
|
||||
* Merges `tagSummary` (on-disk values) and `editedFields` (unsaved edits) into
|
||||
* `displayFields`. Keys present in `tagSummary` with a `null` value (differing
|
||||
* across files) are added to `mixedKeys`. `sortedFieldEntries` applies
|
||||
* `FIELD_PRIORITY` ordering, then alphabetical by label for unlisted keys.
|
||||
*/
|
||||
const { displayFields, mixedKeys, sortedFieldEntries } = useMemo(() => {
|
||||
const allKeys = new Set<string>();
|
||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
||||
for (const k of removedKeys) allKeys.delete(k);
|
||||
|
||||
const displayFields: Record<string, string> = {};
|
||||
const mixedKeys = new Set<string>();
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key in editedFields) {
|
||||
displayFields[key] = editedFields[key];
|
||||
continue;
|
||||
}
|
||||
const summaryVal = tagSummary[key];
|
||||
if (summaryVal === null) {
|
||||
mixedKeys.add(key);
|
||||
displayFields[key] = '';
|
||||
} else if (summaryVal !== undefined) {
|
||||
displayFields[key] = summaryVal;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFieldEntries = Object.entries(displayFields).sort(([a], [b]) => {
|
||||
const pa = FIELD_PRIORITY.indexOf(a);
|
||||
const pb = FIELD_PRIORITY.indexOf(b);
|
||||
if (pa !== -1 && pb !== -1) return pa - pb;
|
||||
if (pa !== -1) return -1;
|
||||
if (pb !== -1) return 1;
|
||||
const labelA = KNOWN_TAG_MAP.get(a)?.label ?? a;
|
||||
const labelB = KNOWN_TAG_MAP.get(b)?.label ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
|
||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||
}, [tagSummary, editedFields, removedKeys]);
|
||||
|
||||
/**
|
||||
* Runs once on mount: reads metadata for all songs in batch and populates
|
||||
* tag summary and initial artwork state.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const songs = (songsProp ?? []).filter((s) => s.path);
|
||||
|
||||
if (songs.length === 0) {
|
||||
setError(t('page.itemDetail.noLocalSongs', 'No songs with local file paths found'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedSongs(songs);
|
||||
const paths = songs.map((s) => s.path!);
|
||||
|
||||
const batchResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.readSongMetadataBatch(paths),
|
||||
);
|
||||
|
||||
if (!batchResult.success || !batchResult.tagSummary) {
|
||||
setError(batchResult.error ?? t('page.itemDetail.fileNotWritable'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchResult.failedFiles?.length) {
|
||||
const count = batchResult.failedFiles.length;
|
||||
const total = batchResult.totalCount ?? paths.length;
|
||||
setReadWarning(
|
||||
t('page.itemDetail.readPartialFailure', {
|
||||
count,
|
||||
defaultValue: `Could not read metadata from ${count} of ${total} file(s).`,
|
||||
total,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setTagSummary(filterTagSummary(batchResult.tagSummary));
|
||||
|
||||
if (
|
||||
batchResult.artworkKind === 'common' &&
|
||||
batchResult.artworkData &&
|
||||
batchResult.artworkMimeType
|
||||
) {
|
||||
setArtworkDisplayUrl(
|
||||
`data:${batchResult.artworkMimeType};base64,${batchResult.artworkData}`,
|
||||
);
|
||||
}
|
||||
setLoadedArtwork({ kind: batchResult.artworkKind });
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initialize().catch((err) => {
|
||||
setError(String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => utils.cancelReadSongMetadata();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** Records an edited value for `key`, overriding the on-disk summary. */
|
||||
const handleFieldChange = useCallback((key: string, value: string) => {
|
||||
setEditedFields((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
/** Removes `key` from both `editedFields` and the display, marking it for deletion on save. */
|
||||
const handleRemoveField = useCallback((key: string) => {
|
||||
setEditedFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||
}, []);
|
||||
|
||||
/** Adds `key` to `editedFields` with an empty value and un-marks it from removal. */
|
||||
const handleAddField = useCallback((key: null | string) => {
|
||||
if (!key) return;
|
||||
setEditedFields((prev) => ({ ...prev, [key]: '' }));
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Creates a blob URL from raw image bytes and queues a `set` artwork operation. */
|
||||
const applyArtworkBytes = useCallback((bytes: Uint8Array, mimeType: string) => {
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
|
||||
setArtworkDisplayUrl(URL.createObjectURL(blob));
|
||||
setArtworkOp({ bytes, mimeType, type: 'set' });
|
||||
}, []);
|
||||
|
||||
/** Opens a native file picker, reads the selected image, and applies it as the new artwork. */
|
||||
const handleChangeArtwork = useCallback(async () => {
|
||||
const path = await window.api.localSettings.openFileSelector({
|
||||
filters: [{ extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'], name: 'Images' }],
|
||||
});
|
||||
if (!path) return;
|
||||
const result = await utils.readLocalImage(path);
|
||||
if (result.success && result.data && result.mimeType) {
|
||||
applyArtworkBytes(base64ToBytes(result.data), result.mimeType);
|
||||
}
|
||||
}, [applyArtworkBytes, utils]);
|
||||
|
||||
/** Clears the artwork preview and queues a `clear` artwork operation. */
|
||||
const handleRemoveArtwork = useCallback(() => {
|
||||
setArtworkDisplayUrl(null);
|
||||
setArtworkOp({ type: 'clear' });
|
||||
}, []);
|
||||
|
||||
/** Returns the `KnownTag` descriptor for `key`, falling back to a generic string entry. */
|
||||
const getFieldMeta = useCallback(
|
||||
(key: string): KnownTag => KNOWN_TAG_MAP.get(key) ?? { key, label: key, type: 'string' },
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates that no editable fields are empty, writes all tag and artwork
|
||||
* changes to disk in batch, then optionally triggers a server rescan and
|
||||
* closes the modal.
|
||||
*/
|
||||
const handleSave = useCallback(async () => {
|
||||
if (resolvedSongs.length === 0) return;
|
||||
|
||||
const emptyFields = Object.entries(editedFields)
|
||||
.filter(([key, value]) => {
|
||||
const meta = KNOWN_TAG_MAP.get(key);
|
||||
return meta?.type !== 'boolean' && value.trim() === '';
|
||||
})
|
||||
.map(([key]) => KNOWN_TAG_MAP.get(key)?.label ?? key);
|
||||
|
||||
if (emptyFields.length > 0) {
|
||||
toast.error({
|
||||
message: `${t('page.itemDetail.emptyFields', 'Fields cannot be empty')}: ${emptyFields.join(', ')}`,
|
||||
title: t('error.generalError', 'Error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const paths = resolvedSongs.map((s) => s.path!).filter(Boolean);
|
||||
|
||||
try {
|
||||
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.writeSongTagsBatch(
|
||||
paths,
|
||||
editedFields,
|
||||
[...removedKeys],
|
||||
artworkOp ?? undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (!writeResult.success) {
|
||||
const failed = writeResult.failedFiles ?? [];
|
||||
const message =
|
||||
failed.length > 0
|
||||
? formatBatchFileErrors(
|
||||
failed,
|
||||
t('page.itemDetail.writePartialFailure', {
|
||||
count: failed.length,
|
||||
defaultValue: `Failed to save ${failed.length} file(s).`,
|
||||
}),
|
||||
)
|
||||
: (writeResult.error ?? t('page.itemDetail.fileNotWritable'));
|
||||
toast.error({ message, title: t('error.generalError', 'Error') });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success({ message: t('page.itemDetail.tagsSaved') });
|
||||
|
||||
if (artworkOp) {
|
||||
await browser?.clearCache();
|
||||
}
|
||||
|
||||
if (rescan && server) {
|
||||
try {
|
||||
await controller.startScan({ apiClientProps: { serverId: server.id } });
|
||||
toast.success({ message: t('page.itemDetail.rescanStarted') });
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
closeAllModals();
|
||||
} finally {
|
||||
setLoadProgress(null);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [artworkOp, browser, editedFields, removedKeys, rescan, resolvedSongs, server, t, utils]);
|
||||
|
||||
/** Known tags not yet present in `displayFields`, sorted alphabetically for the add-field dropdown. */
|
||||
const availableToAdd = useMemo(
|
||||
() =>
|
||||
KNOWN_TAGS.filter((tag) => !(tag.key in displayFields))
|
||||
.map((tag) => ({ label: tag.label, value: tag.key }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[displayFields],
|
||||
);
|
||||
|
||||
const artworkIsMixed = artworkOp === null && loadedArtwork.kind === 'mixed';
|
||||
const showRemoveArtworkButton =
|
||||
artworkOp?.type !== 'clear' && (artworkOp?.type === 'set' || loadedArtwork.kind !== 'none');
|
||||
|
||||
const mixedPlaceholder = t('page.itemDetail.multipleValues', '(Multiple Values)');
|
||||
|
||||
return {
|
||||
applyArtworkBytes,
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
availableToAdd,
|
||||
error,
|
||||
getFieldMeta,
|
||||
handleAddField,
|
||||
handleChangeArtwork,
|
||||
handleFieldChange,
|
||||
handleRemoveArtwork,
|
||||
handleRemoveField,
|
||||
handleSave,
|
||||
isLoading,
|
||||
isSaving,
|
||||
loadProgress,
|
||||
mixedKeys,
|
||||
mixedPlaceholder,
|
||||
readWarning,
|
||||
rescan,
|
||||
setRescan,
|
||||
showRemoveArtworkButton,
|
||||
sortedFieldEntries,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EditorFieldKey } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { EDITOR_FIELD_KEYS } from '/@/shared/types/tag-editor';
|
||||
|
||||
/** Tags pinned to the top of the editor table (most frequently edited). */
|
||||
export const FIELD_PRIORITY: readonly string[] = [
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'albumArtist',
|
||||
'trackNumber',
|
||||
'discNumber',
|
||||
'date',
|
||||
];
|
||||
|
||||
/** 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',
|
||||
};
|
||||
|
||||
/** Which form control to render for a tag row. */
|
||||
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
|
||||
|
||||
export interface KnownTag {
|
||||
key: string;
|
||||
label: string;
|
||||
type: TagFieldType;
|
||||
}
|
||||
|
||||
const humanizeKey = (key: string): string =>
|
||||
key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
|
||||
const resolveFieldType = (key: string): TagFieldType =>
|
||||
FIELD_TYPE_OVERRIDES[key as EditorFieldKey] ?? 'string';
|
||||
|
||||
/** Field definitions used to render the tag editor table and "Add field" dropdown. */
|
||||
export const KNOWN_TAGS: KnownTag[] = EDITOR_FIELD_KEYS.map((key) => ({
|
||||
key,
|
||||
label: FIELD_LABEL_OVERRIDES[key] ?? humanizeKey(key),
|
||||
type: resolveFieldType(key),
|
||||
}));
|
||||
|
||||
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BatchFileError } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { KNOWN_TAG_MAP } from './known-tags';
|
||||
|
||||
export const base64ToBytes = (base64: string): Uint8Array => {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const formatBatchFileErrors = (failed: BatchFileError[], summary: string): string => {
|
||||
const details = failed
|
||||
.slice(0, 3)
|
||||
.map((f) => f.path.split(/[/\\]/).pop() ?? f.path)
|
||||
.join(', ');
|
||||
const suffix = failed.length > 3 ? '…' : '';
|
||||
return `${summary} ${details}${suffix}`;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user