Compare commits

..

25 Commits

Author SHA1 Message Date
jeffvli bd328657d8 make textarea styles consistent with other inputs 2026-07-16 22:08:50 -07:00
jeffvli b0e66fcf45 allow undo changes and deleted rows 2026-07-16 22:05:52 -07:00
jeffvli aa5309c54f persist rescan toggle 2026-07-16 21:26:35 -07:00
jeffvli b11ad3e069 split add field input to separate component 2026-07-16 21:24:06 -07:00
jeffvli 2777a33f9a remove success toasts on save / rescan 2026-07-16 21:18:13 -07:00
jeffvli 9ff705a483 remove custom tag label size 2026-07-16 21:10:38 -07:00
jeffvli 82d64489ce wrap Autocomplete component 2026-07-16 21:10:38 -07:00
jeffvli 48ff8ed6ff support parsing and saving multi-valued tags 2026-07-16 21:10:38 -07:00
jeffvli 38ad3e5d5a add TagsInput component 2026-07-16 21:10:22 -07:00
jeffvli 45c660dcc5 support parsing multivalued tags 2026-07-16 18:53:05 -07:00
jeffvli 4b15bcb7d7 bump taglib-wasm, support embedded lyrics property 2026-07-16 18:52:39 -07:00
ChrisScott9456 107727c65d Use resolveSongPath in use-metadata-editor 2026-07-16 11:53:43 -04:00
ChrisScott9456 030ecca898 more lint fixes 2026-07-12 14:04:53 -04:00
ChrisScott9456 dd0b645ee4 lockfile fix 2026-07-12 13:34:02 -04:00
Chris Scott 5ed4397f8f Merge branch 'development' into development 2026-07-12 13:31:36 -04:00
Kendall Garner 17dba85baf fix(pitch): % -> x 2026-07-12 08:38:52 -07:00
jeffvli 149c7c40b2 fix missing pnpm deps in Dockerfile 2026-07-11 20:27:00 -07:00
ChrisScott9456 c594545658 Improve tag editor add-field UX and fix custom tag writes
- Fix custom tag keys being silently dropped by TagLib — unknown keys are now uppercased before write since TagLib requires all-caps Vorbis field names
- Add real-time validation for invalid characters (= and non-ASCII) in custom tag keys
- Show 'Field already exists' error on Enter/+ attempt rather than silently doing nothing
- Resolve typed tag labels to canonical keys (e.g. 'Album Sort' → albumSort) to prevent duplicate entries under a different key
- Atomic duplicate check in hook functional updater prevents race conditions from rapid adds
- Scroll to and focus newly added field after it is inserted
- Consolidate FIELD_LABEL_OVERRIDES, FIELD_TYPE_OVERRIDES, and PICARD_EXTRAS into a single TAG_CONFIG object; extract resolveTagKey helper
2026-07-11 17:28:49 -04:00
ChrisScott9456 cde2b85148 Style edited fields in metadata editor 2026-06-28 20:56:46 -04:00
ChrisScott9456 8917a5d076 Add global context modal for song edit and move album fetch to it 2026-06-28 20:46:15 -04:00
ChrisScott9456 56f05ddafc Replace startScan (full library refresh) with refreshItem (individual item refresh) for jellyfin metadata editing 2026-06-28 20:19:12 -04:00
ChrisScott9456 aa12585b20 lint fixes 2026-06-15 10:48:18 -04:00
ChrisScott9456 f3966e3211 update lockfile 2026-06-15 10:30:58 -04:00
ChrisScott9456 651fe46519 exclude taglib-wasm@1.3.0 from pnpm age check 2026-06-15 10:26:14 -04:00
ChrisScott9456 7e82ab146e 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
2026-06-14 22:59:59 -04:00
47 changed files with 3066 additions and 659 deletions
+3
View File
@@ -5,6 +5,9 @@ WORKDIR /app
# Copy package.json first to cache node_modules
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .
# Match packageManager in package.json (pnpm is not bundled in the Node image)
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
RUN pnpm install
# Copy code and build with cached modules
+2
View File
@@ -120,6 +120,7 @@
"md5": "^2.3.0",
"motion": "^12.40.0",
"mpris-service": "^2.1.2",
"music-metadata": "^11.12.3",
"nanoid": "^3.3.12",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.16.0",
@@ -139,6 +140,7 @@
"react-window-v2": "npm:react-window@^2.2.7",
"semver": "^7.8.2",
"string-to-color": "^2.2.2",
"taglib-wasm": "^1.5.0",
"wavesurfer.js": "^7.12.7",
"ws": "^8.21.0",
"zod": "^3.25.76",
+755 -647
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -4,6 +4,8 @@ allowBuilds:
electron-winstaller: true
esbuild: true
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- 'taglib-wasm@1.3.0'
overrides:
'xml2js': '0.5.0'
'react-router': '7.14.0'
+22 -2
View File
@@ -161,6 +161,7 @@
"trackGain": "Track gain",
"trackPeak": "Track peak",
"translation": "Translation",
"undo": "Undo",
"unknown": "Unknown",
"version": "Version",
"year": "Year",
@@ -541,7 +542,8 @@
"goTo": "Go to",
"goToAlbum": "Go to $t(entity.album, {\"count\": 1})",
"goToAlbumArtist": "Go to $t(entity.albumArtist, {\"count\": 1})",
"showDetails": "Get info"
"showDetails": "Get info",
"editMetadata": "Edit Metadata"
},
"fullscreenPlayer": {
"config": {
@@ -602,9 +604,27 @@
"title": "$t(common.home)"
},
"itemDetail": {
"multiValueFields": "Multi-value fields",
"multiValueFieldsDescription": "Fields in this list use a tag input and are saved as separate metadata values. Parsed multivalue fields use it automatically. Lyrics are always handled separately.",
"addMultiValueField": "Add multivalue field…",
"addFavoriteValue": "Add favorite value…",
"addFavoriteValueOption": "Add \"{{value}}\" as a favorite…",
"copyPath": "Copy path to clipboard",
"copiedPath": "Path copied successfully",
"openFile": "Show track in file manager"
"openFile": "Show track in file manager",
"fileNotWritable": "File is not accessible or not writable",
"triggerRescan": "Trigger library rescan after saving",
"addField": "Add field…",
"emptyFields": "Fields cannot be empty",
"tagsTab": "Tags",
"artworkTab": "Artwork",
"removeArtwork": "Remove Artwork",
"noArtwork": "No Artwork",
"multipleValues": "(Multiple Values)",
"multipleArtworks": "Multiple Artworks",
"noLocalSongs": "No songs with local file paths found",
"readPartialFailure": "Could not read metadata from {{count}} of {{total}} file(s)",
"writePartialFailure": "Failed to save {{count}} file(s)"
},
"playlist": {
"reorder": "Reordering only enabled when sorting by ID"
+1
View File
@@ -4,4 +4,5 @@ import './player';
import './remote';
import './settings';
import './discord-rpc';
import './tag-editor';
import './visualizer';
+114
View File
@@ -0,0 +1,114 @@
import type {
ArtworkOp,
ReadSongMetadataBatchResult,
TagValue,
WriteSongTagsBatchResult,
} from '/@/shared/types/tag-editor';
import { ipcMain, type IpcMainInvokeEvent } from 'electron';
import { readFilesMetadataBatch, readLocalImageFile, writeFilesTags } from './taglib-service';
const sendBatchProgress = (event: IpcMainInvokeEvent, processed: number, total: number) => {
event.sender.send('batch-progress', { processed, total });
};
let activeReadController: AbortController | null = null;
ipcMain.handle('cancel-read-song-metadata', () => {
activeReadController?.abort();
activeReadController = null;
});
ipcMain.handle(
'read-song-metadata-batch',
async (event, filePaths: string[]): Promise<ReadSongMetadataBatchResult> => {
activeReadController?.abort();
activeReadController = new AbortController();
const { signal } = activeReadController;
try {
const result = await readFilesMetadataBatch(
filePaths,
(processed, total) => sendBatchProgress(event, processed, total),
signal,
);
if (signal.aborted) {
return { artworkKind: 'none', success: false };
}
if (!result.success) {
return {
artworkKind: 'none',
error: result.failedFiles[0]?.error ?? 'No readable audio files in selection',
failedFiles: result.failedFiles,
readCount: result.readCount,
success: false,
totalCount: result.totalCount,
};
}
return {
artworkKind: result.artworkKind,
failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined,
multiValueKeys: result.multiValueKeys,
readCount: result.readCount,
success: true,
tagSummary: result.tagSummary,
totalCount: result.totalCount,
...(result.artworkData
? {
artworkData: result.artworkData,
artworkMimeType: result.artworkMimeType,
}
: {}),
};
} catch (err) {
return { artworkKind: 'none', error: String(err), success: false };
} finally {
activeReadController = null;
}
},
);
ipcMain.handle(
'write-song-tags-batch',
async (
event,
filePaths: string[],
edits: Record<string, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
): Promise<WriteSongTagsBatchResult> => {
try {
const result = await writeFilesTags(
filePaths,
edits,
removed,
artworkOp,
(processed, total) => sendBatchProgress(event, processed, total),
);
if (!result.success) {
return {
error: result.failedFiles[0]?.error,
failedFiles: result.failedFiles,
success: false,
};
}
return { success: true };
} catch (err) {
return { error: String(err), success: false };
}
},
);
ipcMain.handle('read-local-image', async (_event, filePath: string) => {
try {
const { data, mimeType } = await readLocalImageFile(filePath);
return { data, mimeType, success: true };
} catch (err) {
return { error: String(err), success: false };
}
});
@@ -0,0 +1,298 @@
import type { ArtworkKind, ArtworkOp, BatchFileError, TagValue } from '/@/shared/types/tag-editor';
import { constants, promises as fsPromises } from 'fs';
import { PROPERTIES, TagLib } from 'taglib-wasm';
import { getImageMimeTypeFromPath } from '/@/shared/utils/image-mime';
let _taglib: null | TagLib = null;
const getTagLib = async (): Promise<TagLib> => {
if (!_taglib) _taglib = await TagLib.initialize();
return _taglib;
};
const BATCH_CONCURRENCY = 8;
/** Returns an error entry for each path that is missing or not writable by the current process. */
export async function checkPathsWritable(paths: string[]): Promise<BatchFileError[]> {
const failed: BatchFileError[] = [];
await Promise.all(
paths.map(async (filePath) => {
try {
await fsPromises.access(filePath, constants.F_OK | constants.W_OK);
} catch (err) {
failed.push({
error: err instanceof Error ? err.message : String(err),
path: filePath,
});
}
}),
);
return failed;
}
/** Reads an image file and returns it as base64 + MIME type for IPC transport to the renderer. */
export async function readLocalImageFile(filePath: string) {
const buf = await fsPromises.readFile(filePath);
return {
data: buf.toString('base64'),
mimeType: getImageMimeTypeFromPath(filePath),
};
}
/** Normalizes a taglib-wasm PropertyMap while preserving true multivalue arrays. */
function normalizeProperties(
props: Record<string, string[] | undefined>,
): Record<string, TagValue> {
const normalized: Record<string, TagValue> = {};
for (const [key, values] of Object.entries(props)) {
if (values && values.length > 0 && values[0] !== '') {
normalized[key] = values.length === 1 ? values[0] : [...values];
}
}
const coveredByUpperCase = new Set(
Object.keys(normalized)
.filter((k) => k !== k.toUpperCase())
.map((k) => k.toUpperCase()),
);
for (const key of Object.keys(normalized)) {
if (key === key.toUpperCase() && coveredByUpperCase.has(key)) {
delete normalized[key];
}
}
return normalized;
}
const tagValuesEqual = (a: TagValue, b: TagValue | undefined): boolean => {
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((value, index) => value === b[index])
);
}
return a === b;
};
/** Runs `fn` over `items` with at most `concurrency` tasks in flight at once. Stops early if `signal` is aborted. */
const mapWithConcurrency = async <T>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<void>,
signal?: AbortSignal,
): Promise<void> => {
let nextIndex = 0;
const worker = async () => {
while (nextIndex < items.length) {
if (signal?.aborted) return;
const index = nextIndex;
nextIndex += 1;
await fn(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
};
/**
* Reads all tags and artwork from a batch of audio files via taglib-wasm.
* Returns every tag present on disk not limited to any known-field list.
* Values that differ across files are merged to `null` and shown as "(Multiple Values)" in the editor.
* Artwork bytes are captured from the first successful file; subsequent files only contribute their size for comparison.
*/
export async function readFilesMetadataBatch(
filePaths: string[],
onProgress?: (processed: number, total: number) => void,
signal?: AbortSignal,
): Promise<{
artworkData?: string;
artworkKind: ArtworkKind;
artworkMimeType?: string;
failedFiles: BatchFileError[];
multiValueKeys: string[];
readCount: number;
success: boolean;
tagSummary: Record<string, null | TagValue>;
totalCount: number;
}> {
const totalCount = filePaths.length;
const failedFiles: BatchFileError[] = [];
const multiValueKeys = new Set<string>();
const tagSummary: Record<string, null | TagValue> = {};
let artworkKind = 'none' as ArtworkKind;
let artworkByteSize: number | undefined;
let artworkData: string | undefined;
let artworkMimeType: string | undefined;
let readCount = 0;
let processed = 0;
const taglib = await getTagLib();
await mapWithConcurrency(
filePaths,
BATCH_CONCURRENCY,
async (filePath) => {
try {
const file = await taglib.open(filePath);
try {
const rawProperties = file.properties();
const embeddedLyrics = file.getLyrics();
if (embeddedLyrics.length > 0) {
rawProperties.lyrics = [
embeddedLyrics.map(({ text }) => text).join('\n\n'),
];
}
const normalized = normalizeProperties(rawProperties);
for (const [key, value] of Object.entries(normalized)) {
if (Array.isArray(value)) multiValueKeys.add(key);
}
const pictures = file.getPictures();
const frontCover = pictures.find((p) => p.type === 'FrontCover') ?? pictures[0];
const hasCoverArt = frontCover !== undefined;
const picSize = hasCoverArt ? frontCover.data.length : undefined;
if (readCount === 0) {
Object.assign(tagSummary, normalized);
artworkKind = hasCoverArt ? 'common' : 'none';
artworkByteSize = picSize;
if (frontCover) {
artworkData = Buffer.from(frontCover.data).toString('base64');
artworkMimeType = frontCover.mimeType;
}
} else {
for (const k of Object.keys(tagSummary)) {
if (
tagSummary[k] !== null &&
!tagValuesEqual(tagSummary[k], normalized[k])
)
tagSummary[k] = null;
}
for (const k of Object.keys(normalized)) {
if (!(k in tagSummary)) tagSummary[k] = null;
}
if (artworkKind !== 'mixed') {
if (
hasCoverArt !== (artworkKind === 'common') ||
picSize !== artworkByteSize
) {
artworkKind = 'mixed';
}
}
}
readCount += 1;
} finally {
file.dispose();
}
} catch (err) {
failedFiles.push({
error: err instanceof Error ? err.message : String(err),
path: filePath,
});
}
processed += 1;
onProgress?.(processed, totalCount);
},
signal,
);
return {
artworkKind,
failedFiles,
multiValueKeys: [...multiValueKeys],
readCount,
success: readCount > 0,
tagSummary,
totalCount,
...(artworkData ? { artworkData, artworkMimeType } : {}),
};
}
/**
* Writes tag edits and/or artwork to a batch of audio files in-place via taglib-wasm (WASI).
* Only the fields present in `edits`/`removed` are touched all other existing tags are preserved.
* Array values are written as true multivalue properties; scalar values remain single-valued.
* Lyrics use TagLib's dedicated unsynchronized-lyrics API.
*/
export async function writeFilesTags(
filePaths: string[],
edits: Record<string, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
onProgress?: (processed: number, total: number) => void,
): Promise<{ failedFiles: BatchFileError[]; success: boolean }> {
const notWritable = await checkPathsWritable(filePaths);
const notWritableSet = new Set(notWritable.map((f) => f.path));
const writablePaths = filePaths.filter((p) => !notWritableSet.has(p));
const failedFiles: BatchFileError[] = [...notWritable];
if (writablePaths.length === 0) {
return { failedFiles, success: false };
}
const hasEdits = Object.keys(edits).length > 0 || removed.length > 0;
if (!hasEdits && !artworkOp) {
return { failedFiles, success: failedFiles.length === 0 };
}
const taglib = await getTagLib();
let processed = 0;
const total = writablePaths.length;
await mapWithConcurrency(writablePaths, BATCH_CONCURRENCY, async (path) => {
try {
await taglib.edit(path, (file) => {
const propertyEdits = Object.entries(edits).filter(([key]) => key !== 'lyrics');
const propertyRemovals = removed.filter((key) => key !== 'lyrics');
if (propertyEdits.length > 0 || propertyRemovals.length > 0) {
const properties = file.properties();
for (const [key, value] of propertyEdits) {
const propertyKey = key in PROPERTIES ? key : key.toUpperCase();
properties[propertyKey] = Array.isArray(value) ? value : [value];
}
for (const key of propertyRemovals) {
const propertyKey = key in PROPERTIES ? key : key.toUpperCase();
delete properties[propertyKey];
}
file.setProperties(properties);
}
if (removed.includes('lyrics')) {
file.setLyrics([]);
} else if (edits.lyrics !== undefined) {
file.setLyrics([
{
text: Array.isArray(edits.lyrics)
? edits.lyrics.join('\n\n')
: edits.lyrics,
},
]);
}
if (artworkOp?.type === 'clear') {
file.removePictures();
} else if (artworkOp?.type === 'set') {
file.removePictures();
file.addPicture({
data: artworkOp.bytes,
mimeType: artworkOp.mimeType,
type: 'FrontCover',
});
}
});
} catch (err) {
failedFiles.push({ error: String(err), path });
}
processed += 1;
onProgress?.(processed, total);
});
return { failedFiles, success: failedFiles.length === 0 };
}
+5
View File
@@ -24,6 +24,7 @@ import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
import { access, constants } from 'fs';
import { createRequire } from 'module';
import path, { join } from 'path';
import semver from 'semver';
@@ -39,6 +40,10 @@ import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
if (isWindows() && typeof (global as any).require !== 'function') {
(global as any).require = createRequire(__filename);
}
const ALPHA_UPDATER_CONFIG: {
bucket: string;
channel: string;
+45 -1
View File
@@ -1,4 +1,13 @@
import { ipcRenderer, webFrame } from 'electron';
import { ipcRenderer, type IpcRendererEvent, webFrame } from 'electron';
import type {
ArtworkOp,
BatchProgress,
ReadLocalImageResult,
ReadSongMetadataBatchResult,
TagValue,
WriteSongTagsBatchResult,
} from '../shared/types/tag-editor';
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
@@ -6,6 +15,35 @@ const openItem = async (path: string) => {
return ipcRenderer.invoke('open-item', path);
};
const cancelReadSongMetadata = (): void => {
ipcRenderer.invoke('cancel-read-song-metadata');
};
const readSongMetadataBatch = (filePaths: string[]): Promise<ReadSongMetadataBatchResult> => {
return ipcRenderer.invoke('read-song-metadata-batch', filePaths);
};
const writeSongTagsBatch = (
filePaths: string[],
edits: Record<string, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
): Promise<WriteSongTagsBatchResult> => {
return ipcRenderer.invoke('write-song-tags-batch', filePaths, edits, removed, artworkOp);
};
const onBatchProgress = (cb: (event: IpcRendererEvent, data: BatchProgress) => void) => {
ipcRenderer.on('batch-progress', cb);
};
const offBatchProgress = (cb: (event: IpcRendererEvent, data: BatchProgress) => void) => {
ipcRenderer.removeListener('batch-progress', cb);
};
const readLocalImage = (filePath: string): Promise<ReadLocalImageResult> => {
return ipcRenderer.invoke('read-local-image', filePath);
};
const openApplicationDirectory = async () => {
return ipcRenderer.invoke('open-application-directory');
};
@@ -117,6 +155,7 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
};
export const utils = {
cancelReadSongMetadata,
checkForUpdates,
customCssUpdatedListener,
disableAutoUpdates,
@@ -127,10 +166,14 @@ export const utils = {
isMacOS,
isWindows,
mainMessageListener,
offBatchProgress,
onBatchProgress,
openApplicationDirectory,
openCustomCssFolder,
openItem,
playerErrorListener,
readLocalImage,
readSongMetadataBatch,
rendererOpenCommandPalette,
rendererOpenManageServers,
rendererOpenReleaseNotes,
@@ -143,6 +186,7 @@ export const utils = {
setInputFocused,
startPowerSaveBlocker,
stopPowerSaveBlocker,
writeSongTagsBatch,
};
export type Utils = typeof utils;
+12
View File
@@ -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);
+12
View File
@@ -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;
+16
View File
@@ -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 { 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>
);
@@ -448,7 +448,7 @@ export const PitchControls = () => {
</Button>
)}
<Text size="xs" style={{ fontFamily: 'monospace' }} ta="center" w="60px">
{speed.toFixed(2)}% {speedToPitch(speed) > 0 && '+'}
{speed.toFixed(2)}x {speedToPitch(speed) > 0 && '+'}
{speedToPitch(speed) == 0 && '±'}
{speedToPitch(speed).toFixed(2)}st
</Text>
@@ -0,0 +1,109 @@
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 customKeyError =
trimmedInput && !KNOWN_TAG_MAP.has(trimmedInput)
? 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 resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
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;
if (
!KNOWN_TAG_MAP.has(trimmed) &&
(trimmed.includes('=') || // eslint-disable-next-line no-control-regex
/[^\x00-\x7F]/.test(trimmed))
)
return false;
const normalizedKey = resolveTagKey(trimmed);
if (existingFieldKeys.includes(normalizedKey)) {
setDuplicateAttempted(true);
return false;
}
setDuplicateAttempted(false);
setInput('');
onAddField(normalizedKey);
return true;
};
return (
<Group gap="xs">
<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,29 @@
.artwork-box {
width: 100%;
aspect-ratio: 1 / 1;
container-type: inline-size;
overflow: hidden;
cursor: pointer;
background: var(--mantine-color-dark-6);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
}
.artwork-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
height: 100%;
opacity: 0.4;
}
.placeholder-text {
font-size: 5cqw;
}
.remove-button {
color: var(--theme-colors-state-error);
}
@@ -0,0 +1,57 @@
import styles from './artwork-panel.module.css';
import { Button } from '/@/shared/components/button/button';
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
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 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,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,22 @@
.table-scroller {
max-height: 55vh;
padding-top: var(--theme-spacing-sm);
padding-bottom: var(--theme-spacing-sm);
margin-top: var(--theme-spacing-sm);
overflow-y: auto;
border-top: 1px solid var(--mantine-color-default-border);
}
.table-cell {
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
vertical-align: middle;
}
.table-header {
width: 1%;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
font-weight: 500;
vertical-align: middle;
color: var(--theme-colors-foreground-muted);
white-space: nowrap;
}
@@ -0,0 +1,177 @@
import { closeAllModals } from '@mantine/modals';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { 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 { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
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';
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 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>
</Stack>
);
}
return (
<Stack gap="xs">
<Tabs defaultValue="tags" keepMounted={false}>
<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 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}
/>
<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
favoriteValues={editor.favoriteValues[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
}
onAddFavorite={(value) =>
editor.handleAddFavoriteValue(key, value)
}
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>
</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.Panel value="settings">
<TagEditorSettings />
</Tabs.Panel>
</Tabs>
<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 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,119 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiAddLine, RiCloseLine } from 'react-icons/ri';
import { KNOWN_TAG_MAP, KNOWN_TAGS, resolveTagKey } from '../utils/known-tags';
import { useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
import { Fieldset } from '/@/shared/components/fieldset/fieldset';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
import { Text } from '/@/shared/components/text/text';
export const TagEditorSettings = () => {
const { t } = useTranslation();
const { favoriteValues, multiValueFields } = useTagEditorSettings();
const { setSettings } = useSettingsStoreActions();
const [input, setInput] = useState('');
const updateFields = (fields: string[]) => {
setSettings({ tagEditor: { multiValueFields: fields } });
};
const updateFavoriteValues = (key: string, values: string[]) => {
setSettings({
tagEditor: {
favoriteValues: { ...favoriteValues, [key]: values },
},
});
};
const addField = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
const key = resolveTagKey(trimmed);
if (key === 'lyrics' || multiValueFields.includes(key)) {
setInput('');
return;
}
updateFields([...multiValueFields, key]);
setInput('');
};
const availableFields = KNOWN_TAGS.filter(
({ key }) => key !== 'lyrics' && !multiValueFields.includes(key),
)
.map(({ key, label }) => ({ label, value: key }))
.sort((a, b) => a.label.localeCompare(b.label));
return (
<Stack gap="xs">
<Text fw={500} size="md">
{t('page.itemDetail.multiValueFields')}
</Text>
<Text isMuted size="sm">
{t('page.itemDetail.multiValueFieldsDescription')}
</Text>
<Autocomplete
data={availableFields}
onChange={setInput}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addField(input);
}
}}
onOptionSubmit={addField}
placeholder={t('page.itemDetail.addMultiValueField')}
py="md"
rightSection={
<ActionIcon onClick={() => addField(input)} variant="filled">
<RiAddLine size={16} />
</ActionIcon>
}
rightSectionPointerEvents="all"
value={input}
/>
{multiValueFields.map((key) => {
const label = KNOWN_TAG_MAP.get(key)?.label ?? key;
return (
<Fieldset key={key}>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="sm">{label}</Text>
<Text ff="monospace" isMuted size="xs">
{key}
</Text>
</div>
<ActionIcon
aria-label={t('common.remove', 'Remove')}
onClick={() =>
updateFields(
multiValueFields.filter((field) => field !== key),
)
}
variant="subtle"
>
<RiCloseLine size={16} />
</ActionIcon>
</Group>
<TagsInput
aria-label={`${t('page.itemDetail.favoriteValues')} - ${label}`}
onChange={(values) => updateFavoriteValues(key, values)}
placeholder={t('page.itemDetail.addFavoriteValue')}
splitChars={[]}
value={favoriteValues[key] ?? []}
/>
</Stack>
</Fieldset>
);
})}
</Stack>
);
};
@@ -0,0 +1,17 @@
.remove-cell {
width: 32px;
}
.remove-button {
font-size: 1.05rem;
line-height: 1;
}
.dirty-label {
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
}
.removed-row {
opacity: 0.55;
transition: opacity 0.15s ease-in-out;
}
@@ -0,0 +1,205 @@
import type { TagValue } from '/@/shared/types/tag-editor';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { KnownTag } from '../utils/known-tags';
import styles from './tag-field-row.module.css';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
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 FavoriteTagsInputProps {
disabled: boolean;
favoriteValues: string[];
mixedPlaceholder?: string;
onAddFavorite: (value: string) => void;
onChange: (value: string[]) => void;
value: string[];
}
interface TagFieldRowProps {
favoriteValues: string[];
isDirty?: boolean;
isMixed: boolean;
isMultiValue: boolean;
isRemoved: boolean;
meta: KnownTag;
mixedPlaceholder?: string;
onAddFavorite: (value: string) => void;
onChange: (value: TagValue) => void;
onRemove: () => void;
onReset: () => void;
onRevert: () => void;
tagKey: string;
value: TagValue;
}
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
const FavoriteTagsInput = ({
disabled,
favoriteValues,
mixedPlaceholder,
onAddFavorite,
onChange,
value,
}: FavoriteTagsInputProps) => {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState('');
const candidate = searchValue.trim();
const canAddFavorite =
candidate.length > 0 &&
!favoriteValues.some((favorite) => favorite.toLowerCase() === candidate.toLowerCase());
const data = useMemo(
() =>
canAddFavorite
? [
...favoriteValues,
{
label: t('page.itemDetail.addFavoriteValueOption', {
value: candidate,
}),
value: `${ADD_FAVORITE_PREFIX}${candidate}`,
},
]
: favoriteValues,
[canAddFavorite, candidate, favoriteValues, t],
);
return (
<TagsInput
clearable
data={data}
disabled={disabled}
onChange={(values) => {
const normalizedValues = values.map((item) =>
item.startsWith(ADD_FAVORITE_PREFIX)
? item.slice(ADD_FAVORITE_PREFIX.length)
: item,
);
onChange(
normalizedValues.filter(
(item, index) =>
normalizedValues.findIndex(
(other) => other.toLowerCase() === item.toLowerCase(),
) === index,
),
);
}}
onOptionSubmit={(submittedValue) => {
if (submittedValue.startsWith(ADD_FAVORITE_PREFIX))
onAddFavorite(submittedValue.slice(ADD_FAVORITE_PREFIX.length));
}}
onSearchChange={setSearchValue}
placeholder={mixedPlaceholder}
searchValue={searchValue}
size="sm"
splitChars={[]}
value={value}
/>
);
};
export const TagFieldRow = ({
favoriteValues,
isDirty,
isMixed,
isMultiValue,
isRemoved,
meta,
mixedPlaceholder,
onAddFavorite,
onChange,
onRemove,
onReset,
onRevert,
tagKey,
value,
}: TagFieldRowProps) => {
const { t } = useTranslation();
return (
<Table.Tr
className={isRemoved ? styles.removedRow : undefined}
data-field-key={tagKey}
key={tagKey}
>
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
<Table.Td>
{isMultiValue && tagKey !== 'lyrics' ? (
<FavoriteTagsInput
disabled={isRemoved}
favoriteValues={favoriteValues}
mixedPlaceholder={mixedPlaceholder}
onAddFavorite={onAddFavorite}
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"
/>
) : (
<TextInput
disabled={isRemoved}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={mixedPlaceholder}
size="sm"
value={Array.isArray(value) ? value.join('; ') : value}
/>
)}
</Table.Td>
<Table.Td className={styles.removeCell}>
<ActionIcon
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
className={styles.removeButton}
icon={isRemoved || isDirty ? 'undo' : 'x'}
iconProps={{
color: isRemoved ? 'error' : 'default',
size: 'md',
}}
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
size="sm"
tooltip={{
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
}}
variant="subtle"
/>
</Table.Td>
</Table.Tr>
);
};
@@ -0,0 +1,433 @@
import type {
ArtworkKind,
ArtworkOp,
BatchProgress,
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, 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';
/**
* 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 { favoriteValues, multiValueFields, triggerRescan } = useTagEditorSettings();
const { setSettings } = useSettingsStoreActions();
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 [isSaving, setIsSaving] = useState(false);
const [tagSummary, setTagSummary] = useState<Record<string, null | TagValue>>({});
const [editedFields, setEditedFields] = useState<Record<string, TagValue>>({});
const [parsedMultiValueKeys, setParsedMultiValueKeys] = useState<Set<string>>(new Set());
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);
const setRescan = useCallback(
(value: boolean) => setSettings({ tagEditor: { triggerRescan: value } }),
[setSettings],
);
/**
* 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 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, multiValueKeys]);
/**
* 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) => resolveSongPath(s.path)).filter(Boolean) as string[];
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(batchResult.tagSummary);
setParsedMultiValueKeys(new Set(batchResult.multiValueKeys ?? []));
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: TagValue) => {
setEditedFields((prev) => ({ ...prev, [key]: value }));
}, []);
const handleAddFavoriteValue = useCallback(
(key: string, value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
const currentValues = favoriteValues[key] ?? [];
if (currentValues.some((favorite) => favorite.toLowerCase() === trimmed.toLowerCase()))
return;
setSettings({
tagEditor: {
favoriteValues: {
...favoriteValues,
[key]: [...currentValues, trimmed],
},
},
});
},
[favoriteValues, setSettings],
);
/** 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, 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 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)?.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) => resolveSongPath(s.path)).filter(Boolean) as string[];
try {
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
utils.writeSongTagsBatch(
paths,
activeEdits,
[...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;
}
if (artworkOp) {
await browser?.clearCache();
}
if (triggerRescan && server) {
try {
await controller.refreshItems({
apiClientProps: { serverId: server.id },
query: { ids: resolvedSongs.map((s) => s.id) },
});
} catch {
// non-fatal
}
}
closeAllModals();
} finally {
setLoadProgress(null);
setIsSaving(false);
}
}, [
artworkOp,
browser,
editedFields,
removedKeys,
resolvedSongs,
server,
t,
triggerRescan,
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,
editedFields,
error,
favoriteValues,
getFieldMeta,
handleAddFavoriteValue,
handleAddField,
handleChangeArtwork,
handleFieldChange,
handleRemoveArtwork,
handleRemoveField,
handleResetField,
handleRevertField,
handleSave,
isLoading,
isSaving,
loadProgress,
mixedKeys,
mixedPlaceholder,
multiValueKeys,
readWarning,
removedKeys,
rescan: triggerRescan,
setRescan,
showRemoveArtworkButton,
sortedFieldEntries,
};
};
@@ -0,0 +1,100 @@
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;
label: string;
type: TagFieldType;
}
/** Which form control to render for a tag row. */
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
/**
* Per-key overrides and extras. Keys present in taglib-wasm PROPERTIES get their
* label/type overridden; keys absent from PROPERTIES are appended as extra entries.
*/
const TAG_CONFIG: Record<string, { label?: string; type?: TagFieldType }> = {
acoustidFingerprint: { type: 'textarea' },
acoustidId: { label: 'AcoustID' },
albumArtist: { label: 'Album Artist' },
albumArtistSort: { label: 'Album Artist Sort' },
albumSort: { label: 'Album Sort' },
// extras not in PROPERTIES (common MusicBrainz Picard tags)
ARTISTS: { label: 'Artists', type: 'string' },
artistSort: { label: 'Artist Sort' },
bpm: { type: 'number' },
catalogNumber: { label: 'Catalog Number' },
comment: { type: 'textarea' },
composerSort: { label: 'Composer Sort' },
discNumber: { label: 'Disc Number' },
lyrics: { type: 'textarea' },
musicbrainzArtistId: { label: 'MusicBrainz Artist ID' },
musicbrainzReleaseArtistId: { label: 'MusicBrainz Album Artist ID' },
musicbrainzReleaseGroupId: { label: 'MusicBrainz Release Group ID' },
musicbrainzReleaseId: { label: 'MusicBrainz Album ID' },
musicbrainzReleaseTrackId: { label: 'MusicBrainz Release Track ID' },
musicbrainzTrackId: { label: 'MusicBrainz Track ID' },
musicbrainzWorkId: { label: 'MusicBrainz Work ID' },
originalAlbum: { label: 'Original Album' },
originalArtist: { label: 'Original Artist' },
originalDate: { label: 'Original Date' },
ORIGINALYEAR: { label: 'Original Year', type: 'number' },
RELEASECOUNTRY: { label: 'Release Country', type: 'string' },
RELEASESTATUS: { label: 'Release Status', type: 'string' },
RELEASETYPE: { label: 'Release Type', type: 'string' },
remixedBy: { label: 'Remixer' },
titleSort: { label: 'Title Sort' },
totalDiscs: { label: 'Total Discs' },
totalTracks: { label: 'Total Tracks' },
trackNumber: { label: 'Track Number' },
};
const humanizeKey = (key: string): string =>
key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (c) => c.toUpperCase())
.trim();
/** 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,
label: cfg?.label ?? humanizeKey(key),
type: cfg?.type ?? (prop.type as TagFieldType),
};
}),
...Object.entries(TAG_CONFIG)
.filter(([key]) => !(key in PROPERTIES))
.map(([key, cfg]) => ({
key,
label: cfg.label ?? humanizeKey(key),
type: cfg.type ?? ('string' as TagFieldType),
})),
];
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
/**
* Resolves a user-typed string to a canonical tag key.
* Matches by label first (e.g. "Album Sort" "albumSort"), then falls back to
* the raw input. Unknown keys are uppercased so TagLib writes them as valid
* Vorbis comment field names.
*/
export const resolveTagKey = (input: string): string => {
const byLabel = KNOWN_TAGS.find((t) => t.label.toLowerCase() === input.toLowerCase());
const key = byLabel?.key ?? input;
return KNOWN_TAG_MAP.has(key) ? key : key.toUpperCase();
};
@@ -0,0 +1,17 @@
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 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}`;
};
+13
View File
@@ -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,
+14
View File
@@ -744,6 +744,12 @@ const AutoDJSettingsSchema = z.object({
timing: z.number(),
});
const TagEditorSettingsSchema = z.object({
favoriteValues: z.record(z.string(), z.array(z.string())),
multiValueFields: z.array(z.string()),
triggerRescan: z.boolean(),
});
/**
* This schema is used for validation of the imported settings json
*/
@@ -767,6 +773,7 @@ export const ValidationSettingsStateSchema = z.object({
z.literal('window'),
z.string(),
]),
tagEditor: TagEditorSettingsSchema,
visualizer: VisualizerSettingsSchema,
window: WindowSettingsSchema,
});
@@ -1988,6 +1995,11 @@ const initialState: SettingsState = {
username: 'feishin',
},
tab: 'general',
tagEditor: {
favoriteValues: {},
multiValueFields: ['artist', 'albumArtist', 'genre', 'composer', 'lyricist'],
triggerRescan: true,
},
visualizer: {
audiomotionanalyzer: {
alphaBars: false,
@@ -2677,6 +2689,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 } =>
+17
View File
@@ -347,6 +347,19 @@ const scrobbleParameters = z.object({
const scrobble = z.null();
const scanStatusBody = z.object({
count: z.number(),
folderCount: z.number(),
lastScan: z.string().optional(),
scanning: z.boolean(),
});
const startScanParameters = z.object({});
const startScan = z.object({ scanStatus: scanStatusBody });
const getScanStatusParameters = z.object({});
const getScanStatus = z.object({ scanStatus: scanStatusBody });
const search3 = z.object({
searchResult3: z
.object({
@@ -907,6 +920,7 @@ export const ssType = {
getMusicDirectory: getMusicDirectoryParameters,
getPlaylist: getPlaylistParameters,
getPlaylists: getPlaylistsParameters,
getScanStatus: getScanStatusParameters,
getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters,
@@ -923,6 +937,7 @@ export const ssType = {
setRating: setRatingParameters,
similarSongs: similarSongsParameters,
similarSongs2: similarSongs2Parameters,
startScan: startScanParameters,
structuredLyrics: structuredLyricsParameters,
topSongsList: topSongsListParameters,
updateInternetRadioStation: updateInternetRadioStationParameters,
@@ -956,6 +971,7 @@ export const ssType = {
getMusicDirectory,
getPlaylist,
getPlaylists,
getScanStatus,
getSong,
getSongsByGenre,
getStarred,
@@ -981,6 +997,7 @@ export const ssType = {
similarSongs,
similarSongs2,
song,
startScan,
structuredLyrics,
topSongsList,
updateInternetRadioStation,
@@ -0,0 +1,49 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
.input:focus-visible {
border-color: lighten(var(--theme-colors-border), 10%);
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.section {
color: var(--theme-colors-foreground-muted);
}
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
}
.option {
position: relative;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.option:hover {
background: lighten(var(--theme-colors-surface), 5%);
}
@@ -0,0 +1,46 @@
import type { AutocompleteProps as MantineAutocompleteProps } from '@mantine/core';
import { Autocomplete as MantineAutocomplete } from '@mantine/core';
import { CSSProperties, forwardRef } from 'react';
import styles from './autocomplete.module.css';
export interface AutocompleteProps extends MantineAutocompleteProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
(
{
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
}: AutocompleteProps,
ref,
) => {
return (
<MantineAutocomplete
classNames={{
dropdown: styles.dropdown,
input: styles.input,
label: styles.label,
option: styles.option,
root: styles.root,
section: styles.section,
...classNames,
}}
ref={ref}
size={size}
spellCheck={false}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
/>
);
},
);
+2
View File
@@ -125,6 +125,7 @@ import {
LuTimerOff,
LuTrash,
LuTriangleAlert,
LuUndo2,
LuUpload,
LuUser,
LuUserPen,
@@ -361,6 +362,7 @@ export const AppIcon = {
themeDark: LuMoon,
themeLight: LuSun,
track: LuMusic2,
undo: LuUndo2,
unfavorite: LuHeartCrack,
unpin: LuPinOff,
upload: LuUpload,
@@ -0,0 +1,66 @@
.root {
& [data-disabled='true'] {
opacity: 0.6;
}
}
.label {
margin-bottom: var(--theme-spacing-sm);
}
.dropdown {
padding: var(--theme-spacing-xs);
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
border: 1px solid var(--theme-colors-border);
}
.input {
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
& .pill {
background: var(--theme-colors-surface);
}
}
}
.input:focus-within {
border-color: lighten(var(--theme-colors-border), 10%);
}
.input-field {
color: inherit;
}
.option {
position: relative;
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
}
.option:hover {
background: lighten(var(--theme-colors-surface), 5%);
}
.pill {
font-size: var(--theme-font-size-sm);
background: var(--theme-colors-background);
border-radius: var(--theme-radius-sm);
}
.pills-list {
padding-right: var(--theme-spacing-lg);
}
.clear-button {
background: transparent !important;
}
@@ -0,0 +1,58 @@
import {
TagsInput as MantineTagsInput,
TagsInputProps as MantineTagsInputProps,
} from '@mantine/core';
import { CSSProperties, useMemo } from 'react';
import styles from './tags-input.module.css';
export interface TagsInputProps extends MantineTagsInputProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
const defaultClassNames = {
dropdown: styles.dropdown,
input: styles.input,
inputField: styles.inputField,
label: styles.label,
option: styles.option,
pill: styles.pill,
pillsList: styles.pillsList,
root: styles.root,
};
const defaultClearButtonProps = {
classNames: {
root: styles.clearButton,
},
variant: 'transparent' as const,
};
export const TagsInput = ({
classNames,
maxWidth,
variant = 'default',
width,
...props
}: TagsInputProps) => {
const mergedClassNames = useMemo(
() => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),
[classNames],
);
const style = useMemo(
() => (maxWidth || width ? { maxWidth, width } : undefined),
[maxWidth, width],
);
return (
<MantineTagsInput
classNames={mergedClassNames}
clearButtonProps={defaultClearButtonProps}
style={style}
variant={variant}
{...props}
/>
);
};
@@ -1,15 +1,20 @@
.root {
transition: width 0.3s ease-in-out;
&[data-disabled='true'] {
opacity: 0.6;
}
}
.input {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
width: 100%;
border: 1px solid transparent;
&[data-variant='default'] {
color: var(--theme-colors-surface-foreground);
background: var(--theme-colors-surface);
}
&[data-variant='filled'] {
color: var(--theme-colors-foreground);
background: var(--theme-colors-background);
}
}
.input:focus,
@@ -20,3 +25,7 @@
.label {
margin-bottom: var(--theme-spacing-sm);
}
.required {
color: var(--theme-colors-state-error);
}
+16 -1
View File
@@ -9,7 +9,19 @@ export interface TextareaProps extends MantineTextareaProps {
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ children, classNames, maxWidth, style, width, ...props }: TextareaProps, ref) => {
(
{
children,
classNames,
maxWidth,
size = 'sm',
style,
variant = 'default',
width,
...props
}: TextareaProps,
ref,
) => {
return (
<MantineTextarea
classNames={{
@@ -21,7 +33,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
...classNames,
}}
ref={ref}
size={size}
spellCheck={false}
style={{ maxWidth, width, ...style }}
variant={variant}
{...props}
>
{children}
+6
View File
@@ -1384,6 +1384,10 @@ export type RandomSongListQuery = {
export type RandomSongListResponse = SongListResponse;
export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
export type RefreshItemsResponse = null;
export type ScrobbleArgs = BaseEndpointArgs & {
query: ScrobbleQuery;
};
@@ -1577,6 +1581,7 @@ export type ControllerEndpoint = {
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
refreshItems: (args: RefreshItemsArgs) => Promise<RefreshItemsResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
@@ -1745,6 +1750,7 @@ export type InternalControllerEndpoint = {
args: ReplaceApiClientProps<JukeboxControlArgs>,
) => Promise<JukeboxControlResponse>;
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
refreshItems: (args: ReplaceApiClientProps<RefreshItemsArgs>) => Promise<RefreshItemsResponse>;
removeFromPlaylist: (
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
) => Promise<RemoveFromPlaylistResponse>;
+55
View File
@@ -0,0 +1,55 @@
export type ArtworkKind = 'common' | 'mixed' | 'none';
export type ArtworkOp = { bytes: Uint8Array; mimeType: string; type: 'set' } | { type: 'clear' };
export interface BatchFileError {
error: string;
path: string;
}
export interface BatchProgress {
processed: number;
total: number;
}
export interface ReadLocalImageResult extends IpcResult {
data?: string;
mimeType?: string;
}
export interface ReadSongMetadataBatchResult extends IpcResult {
artworkData?: string;
artworkKind: ArtworkKind;
artworkMimeType?: string;
failedFiles?: BatchFileError[];
multiValueKeys?: string[];
readCount?: number;
tagSummary?: Record<string, null | TagValue>;
totalCount?: number;
}
/** Subset of `window.api.utils` consumed by the metadata editor. */
export interface TagEditorUtils {
cancelReadSongMetadata: () => void;
offBatchProgress: (cb: (event: unknown, data: BatchProgress) => void) => void;
onBatchProgress: (cb: (event: unknown, data: BatchProgress) => void) => void;
readLocalImage: (filePath: string) => Promise<ReadLocalImageResult>;
readSongMetadataBatch: (filePaths: string[]) => Promise<ReadSongMetadataBatchResult>;
writeSongTagsBatch: (
filePaths: string[],
edits: Record<string, TagValue>,
removed: string[],
artworkOp?: ArtworkOp,
) => Promise<WriteSongTagsBatchResult>;
}
export type TagValue = string | string[];
export interface WriteSongTagsBatchResult extends IpcResult {
failedFiles?: BatchFileError[];
}
interface IpcResult {
error?: string;
success: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
/** Guesses image MIME type from a file path or filename extension. */
export const getImageMimeTypeFromPath = (filePath: string): string => {
if (/\.png$/i.test(filePath)) return 'image/png';
if (/\.gif$/i.test(filePath)) return 'image/gif';
if (/\.webp$/i.test(filePath)) return 'image/webp';
return 'image/jpeg';
};
+6 -1
View File
@@ -1,7 +1,8 @@
import { ActionIconSize, PillVariant } from '@mantine/core';
import { ActionIconSize, PillVariant, TagsInputVariant } from '@mantine/core';
type ExtendedActionIconSize = 'compact-md' | 'compact-sm' | 'compact-xs' | ActionIconSize;
type ExtendedPillVariant = 'outline' | PillVariant;
type ExtendedTagsInputVariant = 'default' | 'filled' | TagsInputVariant;
declare module '@mantine/core' {
export interface ActionIconProps {
@@ -11,4 +12,8 @@ declare module '@mantine/core' {
export interface PillProps {
variant?: ExtendedPillVariant;
}
export interface TagsInputProps {
variant?: ExtendedTagsInputVariant;
}
}