mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-23 02:46:40 +02:00
feat(tag-editor): add batch metadata editor with artwork support (Duplicate) (#2252)
* feat(tag-editor): add batch metadata editor with artwork support --------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
@@ -786,6 +786,18 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
refreshItems(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${i18n.t('error.apiRouteError')}: refreshItems`);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'refreshItems',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
removeFromPlaylist(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -301,6 +301,18 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
refreshItem: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'Items/:id/Refresh',
|
||||
query: z.object({
|
||||
MetadataRefreshMode: z.string().optional(),
|
||||
}),
|
||||
responses: {
|
||||
204: z.null(),
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1556,6 +1556,21 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to move item in playlist');
|
||||
}
|
||||
},
|
||||
refreshItems: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
await Promise.all(
|
||||
query.ids.map((id) =>
|
||||
jfApiClient(apiClientProps).refreshItem({
|
||||
body: null,
|
||||
params: { id },
|
||||
query: { MetadataRefreshMode: 'FullRefresh' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return null;
|
||||
},
|
||||
removeFromPlaylist: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -1027,6 +1027,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to move item in playlist');
|
||||
}
|
||||
},
|
||||
refreshItems: SubsonicController.refreshItems,
|
||||
removeFromPlaylist: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -186,6 +186,14 @@ export const contract = c.router({
|
||||
200: ssType._response.randomSongList,
|
||||
},
|
||||
},
|
||||
getScanStatus: {
|
||||
method: 'GET',
|
||||
path: 'getScanStatus.view',
|
||||
query: ssType._parameters.getScanStatus,
|
||||
responses: {
|
||||
200: ssType._response.getScanStatus,
|
||||
},
|
||||
},
|
||||
getServerInfo: {
|
||||
method: 'GET',
|
||||
path: 'getOpenSubsonicExtensions.view',
|
||||
@@ -345,6 +353,14 @@ export const contract = c.router({
|
||||
200: ssType._response.setRating,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
method: 'GET',
|
||||
path: 'startScan.view',
|
||||
query: ssType._parameters.startScan,
|
||||
responses: {
|
||||
200: ssType._response.startScan,
|
||||
},
|
||||
},
|
||||
updateInternetRadioStation: {
|
||||
method: 'GET',
|
||||
path: 'updateInternetRadioStation.view',
|
||||
|
||||
@@ -2082,6 +2082,17 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return res.body;
|
||||
},
|
||||
refreshItems: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).startScan({ query: {} });
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to start scan');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
removeFromPlaylist: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
interface EditMetadataActionProps {
|
||||
albumIds?: string[];
|
||||
songs?: Song[];
|
||||
}
|
||||
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
export const EditMetadataAction = ({ albumIds, songs: songItems }: EditMetadataActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const songs = useMemo(() => songItems?.filter((s) => s.path) ?? [], [songItems]);
|
||||
const count = albumIds?.length ?? songs.length;
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
openContextModal({
|
||||
innerProps: { albumIds, songs },
|
||||
modal: 'editMetadata',
|
||||
size: 'xl',
|
||||
styles: { body: { paddingBottom: 'var(--theme-spacing-xl)' } },
|
||||
title: t('page.contextMenu.editMetadata'),
|
||||
});
|
||||
}, [albumIds, 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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 { MoveQueueItemsAction } from '/@/renderer/features/context-menu/actions/move-queue-items-action';
|
||||
@@ -48,6 +49,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -14,86 +14,106 @@ import { Text } from '/@/shared/components/text/text';
|
||||
import { useDebouncedCallback } from '/@/shared/hooks/use-debounced-callback';
|
||||
import { Played } from '/@/shared/types/domain-types';
|
||||
|
||||
export const PathSettings = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useCurrentServerId();
|
||||
const randomSong = useQuery({
|
||||
...songsQueries.random({
|
||||
query: { limit: 1, played: Played.All },
|
||||
serverId,
|
||||
}),
|
||||
gcTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
interface PathSettingsProps {
|
||||
persistImmediately?: boolean;
|
||||
previewPath?: null | string;
|
||||
}
|
||||
|
||||
const { pathReplace, pathReplaceWith } = useGeneralSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const resolvedPreviewPath = useResolvedSongPath(randomSong.data?.items[0]?.path);
|
||||
|
||||
const [localPathReplace, setLocalPathReplace] = useState(pathReplace);
|
||||
const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPathReplace(pathReplace);
|
||||
}, [pathReplace]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPathReplaceWith(pathReplaceWith);
|
||||
}, [pathReplaceWith]);
|
||||
|
||||
const debouncedSetPathReplace = useDebouncedCallback((value: string) => {
|
||||
setSettings({
|
||||
general: {
|
||||
pathReplace: value,
|
||||
},
|
||||
export const PathSettings = memo(
|
||||
({ persistImmediately = false, previewPath }: PathSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useCurrentServerId();
|
||||
const randomSong = useQuery({
|
||||
...songsQueries.random({
|
||||
query: { limit: 1, played: Played.All },
|
||||
serverId,
|
||||
}),
|
||||
enabled: previewPath === undefined,
|
||||
gcTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const debouncedSetPathReplaceWith = useDebouncedCallback((value: string) => {
|
||||
setSettings({
|
||||
general: {
|
||||
pathReplaceWith: value,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
const { pathReplace, pathReplaceWith } = useGeneralSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const resolvedPreviewPath = useResolvedSongPath(
|
||||
previewPath === undefined ? randomSong.data?.items[0]?.path : previewPath,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group>
|
||||
<Text>{t('setting.pathReplace')}</Text>
|
||||
<ActionIcon
|
||||
icon="refresh"
|
||||
loading={randomSong.isFetching}
|
||||
onClick={() => randomSong.refetch()}
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
/>
|
||||
</Group>
|
||||
<Code>
|
||||
<Text isMuted size="md">
|
||||
{resolvedPreviewPath || ''}
|
||||
</Text>
|
||||
</Code>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setLocalPathReplace(value);
|
||||
debouncedSetPathReplace(value);
|
||||
}}
|
||||
placeholder={t('setting.pathReplace_optionRemovePrefix')}
|
||||
value={localPathReplace}
|
||||
/>
|
||||
<TextInput
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setLocalPathReplaceWith(value);
|
||||
debouncedSetPathReplaceWith(value);
|
||||
}}
|
||||
placeholder={t('setting.pathReplace_optionAddPrefix')}
|
||||
value={localPathReplaceWith}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
const [localPathReplace, setLocalPathReplace] = useState(pathReplace);
|
||||
const [localPathReplaceWith, setLocalPathReplaceWith] = useState(pathReplaceWith);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPathReplace(pathReplace);
|
||||
}, [pathReplace]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPathReplaceWith(pathReplaceWith);
|
||||
}, [pathReplaceWith]);
|
||||
|
||||
const debouncedSetPathReplace = useDebouncedCallback((value: string) => {
|
||||
setSettings({
|
||||
general: {
|
||||
pathReplace: value,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const debouncedSetPathReplaceWith = useDebouncedCallback((value: string) => {
|
||||
setSettings({
|
||||
general: {
|
||||
pathReplaceWith: value,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group>
|
||||
<Text>{t('setting.pathReplace')}</Text>
|
||||
{previewPath === undefined && (
|
||||
<ActionIcon
|
||||
icon="refresh"
|
||||
loading={randomSong.isFetching}
|
||||
onClick={() => randomSong.refetch()}
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Code>
|
||||
<Text isMuted size="md">
|
||||
{resolvedPreviewPath || ''}
|
||||
</Text>
|
||||
</Code>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setLocalPathReplace(value);
|
||||
if (persistImmediately) {
|
||||
setSettings({ general: { pathReplace: value } });
|
||||
} else {
|
||||
debouncedSetPathReplace(value);
|
||||
}
|
||||
}}
|
||||
placeholder={t('setting.pathReplace_optionRemovePrefix')}
|
||||
value={localPathReplace}
|
||||
/>
|
||||
<TextInput
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setLocalPathReplaceWith(value);
|
||||
if (persistImmediately) {
|
||||
setSettings({ general: { pathReplaceWith: value } });
|
||||
} else {
|
||||
debouncedSetPathReplaceWith(value);
|
||||
}
|
||||
}}
|
||||
placeholder={t('setting.pathReplace_optionAddPrefix')}
|
||||
value={localPathReplaceWith}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
|
||||
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
|
||||
interface AddFieldInputProps {
|
||||
availableFields: Array<{ label: string; value: string }>;
|
||||
existingFieldKeys: string[];
|
||||
onAddField: (key: string) => void;
|
||||
}
|
||||
|
||||
export const AddFieldInput = ({
|
||||
availableFields,
|
||||
existingFieldKeys,
|
||||
onAddField,
|
||||
}: AddFieldInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const skipNextEnterRef = useRef(false);
|
||||
const skipNextOnChangeResetRef = useRef(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [duplicateAttempted, setDuplicateAttempted] = useState(false);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
|
||||
const isKnownTag = Boolean(resolvedInputKey && KNOWN_TAG_MAP.has(resolvedInputKey));
|
||||
const customKeyError =
|
||||
trimmedInput && !isKnownTag
|
||||
? trimmedInput.includes('=')
|
||||
? "Tag key cannot contain '='"
|
||||
: // eslint-disable-next-line no-control-regex
|
||||
/[^\x00-\x7F]/.test(trimmedInput)
|
||||
? 'Tag key must use ASCII characters only'
|
||||
: null
|
||||
: null;
|
||||
|
||||
const duplicateError =
|
||||
trimmedInput && !customKeyError && existingFieldKeys.includes(resolvedInputKey)
|
||||
? 'Field already exists'
|
||||
: null;
|
||||
const fieldError = customKeyError ?? (duplicateAttempted ? duplicateError : null);
|
||||
|
||||
const addField = (key: string): boolean => {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const normalizedKey = resolveTagKey(trimmed);
|
||||
const isKnown = KNOWN_TAG_MAP.has(normalizedKey);
|
||||
if (
|
||||
!isKnown &&
|
||||
(trimmed.includes('=') || // eslint-disable-next-line no-control-regex
|
||||
/[^\x00-\x7F]/.test(trimmed))
|
||||
)
|
||||
return false;
|
||||
|
||||
if (existingFieldKeys.includes(normalizedKey)) {
|
||||
setDuplicateAttempted(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
setDuplicateAttempted(false);
|
||||
setInput('');
|
||||
onAddField(normalizedKey);
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Autocomplete
|
||||
data={availableFields}
|
||||
error={fieldError}
|
||||
flex={1}
|
||||
onChange={(value) => {
|
||||
setInput(value);
|
||||
if (skipNextOnChangeResetRef.current) {
|
||||
skipNextOnChangeResetRef.current = false;
|
||||
} else {
|
||||
setDuplicateAttempted(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (skipNextEnterRef.current) {
|
||||
skipNextEnterRef.current = false;
|
||||
return;
|
||||
}
|
||||
addField(input);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={(value) => {
|
||||
skipNextEnterRef.current = true;
|
||||
skipNextOnChangeResetRef.current = true;
|
||||
const added = addField(value);
|
||||
if (added) {
|
||||
queueMicrotask(() => setInput(''));
|
||||
}
|
||||
}}
|
||||
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
||||
value={input}
|
||||
/>
|
||||
<ActionIcon
|
||||
disabled={!!fieldError}
|
||||
icon="add"
|
||||
onClick={() => addField(input)}
|
||||
variant="filled"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
.artwork-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-dark-6);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
|
||||
.artwork-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
min-height: 200px;
|
||||
aspect-ratio: 1;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 5cqw;
|
||||
}
|
||||
|
||||
.icon-controls {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
z-index: 2;
|
||||
padding: 4px;
|
||||
pointer-events: none;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.icon-controls button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import styles from './artwork-panel.module.css';
|
||||
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
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 gap="md" pt="md">
|
||||
<DragDropZone
|
||||
accept="image/*"
|
||||
className={styles.artworkBox}
|
||||
mode="file"
|
||||
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>
|
||||
)}
|
||||
<Group className={styles.iconControls} gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
icon="uploadImage"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={onBrowse}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
variant="default"
|
||||
/>
|
||||
<ActionIcon
|
||||
aria-label={removeArtworkLabel}
|
||||
disabled={!showRemoveButton}
|
||||
icon="delete"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={onRemove}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
variant="default"
|
||||
/>
|
||||
</Group>
|
||||
</DragDropZone>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { SongEditModal } from '/@/renderer/features/tag-editor/components/song-edit-modal';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
type SongEditInnerProps = {
|
||||
albumIds?: string[];
|
||||
songs?: Song[];
|
||||
};
|
||||
|
||||
export const SongEditContextModal = ({ innerProps }: ContextModalProps<SongEditInnerProps>) => {
|
||||
const server = useCurrentServer();
|
||||
const [resolvedSongs, setResolvedSongs] = useState<null | Song[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (innerProps.albumIds) {
|
||||
if (!server?.id) {
|
||||
setResolvedSongs([]);
|
||||
return;
|
||||
}
|
||||
Promise.all(
|
||||
innerProps.albumIds.map((id) =>
|
||||
controller.getAlbumDetail({
|
||||
apiClientProps: { serverId: server.id },
|
||||
query: { id },
|
||||
}),
|
||||
),
|
||||
).then((albums) => {
|
||||
setResolvedSongs(
|
||||
albums.flatMap((album) => album?.songs ?? []).filter((s) => s.path),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setResolvedSongs((innerProps.songs ?? []).filter((s) => s.path));
|
||||
}
|
||||
}, [innerProps.albumIds, innerProps.songs, server?.id]);
|
||||
|
||||
if (resolvedSongs === null) {
|
||||
return (
|
||||
<Stack align="center" p="xl">
|
||||
<Spinner />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return <SongEditModal songs={resolvedSongs} />;
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
.scope-select-option {
|
||||
padding-block: var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.scope-option {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scope-option-label {
|
||||
padding-block: var(--theme-spacing-xs);
|
||||
}
|
||||
|
||||
.scope-option-image {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.scope-option-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-scroller {
|
||||
max-height: 55vh;
|
||||
padding-top: var(--theme-spacing-sm);
|
||||
padding-bottom: var(--theme-spacing-sm);
|
||||
margin-top: var(--theme-spacing-sm);
|
||||
border-top: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
min-width: 0;
|
||||
padding: var(--theme-spacing-xs);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-cell:nth-child(2) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-cell:last-child {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
width: 25%;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EDIT_SCOPE_ALL, useMetadataEditor } from '../hooks/use-metadata-editor';
|
||||
import { AddFieldInput } from './add-field-input';
|
||||
import { ArtworkPanel } from './artwork-panel';
|
||||
import styles from './song-edit-modal.module.css';
|
||||
import { TagEditorSettings } from './tag-editor-settings';
|
||||
import { TagFieldRow } from './tag-field-row';
|
||||
|
||||
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||
import { PathSettings } from '/@/renderer/features/settings/components/general/path-settings';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
|
||||
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 { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useMetadataEditor({
|
||||
browser: window.api.browser,
|
||||
songs,
|
||||
utils: window.api.utils,
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState<'artwork' | 'settings' | 'tags'>('tags');
|
||||
|
||||
const songsById = useMemo(
|
||||
() => new Map(editor.resolvedSongs.map((song) => [song.id, song])),
|
||||
[editor.resolvedSongs],
|
||||
);
|
||||
|
||||
const scopeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('common.countSelected', {
|
||||
count: editor.resolvedSongs.length,
|
||||
}),
|
||||
value: EDIT_SCOPE_ALL,
|
||||
},
|
||||
...editor.resolvedSongs.map((song) => ({
|
||||
label: song.name,
|
||||
value: song.id,
|
||||
})),
|
||||
],
|
||||
[editor.resolvedSongs, t],
|
||||
);
|
||||
|
||||
const renderScopeOption = useCallback(
|
||||
({ option }: { option: { label: string; value: string } }) => {
|
||||
if (option.value === EDIT_SCOPE_ALL) {
|
||||
return (
|
||||
<Text className={styles.scopeOptionLabel} fw={500}>
|
||||
{option.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const song = songsById.get(option.value);
|
||||
if (!song) {
|
||||
return option.label;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group className={styles.scopeOption} gap="sm" wrap="nowrap">
|
||||
<ItemImage
|
||||
containerClassName={styles.scopeOptionImage}
|
||||
enableViewport={false}
|
||||
explicitStatus={song.explicitStatus}
|
||||
id={song.imageId}
|
||||
itemType={LibraryItem.SONG}
|
||||
serverId={song._serverId}
|
||||
src={song.imageUrl}
|
||||
type="table"
|
||||
/>
|
||||
<Stack className={styles.scopeOptionMeta} gap={2}>
|
||||
<Text fw={500} lineClamp={1}>
|
||||
{song.name || '—'}
|
||||
</Text>
|
||||
<Text c="dimmed" lineClamp={1} size="sm">
|
||||
{song.artistName || '—'}
|
||||
</Text>
|
||||
<Text c="dimmed" lineClamp={1} size="sm">
|
||||
{song.album || '—'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
[songsById],
|
||||
);
|
||||
|
||||
const handleAddField = (key: string) => {
|
||||
editor.handleAddField(key);
|
||||
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>
|
||||
{editor.isFileNotFound && (
|
||||
<>
|
||||
<PathSettings persistImmediately previewPath={songs[0]?.path} />
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={editor.reload} variant="filled">
|
||||
{t('common.reload', 'Reload')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{editor.resolvedSongs.length > 1 ? (
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
classNames={{
|
||||
option: styles.scopeSelectOption,
|
||||
}}
|
||||
data={scopeOptions}
|
||||
onChange={(value) => {
|
||||
if (value) editor.setEditScope(value);
|
||||
}}
|
||||
renderOption={renderScopeOption}
|
||||
searchable
|
||||
value={editor.editScope}
|
||||
/>
|
||||
) : null}
|
||||
<Tabs
|
||||
keepMounted={false}
|
||||
onChange={(value) => setTab(value as 'artwork' | 'settings' | 'tags')}
|
||||
value={tab}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="tags">{t('page.itemDetail.tagsTab', 'Tags')}</Tabs.Tab>
|
||||
<Tabs.Tab value="artwork">
|
||||
{t('page.itemDetail.artworkTab', 'Artwork')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab ml="auto" value="settings">
|
||||
{t('common.settings', 'Settings')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="tags">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{editor.readWarning && (
|
||||
<Text c="orange" size="sm">
|
||||
{editor.readWarning}
|
||||
</Text>
|
||||
)}
|
||||
<AddFieldInput
|
||||
availableFields={editor.availableToAdd}
|
||||
existingFieldKeys={editor.sortedFieldEntries.map(([key]) => key)}
|
||||
onAddField={handleAddField}
|
||||
/>
|
||||
<ScrollArea className={styles.tableScroller} ref={tableContainerRef}>
|
||||
<Table
|
||||
classNames={{
|
||||
table: styles.table,
|
||||
td: styles.tableCell,
|
||||
th: styles.tableHeader,
|
||||
}}
|
||||
highlightOnHover={false}
|
||||
withRowBorders
|
||||
>
|
||||
<Table.Tbody>
|
||||
{editor.sortedFieldEntries.map(([key, value]) => {
|
||||
const tagConfig = editor.getTagConfig(key);
|
||||
return (
|
||||
<TagFieldRow
|
||||
autocompleteSource={tagConfig.autocompleteSource}
|
||||
customValues={tagConfig.customValues}
|
||||
hasTagConfig={editor.hasTagConfig(key)}
|
||||
isDirty={
|
||||
key in editor.editedFields ||
|
||||
editor.removedKeys.has(key)
|
||||
}
|
||||
isMixed={editor.mixedKeys.has(key)}
|
||||
isMultiValue={
|
||||
editor.multiValueKeys.has(key) ||
|
||||
Array.isArray(value)
|
||||
}
|
||||
isRemoved={editor.removedKeys.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)}
|
||||
onReset={() => editor.handleResetField(key)}
|
||||
onRevert={() => editor.handleRevertField(key)}
|
||||
tagKey={key}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</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.Panel value="settings">
|
||||
<TagEditorSettings />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{tab !== 'settings' && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={editor.rescan}
|
||||
label={t('page.itemDetail.triggerRescan')}
|
||||
onChange={(e) => editor.setRescan(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
disabled={editor.isSaving}
|
||||
onClick={() => closeAllModals()}
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={editor.isSaving}
|
||||
loading={editor.isSaving}
|
||||
onClick={() => editor.handleSave({ close: false })}
|
||||
variant="default"
|
||||
>
|
||||
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
|
||||
? `${t('common.save', 'Save')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
|
||||
: t('common.save', 'Save')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={editor.isSaving}
|
||||
onClick={() => editor.handleSave({ close: true })}
|
||||
variant="filled"
|
||||
>
|
||||
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
|
||||
? `${t('common.saveAndClose', 'Save and close')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
|
||||
: t('common.saveAndClose', 'Save and close')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, 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 { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import {
|
||||
type TagAutocompleteSource,
|
||||
type TagConfig,
|
||||
toServerTagAutocompleteSource,
|
||||
useCurrentServerId,
|
||||
useSettingsStore,
|
||||
useSettingsStoreActions,
|
||||
useTagEditorSettings,
|
||||
} from '/@/renderer/store';
|
||||
import { titleCase } from '/@/renderer/utils';
|
||||
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
|
||||
import { Accordion } from '/@/shared/components/accordion/accordion';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||
|
||||
const DEFAULT_TAG_CONFIG: TagConfig = {
|
||||
autocompleteSource: 'none',
|
||||
customValues: [],
|
||||
multiValue: false,
|
||||
};
|
||||
|
||||
export const TagEditorSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useCurrentServerId();
|
||||
const { tagConfigs } = useTagEditorSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const tagsQuery = useQuery({
|
||||
...sharedQueries.tagList({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 60,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
},
|
||||
query: { type: LibraryItem.SONG },
|
||||
serverId,
|
||||
}),
|
||||
enabled: Boolean(serverId),
|
||||
});
|
||||
|
||||
const configuredKeys = Object.keys(tagConfigs).sort((a, b) => {
|
||||
const labelA = KNOWN_TAG_MAP.get(a)?.tagName ?? a;
|
||||
const labelB = KNOWN_TAG_MAP.get(b)?.tagName ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
|
||||
const updateTagConfig = (key: string, patch: Partial<TagConfig>) => {
|
||||
const nextPatch =
|
||||
patch.customValues !== undefined
|
||||
? {
|
||||
...patch,
|
||||
customValues: [...patch.customValues].sort((a, b) => a.localeCompare(b)),
|
||||
}
|
||||
: patch;
|
||||
|
||||
setSettings({
|
||||
tagEditor: {
|
||||
tagConfigs: {
|
||||
[key]: {
|
||||
...DEFAULT_TAG_CONFIG,
|
||||
...tagConfigs[key],
|
||||
...nextPatch,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setTagConfigs = (next: Record<string, TagConfig>) => {
|
||||
useSettingsStore.setState((state) => {
|
||||
state.tagEditor.tagConfigs = next;
|
||||
});
|
||||
};
|
||||
|
||||
const addField = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const key = resolveTagKey(trimmed);
|
||||
if (key === 'lyrics' || key in tagConfigs) {
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
updateTagConfig(key, DEFAULT_TAG_CONFIG);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const removeField = (key: string) => {
|
||||
const rest = { ...tagConfigs };
|
||||
delete rest[key];
|
||||
setTagConfigs(rest);
|
||||
};
|
||||
|
||||
const availableFields = KNOWN_TAGS.filter(({ key }) => key !== 'lyrics' && !(key in tagConfigs))
|
||||
.map(({ key, tagName }) => ({ label: tagName, value: key }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const autocompleteSourceOptions = useMemo(() => {
|
||||
const excluded = new Set(tagsQuery.data?.excluded.song ?? []);
|
||||
const serverTagOptions =
|
||||
tagsQuery.data?.tags
|
||||
?.filter((tag) => !excluded.has(tag.name))
|
||||
.map((tag) => ({
|
||||
label: NDSongQueryFieldsLabelMap[tag.name] ?? titleCase(tag.name),
|
||||
value: toServerTagAutocompleteSource(tag.name),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)) ?? [];
|
||||
|
||||
return [
|
||||
{ label: t('common.none', 'None'), value: 'none' },
|
||||
{
|
||||
label: t('entity.artist_other'),
|
||||
value: 'serverArtists',
|
||||
},
|
||||
{
|
||||
label: t('entity.genre_other', 'Genres'),
|
||||
value: 'serverGenres',
|
||||
},
|
||||
...serverTagOptions,
|
||||
];
|
||||
}, [t, tagsQuery.data?.excluded.song, tagsQuery.data?.tags]);
|
||||
|
||||
const multiValueToggleData = [
|
||||
{
|
||||
label: t('common.filter_single'),
|
||||
value: 'single',
|
||||
},
|
||||
{
|
||||
label: t('common.filter_multiple'),
|
||||
value: 'multi',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text fw={500} size="md">
|
||||
{t('page.itemDetail.tagConfiguration', 'Tag configuration')}
|
||||
</Text>
|
||||
<Group>
|
||||
<Autocomplete
|
||||
data={availableFields}
|
||||
flex={1}
|
||||
onChange={setInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addField(input);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={addField}
|
||||
placeholder={t('page.itemDetail.addTagConfig', 'Add tag…')}
|
||||
py="md"
|
||||
value={input}
|
||||
/>
|
||||
<ActionIcon onClick={() => addField(input)} variant="filled">
|
||||
<RiAddLine size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{configuredKeys.length > 0 && (
|
||||
<Accordion multiple variant="separated">
|
||||
{configuredKeys.map((key) => {
|
||||
const tagName = KNOWN_TAG_MAP.get(key)?.tagName ?? key;
|
||||
const config = tagConfigs[key];
|
||||
return (
|
||||
<Accordion.Item key={key} value={key}>
|
||||
<Accordion.Control
|
||||
component="div"
|
||||
role="button"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text>{tagName}</Text>
|
||||
<Group
|
||||
gap="xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
wrap="nowrap"
|
||||
>
|
||||
<SegmentedControl
|
||||
data={multiValueToggleData}
|
||||
onChange={(value) =>
|
||||
updateTagConfig(key, {
|
||||
multiValue: value === 'multi',
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
value={config.multiValue ? 'multi' : 'single'}
|
||||
/>
|
||||
<ActionIcon
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
onClick={() => removeField(key)}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiCloseLine size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
data={autocompleteSourceOptions}
|
||||
label={t('page.itemDetail.autocompleteSource')}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
updateTagConfig(key, {
|
||||
autocompleteSource:
|
||||
value as TagAutocompleteSource,
|
||||
});
|
||||
}}
|
||||
searchable
|
||||
value={config.autocompleteSource}
|
||||
/>
|
||||
<TagsInput
|
||||
aria-label={`${t('page.itemDetail.customValues', 'Custom values')} - ${tagName}`}
|
||||
onChange={(values) =>
|
||||
updateTagConfig(key, { customValues: values })
|
||||
}
|
||||
placeholder={t(
|
||||
'page.itemDetail.addCustomValue',
|
||||
'Add custom value…',
|
||||
)}
|
||||
splitChars={[]}
|
||||
value={[...config.customValues].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
.remove-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
tr:hover .remove-button,
|
||||
.remove-button:focus-visible,
|
||||
.remove-button-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dirty-label {
|
||||
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.removed-row > :not(:last-child) {
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.removed-row > :first-child {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { TagValue } from '/@/shared/types/tag-editor';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { KnownTag } from '../utils/known-tags';
|
||||
|
||||
import { useTagAutocompleteSuggestions } from '../hooks/use-tag-autocomplete-suggestions';
|
||||
import styles from './tag-field-row.module.css';
|
||||
|
||||
import { type TagAutocompleteSource } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
|
||||
interface CustomTagsInputProps {
|
||||
autocompleteSource: TagAutocompleteSource;
|
||||
customValues: string[];
|
||||
disabled: boolean;
|
||||
mixedPlaceholder?: string;
|
||||
onChange: (value: string[]) => void;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
interface StringAutocompleteInputProps {
|
||||
autocompleteSource: TagAutocompleteSource;
|
||||
customValues: string[];
|
||||
disabled: boolean;
|
||||
mixedPlaceholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TagFieldRowProps {
|
||||
autocompleteSource: TagAutocompleteSource;
|
||||
customValues: string[];
|
||||
hasTagConfig: boolean;
|
||||
isDirty?: boolean;
|
||||
isMixed: boolean;
|
||||
isMultiValue: boolean;
|
||||
isRemoved: boolean;
|
||||
meta: KnownTag;
|
||||
mixedPlaceholder?: string;
|
||||
onChange: (value: TagValue) => void;
|
||||
onRemove: () => void;
|
||||
onReset: () => void;
|
||||
onRevert: () => void;
|
||||
tagKey: string;
|
||||
value: TagValue;
|
||||
}
|
||||
|
||||
const useSuggestionData = (
|
||||
autocompleteSource: TagAutocompleteSource,
|
||||
customValues: string[],
|
||||
searchValue: string,
|
||||
) => {
|
||||
const { groups, isLoading } = useTagAutocompleteSuggestions({
|
||||
customValues,
|
||||
search: searchValue,
|
||||
source: autocompleteSource,
|
||||
});
|
||||
|
||||
return { data: groups, isLoading };
|
||||
};
|
||||
|
||||
const CustomTagsInput = ({
|
||||
autocompleteSource,
|
||||
customValues,
|
||||
disabled,
|
||||
mixedPlaceholder,
|
||||
onChange,
|
||||
value,
|
||||
}: CustomTagsInputProps) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const { data, isLoading } = useSuggestionData(autocompleteSource, customValues, searchValue);
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
clearable
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
loading={isLoading}
|
||||
onChange={onChange}
|
||||
onSearchChange={setSearchValue}
|
||||
placeholder={mixedPlaceholder}
|
||||
searchValue={searchValue}
|
||||
size="sm"
|
||||
splitChars={[]}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StringAutocompleteInput = ({
|
||||
autocompleteSource,
|
||||
customValues,
|
||||
disabled,
|
||||
mixedPlaceholder,
|
||||
onChange,
|
||||
value,
|
||||
}: StringAutocompleteInputProps) => {
|
||||
const { data, isLoading } = useSuggestionData(autocompleteSource, customValues, value);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
limit={100}
|
||||
loading={isLoading}
|
||||
onChange={onChange}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagFieldRow = ({
|
||||
autocompleteSource,
|
||||
customValues,
|
||||
hasTagConfig,
|
||||
isDirty,
|
||||
isMixed,
|
||||
isMultiValue,
|
||||
isRemoved,
|
||||
meta,
|
||||
mixedPlaceholder,
|
||||
onChange,
|
||||
onRemove,
|
||||
onReset,
|
||||
onRevert,
|
||||
tagKey,
|
||||
value,
|
||||
}: TagFieldRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const stringValue = Array.isArray(value) ? value.join('; ') : value;
|
||||
const useStringAutocomplete =
|
||||
!isMultiValue && meta.type === 'string' && tagKey !== 'lyrics' && hasTagConfig;
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
className={clsx({
|
||||
[styles.removedRow]: isRemoved,
|
||||
})}
|
||||
data-field-key={tagKey}
|
||||
key={tagKey}
|
||||
>
|
||||
<Table.Th className={clsx({ [styles.dirtyLabel]: isDirty })}>{meta.tagName}</Table.Th>
|
||||
<Table.Td>
|
||||
{isMultiValue && tagKey !== 'lyrics' ? (
|
||||
<CustomTagsInput
|
||||
autocompleteSource={autocompleteSource}
|
||||
customValues={customValues}
|
||||
disabled={isRemoved}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
onChange={onChange}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
/>
|
||||
) : meta.type === 'textarea' ? (
|
||||
<Textarea
|
||||
autosize
|
||||
disabled={isRemoved}
|
||||
maxRows={6}
|
||||
minRows={2}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('\n\n') : value}
|
||||
/>
|
||||
) : meta.type === 'number' ? (
|
||||
<NumberInput
|
||||
disabled={isRemoved}
|
||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={
|
||||
isMixed || value === '' || Array.isArray(value)
|
||||
? undefined
|
||||
: Number(value)
|
||||
}
|
||||
/>
|
||||
) : meta.type === 'boolean' ? (
|
||||
<Checkbox
|
||||
checked={!isMixed && value === '1'}
|
||||
disabled={isRemoved}
|
||||
indeterminate={isMixed}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||
size="sm"
|
||||
/>
|
||||
) : useStringAutocomplete ? (
|
||||
<StringAutocompleteInput
|
||||
autocompleteSource={autocompleteSource}
|
||||
customValues={customValues}
|
||||
disabled={isRemoved}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
onChange={onChange}
|
||||
value={stringValue}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled={isRemoved}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={stringValue}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td className={styles.removeCell}>
|
||||
<ActionIcon
|
||||
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
|
||||
className={clsx(styles.removeButton, {
|
||||
[styles.removeButtonVisible]: isRemoved || isDirty,
|
||||
})}
|
||||
icon={isRemoved || isDirty ? 'undo' : 'x'}
|
||||
iconProps={{
|
||||
color: isRemoved || isDirty ? 'default' : 'error',
|
||||
size: 'lg',
|
||||
}}
|
||||
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,679 @@
|
||||
import type {
|
||||
ArtworkKind,
|
||||
ArtworkOp,
|
||||
BatchProgress,
|
||||
FileArtworkData,
|
||||
TagEditorUtils,
|
||||
TagValue,
|
||||
} from '/@/shared/types/tag-editor';
|
||||
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
|
||||
import { base64ToBytes, bytesToBase64, formatBatchFileErrors } from '../utils/utils';
|
||||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export const EDIT_SCOPE_ALL = '__all__';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
|
||||
const toArtworkDataUrl = (artwork: FileArtworkData): string =>
|
||||
`data:${artwork.mimeType};base64,${artwork.data}`;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const rebuildMergedTagSummary = (
|
||||
tagsByPath: Record<string, Record<string, TagValue>>,
|
||||
): Record<string, null | TagValue> => {
|
||||
const paths = Object.keys(tagsByPath);
|
||||
if (paths.length === 0) return {};
|
||||
|
||||
const summary: Record<string, null | TagValue> = { ...tagsByPath[paths[0]] };
|
||||
for (let index = 1; index < paths.length; index++) {
|
||||
const tags = tagsByPath[paths[index]];
|
||||
for (const key of Object.keys(summary)) {
|
||||
if (summary[key] !== null && !tagValuesEqual(summary[key] as TagValue, tags[key])) {
|
||||
summary[key] = null;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(tags)) {
|
||||
if (!(key in summary)) summary[key] = null;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
};
|
||||
|
||||
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 { tagConfigs, triggerRescan } = useTagEditorSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const multiValueFields = useMemo(
|
||||
() =>
|
||||
Object.entries(tagConfigs)
|
||||
.filter(([, config]) => config.multiValue)
|
||||
.map(([key]) => key),
|
||||
[tagConfigs],
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [isFileNotFound, setIsFileNotFound] = useState(false);
|
||||
const [readWarning, setReadWarning] = useState<null | string>(null);
|
||||
const [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editScope, setEditScopeState] = useState<string>(EDIT_SCOPE_ALL);
|
||||
const [mergedTagSummary, setMergedTagSummary] = useState<Record<string, null | TagValue>>({});
|
||||
const [fileTags, setFileTags] = useState<Record<string, Record<string, TagValue>>>({});
|
||||
const [fileArtwork, setFileArtwork] = useState<Record<string, FileArtworkData>>({});
|
||||
const [mergedArtworkKind, setMergedArtworkKind] = useState<ArtworkKind>('none');
|
||||
const [mergedArtwork, setMergedArtwork] = useState<FileArtworkData | null>(null);
|
||||
const [batchMultiValueKeys, setBatchMultiValueKeys] = useState<Set<string>>(new Set());
|
||||
const [editedFields, setEditedFields] = useState<Record<string, TagValue>>({});
|
||||
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
|
||||
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
|
||||
const [artworkOp, setArtworkOp] = useState<ArtworkOp | null>(null);
|
||||
|
||||
const setRescan = useCallback(
|
||||
(value: boolean) => setSettings({ tagEditor: { triggerRescan: value } }),
|
||||
[setSettings],
|
||||
);
|
||||
|
||||
const scopedSong = useMemo(
|
||||
() =>
|
||||
editScope === EDIT_SCOPE_ALL
|
||||
? null
|
||||
: (resolvedSongs.find((song) => song.id === editScope) ?? null),
|
||||
[editScope, resolvedSongs],
|
||||
);
|
||||
|
||||
const scopedPath = useMemo(() => {
|
||||
if (!scopedSong?.path) return null;
|
||||
return resolveSongPath(scopedSong.path);
|
||||
}, [scopedSong]);
|
||||
|
||||
const tagSummary = useMemo((): Record<string, null | TagValue> => {
|
||||
if (editScope === EDIT_SCOPE_ALL) return mergedTagSummary;
|
||||
if (!scopedPath) return {};
|
||||
return fileTags[scopedPath] ?? {};
|
||||
}, [editScope, fileTags, mergedTagSummary, scopedPath]);
|
||||
|
||||
const parsedMultiValueKeys = useMemo(() => {
|
||||
if (editScope === EDIT_SCOPE_ALL) return batchMultiValueKeys;
|
||||
if (!scopedPath) return new Set<string>();
|
||||
const tags = fileTags[scopedPath] ?? {};
|
||||
return new Set(
|
||||
Object.entries(tags)
|
||||
.filter(([, value]) => Array.isArray(value))
|
||||
.map(([key]) => key),
|
||||
);
|
||||
}, [batchMultiValueKeys, editScope, fileTags, scopedPath]);
|
||||
|
||||
const loadedArtworkKind = useMemo((): ArtworkKind => {
|
||||
if (editScope === EDIT_SCOPE_ALL) return mergedArtworkKind;
|
||||
if (!scopedPath) return 'none';
|
||||
return fileArtwork[scopedPath] ? 'common' : 'none';
|
||||
}, [editScope, fileArtwork, mergedArtworkKind, scopedPath]);
|
||||
|
||||
const applyBaselineArtwork = useCallback(
|
||||
(scope: string, songs: Song[]) => {
|
||||
if (scope === EDIT_SCOPE_ALL) {
|
||||
if (mergedArtworkKind === 'common' && mergedArtwork) {
|
||||
setArtworkDisplayUrl(toArtworkDataUrl(mergedArtwork));
|
||||
} else {
|
||||
setArtworkDisplayUrl(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const song = songs.find((item) => item.id === scope);
|
||||
const path = song?.path ? resolveSongPath(song.path) : null;
|
||||
const artwork = path ? fileArtwork[path] : undefined;
|
||||
setArtworkDisplayUrl(artwork ? toArtworkDataUrl(artwork) : null);
|
||||
},
|
||||
[fileArtwork, mergedArtwork, mergedArtworkKind],
|
||||
);
|
||||
|
||||
const setEditScope = useCallback(
|
||||
(scope: string) => {
|
||||
setEditScopeState(scope);
|
||||
setEditedFields({});
|
||||
setRemovedKeys(new Set());
|
||||
setArtworkOp(null);
|
||||
applyBaselineArtwork(scope, resolvedSongs);
|
||||
},
|
||||
[applyBaselineArtwork, resolvedSongs],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 multiValueKeys = useMemo(
|
||||
() => new Set([...multiValueFields, ...parsedMultiValueKeys]),
|
||||
[multiValueFields, parsedMultiValueKeys],
|
||||
);
|
||||
|
||||
const { displayFields, mixedKeys, sortedFieldEntries } = useMemo(() => {
|
||||
const allKeys = new Set<string>();
|
||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
||||
|
||||
const displayFields: Record<string, TagValue> = {};
|
||||
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] = multiValueKeys.has(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 tagNameA = KNOWN_TAG_MAP.get(a)?.tagName ?? a;
|
||||
const tagNameB = KNOWN_TAG_MAP.get(b)?.tagName ?? b;
|
||||
return tagNameA.localeCompare(tagNameB);
|
||||
});
|
||||
|
||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||
}, [tagSummary, editedFields, multiValueKeys]);
|
||||
|
||||
/** Reads metadata for all songs and populates the editor state. */
|
||||
const initialize = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsFileNotFound(false);
|
||||
setIsLoading(true);
|
||||
setLoadProgress(null);
|
||||
setReadWarning(null);
|
||||
setEditScopeState(EDIT_SCOPE_ALL);
|
||||
setEditedFields({});
|
||||
setRemovedKeys(new Set());
|
||||
setArtworkOp(null);
|
||||
|
||||
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) => resolveSongPath(s.path)).filter(Boolean) as string[];
|
||||
|
||||
const batchResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.readSongMetadataBatch(paths),
|
||||
);
|
||||
|
||||
if (!batchResult.success || !batchResult.tagSummary) {
|
||||
const failedFiles = batchResult.failedFiles ?? [];
|
||||
setIsFileNotFound(
|
||||
failedFiles.length === paths.length &&
|
||||
failedFiles.every((file) => file.code === 'ENOENT'),
|
||||
);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setMergedTagSummary(batchResult.tagSummary);
|
||||
setFileTags(batchResult.fileTags ?? {});
|
||||
setFileArtwork(batchResult.fileArtwork ?? {});
|
||||
setBatchMultiValueKeys(new Set(batchResult.multiValueKeys ?? []));
|
||||
setMergedArtworkKind(batchResult.artworkKind);
|
||||
|
||||
const nextMergedArtwork =
|
||||
batchResult.artworkKind === 'common' &&
|
||||
batchResult.artworkData &&
|
||||
batchResult.artworkMimeType
|
||||
? { data: batchResult.artworkData, mimeType: batchResult.artworkMimeType }
|
||||
: null;
|
||||
setMergedArtwork(nextMergedArtwork);
|
||||
setArtworkDisplayUrl(nextMergedArtwork ? toArtworkDataUrl(nextMergedArtwork) : null);
|
||||
setIsLoading(false);
|
||||
}, [songsProp, t, utils]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
initialize().catch((err) => {
|
||||
setIsFileNotFound(false);
|
||||
setError(String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [initialize]);
|
||||
|
||||
/** Loads on mount and cancels an in-flight read when the editor unmounts. */
|
||||
useEffect(() => {
|
||||
reload();
|
||||
|
||||
return () => utils.cancelReadSongMetadata();
|
||||
}, [reload, utils]);
|
||||
|
||||
/** Records an edited value for `key`, overriding the on-disk summary. */
|
||||
const handleFieldChange = useCallback((key: string, value: TagValue) => {
|
||||
setEditedFields((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
/** Marks `key` for deletion while preserving its displayed value for undo. */
|
||||
const handleRemoveField = useCallback((key: string) => {
|
||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||
}, []);
|
||||
|
||||
/** Re-enables a field that was marked for deletion. */
|
||||
const handleResetField = useCallback((key: string) => {
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Restores a field to its original on-disk value and removal state. */
|
||||
const handleRevertField = useCallback((key: string) => {
|
||||
setEditedFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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. */
|
||||
const handleAddField = useCallback(
|
||||
(key: null | string) => {
|
||||
if (!key) return;
|
||||
setEditedFields((prev) => {
|
||||
const wasRemoved = removedKeysRef.current.has(key);
|
||||
if (wasRemoved) return prev;
|
||||
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
||||
if (alreadyVisible) return prev;
|
||||
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
|
||||
});
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[multiValueKeys],
|
||||
);
|
||||
|
||||
/** Creates a blob URL from raw image bytes and queues a `set` artwork operation. */
|
||||
const applyArtworkBytes = useCallback((bytes: Uint8Array, mimeType: string) => {
|
||||
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, tagName: key, type: 'string' },
|
||||
[],
|
||||
);
|
||||
|
||||
const songsInScope = useMemo(
|
||||
() =>
|
||||
editScope === EDIT_SCOPE_ALL
|
||||
? resolvedSongs
|
||||
: resolvedSongs.filter((song) => song.id === editScope),
|
||||
[editScope, resolvedSongs],
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates that no editable fields are empty, writes tag and artwork changes
|
||||
* to disk for the current scope, optionally triggers a server rescan, then
|
||||
* either closes the modal or refreshes in-memory baselines so editing can continue.
|
||||
*/
|
||||
const handleSave = useCallback(
|
||||
async ({ close = true }: { close?: boolean } = {}) => {
|
||||
if (songsInScope.length === 0) return;
|
||||
|
||||
const activeEdits = Object.fromEntries(
|
||||
Object.entries(editedFields).filter(([key]) => !removedKeys.has(key)),
|
||||
) as Record<string, TagValue>;
|
||||
const emptyFields = Object.entries(activeEdits)
|
||||
.filter(([key, value]) => {
|
||||
const meta = KNOWN_TAG_MAP.get(key);
|
||||
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)?.tagName ?? 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 = songsInScope
|
||||
.map((s) => resolveSongPath(s.path))
|
||||
.filter(Boolean) as string[];
|
||||
const removedKeyList = [...removedKeys];
|
||||
|
||||
try {
|
||||
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.writeSongTagsBatch(
|
||||
paths,
|
||||
activeEdits,
|
||||
removedKeyList,
|
||||
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;
|
||||
}
|
||||
|
||||
if (artworkOp) {
|
||||
await browser?.clearCache();
|
||||
}
|
||||
|
||||
if (triggerRescan && server) {
|
||||
try {
|
||||
await controller.refreshItems({
|
||||
apiClientProps: { serverId: server.id },
|
||||
query: { ids: songsInScope.map((s) => s.id) },
|
||||
});
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
if (close) {
|
||||
closeAllModals();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFileTags = { ...fileTags };
|
||||
for (const path of paths) {
|
||||
const tags = { ...(nextFileTags[path] ?? {}) };
|
||||
for (const [key, value] of Object.entries(activeEdits)) {
|
||||
tags[key] = value;
|
||||
}
|
||||
for (const key of removedKeyList) {
|
||||
delete tags[key];
|
||||
}
|
||||
nextFileTags[path] = tags;
|
||||
}
|
||||
setFileTags(nextFileTags);
|
||||
setMergedTagSummary(rebuildMergedTagSummary(nextFileTags));
|
||||
|
||||
const nextFileArtwork = { ...fileArtwork };
|
||||
if (artworkOp?.type === 'set') {
|
||||
const nextArtwork = {
|
||||
data: bytesToBase64(artworkOp.bytes),
|
||||
mimeType: artworkOp.mimeType,
|
||||
};
|
||||
for (const path of paths) {
|
||||
nextFileArtwork[path] = nextArtwork;
|
||||
}
|
||||
setFileArtwork(nextFileArtwork);
|
||||
if (editScope === EDIT_SCOPE_ALL || Object.keys(nextFileTags).length === 1) {
|
||||
setMergedArtwork(nextArtwork);
|
||||
setMergedArtworkKind('common');
|
||||
} else {
|
||||
const artworkEntries = Object.values(nextFileArtwork);
|
||||
const allHaveArt = Object.keys(nextFileTags).every(
|
||||
(path) => nextFileArtwork[path],
|
||||
);
|
||||
const sameArt =
|
||||
allHaveArt &&
|
||||
artworkEntries.length > 0 &&
|
||||
artworkEntries.every(
|
||||
(entry) =>
|
||||
entry.data === artworkEntries[0].data &&
|
||||
entry.mimeType === artworkEntries[0].mimeType,
|
||||
);
|
||||
setMergedArtwork(sameArt ? artworkEntries[0] : null);
|
||||
setMergedArtworkKind(sameArt ? 'common' : 'mixed');
|
||||
}
|
||||
setArtworkDisplayUrl(toArtworkDataUrl(nextArtwork));
|
||||
} else if (artworkOp?.type === 'clear') {
|
||||
for (const path of paths) {
|
||||
delete nextFileArtwork[path];
|
||||
}
|
||||
setFileArtwork(nextFileArtwork);
|
||||
const remaining = Object.values(nextFileArtwork);
|
||||
if (remaining.length === 0) {
|
||||
setMergedArtwork(null);
|
||||
setMergedArtworkKind('none');
|
||||
} else if (
|
||||
remaining.every(
|
||||
(entry) =>
|
||||
entry.data === remaining[0].data &&
|
||||
entry.mimeType === remaining[0].mimeType,
|
||||
)
|
||||
) {
|
||||
setMergedArtwork(remaining[0]);
|
||||
setMergedArtworkKind('common');
|
||||
} else {
|
||||
setMergedArtwork(null);
|
||||
setMergedArtworkKind('mixed');
|
||||
}
|
||||
setArtworkDisplayUrl(null);
|
||||
}
|
||||
|
||||
const nextMultiValueKeys = new Set(batchMultiValueKeys);
|
||||
for (const [key, value] of Object.entries(activeEdits)) {
|
||||
if (Array.isArray(value)) nextMultiValueKeys.add(key);
|
||||
else nextMultiValueKeys.delete(key);
|
||||
}
|
||||
for (const key of removedKeyList) {
|
||||
nextMultiValueKeys.delete(key);
|
||||
}
|
||||
setBatchMultiValueKeys(nextMultiValueKeys);
|
||||
|
||||
setEditedFields({});
|
||||
setRemovedKeys(new Set());
|
||||
setArtworkOp(null);
|
||||
} finally {
|
||||
setLoadProgress(null);
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
artworkOp,
|
||||
batchMultiValueKeys,
|
||||
browser,
|
||||
editScope,
|
||||
editedFields,
|
||||
fileArtwork,
|
||||
fileTags,
|
||||
removedKeys,
|
||||
server,
|
||||
songsInScope,
|
||||
t,
|
||||
triggerRescan,
|
||||
utils,
|
||||
],
|
||||
);
|
||||
|
||||
/** Tags not yet present in `displayFields`: known tags plus configured custom tags. */
|
||||
const availableToAdd = useMemo(() => {
|
||||
const options = new Map<string, { label: string; value: string }>();
|
||||
|
||||
for (const tag of KNOWN_TAGS) {
|
||||
if (tag.key in displayFields) continue;
|
||||
options.set(tag.key, { label: tag.tagName, value: tag.key });
|
||||
}
|
||||
|
||||
for (const key of Object.keys(tagConfigs)) {
|
||||
if (key in displayFields || options.has(key)) continue;
|
||||
options.set(key, {
|
||||
label: KNOWN_TAG_MAP.get(key)?.tagName ?? key,
|
||||
value: key,
|
||||
});
|
||||
}
|
||||
|
||||
return [...options.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [displayFields, tagConfigs]);
|
||||
|
||||
const artworkIsMixed = artworkOp === null && loadedArtworkKind === 'mixed';
|
||||
const showRemoveArtworkButton =
|
||||
artworkOp?.type !== 'clear' && (artworkOp?.type === 'set' || loadedArtworkKind !== 'none');
|
||||
|
||||
const mixedPlaceholder = t('page.itemDetail.multipleValues', '(Multiple Values)');
|
||||
|
||||
const getTagConfig = useCallback(
|
||||
(key: string) =>
|
||||
tagConfigs[key] ?? {
|
||||
autocompleteSource: 'none' as const,
|
||||
customValues: [] as string[],
|
||||
multiValue: false,
|
||||
},
|
||||
[tagConfigs],
|
||||
);
|
||||
|
||||
const hasTagConfig = useCallback((key: string) => key in tagConfigs, [tagConfigs]);
|
||||
|
||||
return {
|
||||
applyArtworkBytes,
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
availableToAdd,
|
||||
editedFields,
|
||||
editScope,
|
||||
error,
|
||||
getFieldMeta,
|
||||
getTagConfig,
|
||||
handleAddField,
|
||||
handleChangeArtwork,
|
||||
handleFieldChange,
|
||||
handleRemoveArtwork,
|
||||
handleRemoveField,
|
||||
handleResetField,
|
||||
handleRevertField,
|
||||
handleSave,
|
||||
hasTagConfig,
|
||||
isFileNotFound,
|
||||
isLoading,
|
||||
isSaving,
|
||||
loadProgress,
|
||||
mixedKeys,
|
||||
mixedPlaceholder,
|
||||
multiValueKeys,
|
||||
readWarning,
|
||||
reload,
|
||||
removedKeys,
|
||||
rescan: triggerRescan,
|
||||
resolvedSongs,
|
||||
setEditScope,
|
||||
setRescan,
|
||||
showRemoveArtworkButton,
|
||||
sortedFieldEntries,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
|
||||
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
|
||||
import {
|
||||
getServerTagAutocompleteName,
|
||||
isServerTagAutocompleteSource,
|
||||
type TagAutocompleteSource,
|
||||
useCurrentServerId,
|
||||
} from '/@/renderer/store';
|
||||
import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value';
|
||||
import {
|
||||
AlbumArtistListSort,
|
||||
GenreListSort,
|
||||
LibraryItem,
|
||||
SortOrder,
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
const SUGGESTION_LIMIT = 25;
|
||||
|
||||
export type TagAutocompleteGroup = {
|
||||
group: string;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
interface UseTagAutocompleteSuggestionsArgs {
|
||||
customValues?: string[];
|
||||
search: string;
|
||||
source: TagAutocompleteSource;
|
||||
}
|
||||
|
||||
interface UseTagAutocompleteSuggestionsResult {
|
||||
groups: TagAutocompleteGroup[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const useTagAutocompleteSuggestions = ({
|
||||
customValues = [],
|
||||
search,
|
||||
source,
|
||||
}: UseTagAutocompleteSuggestionsArgs): UseTagAutocompleteSuggestionsResult => {
|
||||
const { t } = useTranslation();
|
||||
const serverId = useCurrentServerId();
|
||||
const trimmedSearch = search.trim();
|
||||
const [debouncedSearch = ''] = useDebouncedValue(trimmedSearch, 300);
|
||||
const canSearch = debouncedSearch.length >= 1;
|
||||
const isDebouncing = trimmedSearch !== debouncedSearch;
|
||||
const serverTagName = getServerTagAutocompleteName(source);
|
||||
const usesServerSearch =
|
||||
source === 'serverArtists' ||
|
||||
source === 'serverGenres' ||
|
||||
isServerTagAutocompleteSource(source);
|
||||
|
||||
const artistQuery = useQuery({
|
||||
...artistsQueries.albumArtistList({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: SUGGESTION_LIMIT,
|
||||
searchTerm: debouncedSearch,
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
enabled: source === 'serverArtists' && canSearch && Boolean(serverId),
|
||||
});
|
||||
|
||||
const genreQuery = useQuery({
|
||||
...genresQueries.list({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: SUGGESTION_LIMIT,
|
||||
searchTerm: debouncedSearch,
|
||||
sortBy: GenreListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId,
|
||||
}),
|
||||
enabled: source === 'serverGenres' && canSearch && Boolean(serverId),
|
||||
});
|
||||
|
||||
const tagListQuery = useQuery({
|
||||
...sharedQueries.tagList({
|
||||
options: {
|
||||
gcTime: 1000 * 60 * 60,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
},
|
||||
query: { type: LibraryItem.SONG },
|
||||
serverId,
|
||||
}),
|
||||
enabled: isServerTagAutocompleteSource(source) && canSearch && Boolean(serverId),
|
||||
});
|
||||
|
||||
const isFetching =
|
||||
(source === 'serverArtists' && artistQuery.isFetching) ||
|
||||
(source === 'serverGenres' && genreQuery.isFetching) ||
|
||||
(isServerTagAutocompleteSource(source) && tagListQuery.isFetching);
|
||||
|
||||
const isLoading = usesServerSearch && trimmedSearch.length >= 1 && (isDebouncing || isFetching);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const nextGroups: TagAutocompleteGroup[] = [];
|
||||
const customLower = new Set(customValues.map((value) => value.toLowerCase()));
|
||||
const searchLower = debouncedSearch.toLowerCase();
|
||||
|
||||
if (customValues.length > 0) {
|
||||
nextGroups.push({
|
||||
group: t('page.itemDetail.customValues', 'Custom values'),
|
||||
items: [...customValues].sort((a, b) => a.localeCompare(b)),
|
||||
});
|
||||
}
|
||||
|
||||
let serverNames: string[] = [];
|
||||
|
||||
if (source === 'serverArtists') {
|
||||
serverNames =
|
||||
artistQuery.data?.items.map((artist) => artist.name).filter(Boolean) ?? [];
|
||||
} else if (source === 'serverGenres') {
|
||||
serverNames = genreQuery.data?.items.map((genre) => genre.name).filter(Boolean) ?? [];
|
||||
} else if (serverTagName) {
|
||||
const tag = tagListQuery.data?.tags?.find((item) => item.name === serverTagName);
|
||||
serverNames =
|
||||
tag?.options
|
||||
.map((option) => option.name)
|
||||
.filter(
|
||||
(name) => name && (!canSearch || name.toLowerCase().includes(searchLower)),
|
||||
)
|
||||
.slice(0, SUGGESTION_LIMIT) ?? [];
|
||||
}
|
||||
|
||||
if (source === 'serverArtists' || source === 'serverGenres' || serverTagName) {
|
||||
const serverItems = [...new Set(serverNames)].filter(
|
||||
(name) => !customLower.has(name.toLowerCase()),
|
||||
);
|
||||
|
||||
if (serverItems.length > 0) {
|
||||
nextGroups.push({
|
||||
group: t('page.itemDetail.serverSuggestions', 'Server'),
|
||||
items: serverItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nextGroups;
|
||||
}, [
|
||||
artistQuery.data?.items,
|
||||
canSearch,
|
||||
customValues,
|
||||
debouncedSearch,
|
||||
genreQuery.data?.items,
|
||||
serverTagName,
|
||||
source,
|
||||
t,
|
||||
tagListQuery.data?.tags,
|
||||
]);
|
||||
|
||||
return { groups, isLoading };
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { PROPERTIES } from 'taglib-wasm';
|
||||
|
||||
/** 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',
|
||||
];
|
||||
|
||||
export interface KnownTag {
|
||||
key: string;
|
||||
tagName: string;
|
||||
type: TagFieldType;
|
||||
}
|
||||
|
||||
/** Which form control to render for a tag row. */
|
||||
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
|
||||
|
||||
/**
|
||||
* Per-key type overrides and extras. Keys absent from PROPERTIES are appended as
|
||||
* extra entries.
|
||||
*/
|
||||
const TAG_CONFIG: Record<string, { type?: TagFieldType }> = {
|
||||
acoustidFingerprint: { type: 'textarea' },
|
||||
// extras not in PROPERTIES (common MusicBrainz Picard tags)
|
||||
ARTISTS: {},
|
||||
artistSort: {},
|
||||
bpm: { type: 'number' },
|
||||
catalogNumber: {},
|
||||
comment: { type: 'textarea' },
|
||||
composerSort: {},
|
||||
discNumber: {},
|
||||
lyrics: { type: 'textarea' },
|
||||
musicbrainzArtistId: {},
|
||||
musicbrainzReleaseArtistId: {},
|
||||
musicbrainzReleaseGroupId: {},
|
||||
musicbrainzReleaseId: {},
|
||||
musicbrainzReleaseTrackId: {},
|
||||
musicbrainzTrackId: {},
|
||||
musicbrainzWorkId: {},
|
||||
originalAlbum: {},
|
||||
originalArtist: {},
|
||||
originalDate: {},
|
||||
ORIGINALYEAR: { type: 'number' },
|
||||
RELEASECOUNTRY: { type: 'string' },
|
||||
RELEASESTATUS: { type: 'string' },
|
||||
RELEASETYPE: { type: 'string' },
|
||||
remixedBy: {},
|
||||
titleSort: {},
|
||||
totalDiscs: {},
|
||||
totalTracks: {},
|
||||
trackNumber: {},
|
||||
};
|
||||
|
||||
/** Field definitions derived from taglib-wasm's PROPERTIES plus any extras in TAG_CONFIG. */
|
||||
export const KNOWN_TAGS: KnownTag[] = [
|
||||
...Object.entries(PROPERTIES).map(([key, prop]) => {
|
||||
const cfg = TAG_CONFIG[key];
|
||||
return {
|
||||
key,
|
||||
tagName: prop.key,
|
||||
type: cfg?.type ?? (prop.type as TagFieldType),
|
||||
};
|
||||
}),
|
||||
...Object.entries(TAG_CONFIG)
|
||||
.filter(([key]) => !(key in PROPERTIES))
|
||||
.map(([key, cfg]) => ({
|
||||
key,
|
||||
tagName: key,
|
||||
type: cfg.type ?? ('string' as TagFieldType),
|
||||
})),
|
||||
];
|
||||
|
||||
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
|
||||
|
||||
/**
|
||||
* Resolves a raw tag name to its TagLib property key. Known tags return their
|
||||
* canonical key; unknown names are uppercased to match TagLib's wire format.
|
||||
*/
|
||||
export const resolveTagKey = (input: string): string => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
const exact = KNOWN_TAGS.find((tag) => tag.tagName === trimmed || tag.key === trimmed);
|
||||
if (exact) return exact.key;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
const insensitive = KNOWN_TAGS.find(
|
||||
(tag) => tag.tagName.toLowerCase() === lower || tag.key.toLowerCase() === lower,
|
||||
);
|
||||
if (insensitive) return insensitive.key;
|
||||
|
||||
return trimmed.toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BatchFileError } from '/@/shared/types/tag-editor';
|
||||
|
||||
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 bytesToBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
@@ -175,9 +175,22 @@ const VisualizerSettingsContextModal = (props: any) => (
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const LazySongEditContextModal = lazy(() =>
|
||||
import('/@/renderer/features/tag-editor/components/song-edit-context-modal').then((module) => ({
|
||||
default: module.SongEditContextModal,
|
||||
})),
|
||||
);
|
||||
|
||||
const SongEditContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<LazySongEditContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const appRouterModals = {
|
||||
addToPlaylist: AddToPlaylistContextModal,
|
||||
base: BaseContextModal,
|
||||
editMetadata: SongEditContextModal,
|
||||
lyricsSettings: LyricsSettingsContextModal,
|
||||
saveAndReplace: SaveAndReplaceContextModal,
|
||||
settings: SettingsContextModal,
|
||||
|
||||
@@ -748,6 +748,35 @@ const AutoDJSettingsSchema = z.object({
|
||||
timing: z.number(),
|
||||
});
|
||||
|
||||
const TagAutocompleteSourceSchema = z.string();
|
||||
|
||||
const TagConfigSchema = z.object({
|
||||
autocompleteSource: TagAutocompleteSourceSchema,
|
||||
customValues: z.array(z.string()),
|
||||
multiValue: z.boolean(),
|
||||
});
|
||||
|
||||
const TagEditorSettingsSchema = z.object({
|
||||
tagConfigs: z.record(z.string(), TagConfigSchema),
|
||||
triggerRescan: z.boolean(),
|
||||
});
|
||||
|
||||
export type TagAutocompleteSource = string;
|
||||
export type TagConfig = z.infer<typeof TagConfigSchema>;
|
||||
|
||||
export const SERVER_TAG_AUTOCOMPLETE_PREFIX = 'tag:';
|
||||
|
||||
export const isServerTagAutocompleteSource = (source: string): boolean =>
|
||||
source.startsWith(SERVER_TAG_AUTOCOMPLETE_PREFIX);
|
||||
|
||||
export const getServerTagAutocompleteName = (source: string): null | string =>
|
||||
isServerTagAutocompleteSource(source)
|
||||
? source.slice(SERVER_TAG_AUTOCOMPLETE_PREFIX.length)
|
||||
: null;
|
||||
|
||||
export const toServerTagAutocompleteSource = (tagName: string): string =>
|
||||
`${SERVER_TAG_AUTOCOMPLETE_PREFIX}${tagName}`;
|
||||
|
||||
/**
|
||||
* This schema is used for validation of the imported settings json
|
||||
*/
|
||||
@@ -771,6 +800,7 @@ export const ValidationSettingsStateSchema = z.object({
|
||||
z.literal('window'),
|
||||
z.string(),
|
||||
]),
|
||||
tagEditor: TagEditorSettingsSchema,
|
||||
visualizer: VisualizerSettingsSchema,
|
||||
window: WindowSettingsSchema,
|
||||
});
|
||||
@@ -1997,6 +2027,56 @@ const initialState: SettingsState = {
|
||||
username: 'feishin',
|
||||
},
|
||||
tab: 'general',
|
||||
tagEditor: {
|
||||
tagConfigs: {
|
||||
albumArtist: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: false,
|
||||
},
|
||||
ALBUMARTISTS: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
albumArtistSort: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: false,
|
||||
},
|
||||
ALBUMARTISTSSORT: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
artist: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
ARTISTS: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
artistSort: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
ARTISTSSORT: {
|
||||
autocompleteSource: 'serverArtists',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
genre: {
|
||||
autocompleteSource: 'serverGenres',
|
||||
customValues: [],
|
||||
multiValue: true,
|
||||
},
|
||||
},
|
||||
triggerRescan: true,
|
||||
},
|
||||
visualizer: {
|
||||
audiomotionanalyzer: {
|
||||
alphaBars: false,
|
||||
@@ -2692,6 +2772,8 @@ export const useCssSettings = () => useSettingsStore((state) => state.css, shall
|
||||
export const useQueryBuilderSettings = () =>
|
||||
useSettingsStore((state) => state.queryBuilder, shallow);
|
||||
|
||||
export const useTagEditorSettings = () => useSettingsStore((state) => state.tagEditor, shallow);
|
||||
|
||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||
|
||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||
|
||||
Reference in New Issue
Block a user