add settings configuration for integrations

This commit is contained in:
jeffvli
2026-02-06 22:19:42 -08:00
parent 8ae29407ec
commit a547be1577
9 changed files with 340 additions and 34 deletions
@@ -1379,10 +1379,10 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
const { t } = useTranslation();
const artistReleaseTypeItems = useArtistReleaseTypeItems();
const musicBrainzExcludeReleaseTypes = useSettingsStore(
(state) => state.general.musicBrainzExcludeReleaseTypes,
(state) => state.integrations.musicBrainzExcludeReleaseTypes,
);
const musicBrainzPrioritizeCountries = useSettingsStore(
(state) => state.general.musicBrainzPrioritizeCountries,
(state) => state.integrations.musicBrainzPrioritizeCountries,
);
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
@@ -1407,12 +1407,14 @@ const ArtistAlbums = ({ albumsQuery }: ArtistAlbumsProps) => {
}),
);
const musicBrainzEnabled = useSettingsStore((state) => state.integrations.musicBrainz);
const musicbrainzArtistQuery = useQuery({
...musicbrainzQueries.artist({
excludeReleaseTypes: musicBrainzExcludeReleaseTypes,
mbzArtistId: detailQuery.data?.mbz as string,
prioritizeCountries: musicBrainzPrioritizeCountries,
}),
enabled: Boolean(musicBrainzEnabled && detailQuery.data?.mbz),
meta: {
albumArtist: detailQuery.data,
albums: albumsQuery.data?.items || [],
@@ -18,6 +18,7 @@ import {
getImageUrl,
normalizeReleaseToAlbum,
} from '/@/renderer/features/musicbrainz/utils';
import { logFn } from '/@/renderer/utils/logger';
import {
Album,
AlbumArtist,
@@ -283,6 +284,12 @@ const RELEASE_INCLUDES: Array<
| 'release-groups'
> = ['artist-credits', 'artists', 'media', 'recording-level-rels', 'recordings', 'release-groups'];
const EMPTY_BROWSE_RELEASES: IBrowseReleasesResult = {
'release-count': 0,
'release-offset': 0,
releases: [],
};
export const musicbrainzQueries = {
artist: (args: {
excludeReleaseTypes?: string[];
@@ -297,13 +304,30 @@ export const musicbrainzQueries = {
return queryOptions({
gcTime: CACHE_TIME,
queryFn: async ({ meta }) => {
const artist = await musicbrainzApi.lookup('artist', args.mbzArtistId);
const releases = await fetchMbzReleasesByArtistId(args.mbzArtistId);
try {
const artist = await musicbrainzApi.lookup('artist', args.mbzArtistId);
const releases = await fetchMbzReleasesByArtistId(args.mbzArtistId);
return {
data: { artist, releases },
meta: meta as MusicBrainzArtistSelectMeta,
};
logFn.debug('MusicBrainz artist lookup API queried', {
meta: { artistId: args.mbzArtistId, releases },
});
return {
data: { artist, releases },
meta: meta as MusicBrainzArtistSelectMeta,
};
} catch (error) {
logFn.warn('MusicBrainz artist lookup failed', {
meta: { artistId: args.mbzArtistId, error },
});
return {
data: {
artist: {} as IArtist,
releases: EMPTY_BROWSE_RELEASES,
},
meta: meta as MusicBrainzArtistSelectMeta,
};
}
},
queryKey: queryKeys.musicbrainz.artist(undefined, args.mbzArtistId, config),
select: artistSelect,
@@ -314,14 +338,26 @@ export const musicbrainzQueries = {
queryOptions({
gcTime: CACHE_TIME,
queryFn: async () => {
const mbzRelease = await musicbrainzApi.lookup(
'release',
args.releaseId,
RELEASE_INCLUDES,
);
const release = normalizeReleaseToAlbum(mbzRelease);
const works = collectWorksFromRelease(mbzRelease);
return { release, works };
try {
const mbzRelease = await musicbrainzApi.lookup(
'release',
args.releaseId,
RELEASE_INCLUDES,
);
const release = normalizeReleaseToAlbum(mbzRelease);
const works = collectWorksFromRelease(mbzRelease);
logFn.debug('MusicBrainz release lookup API queried', {
meta: { release, releaseId: args.releaseId },
});
return { release, works };
} catch (error) {
logFn.warn('MusicBrainz release lookup failed', {
meta: { error, releaseId: args.releaseId },
});
return { release: null, works: [] };
}
},
queryKey: queryKeys.musicbrainz.release(args.releaseId),
staleTime: CACHE_TIME,
@@ -331,6 +367,52 @@ export const musicbrainzQueries = {
export const MUSICBRAINZ_ID_PREFIX = 'musicbrainz-';
export async function fetchMbzReleaseAsAlbum(releaseId: string): Promise<Album> {
const mbzRelease = await musicbrainzApi.lookup('release', releaseId, RELEASE_INCLUDES);
return normalizeReleaseToAlbum(mbzRelease);
try {
const mbzRelease = await musicbrainzApi.lookup('release', releaseId, RELEASE_INCLUDES);
return normalizeReleaseToAlbum(mbzRelease);
} catch (error) {
logFn.warn('MusicBrainz release fetch failed', { meta: { error, releaseId } });
return createEmptyMbzAlbum(releaseId);
}
}
function createEmptyMbzAlbum(releaseId: string): Album {
return {
_itemType: LibraryItem.ALBUM,
_serverId: 'musicbrainz',
_serverType: ServerType.EXTERNAL,
albumArtistName: '',
albumArtists: [],
artists: [],
comment: null,
createdAt: '',
duration: null,
explicitStatus: null,
genres: [],
id: `musicbrainz-${releaseId}`,
imageId: null,
imageUrl: null,
isCompilation: null,
lastPlayedAt: null,
mbzId: releaseId,
mbzReleaseGroupId: null,
name: '',
originalDate: null,
originalYear: null,
participants: {},
playCount: null,
recordLabels: [],
releaseDate: null,
releaseType: null,
releaseTypes: [],
releaseYear: null,
size: null,
songCount: null,
sortName: '',
tags: {},
updatedAt: '',
userFavorite: false,
userRating: null,
version: null,
};
}
@@ -1,5 +1,7 @@
import { queryOptions } from '@tanstack/react-query';
import { logFn } from '/@/renderer/utils/logger';
async function searchYoutube(query: string): Promise<Array<{ type: string; videoId?: string }>> {
if (typeof window !== 'undefined' && window.api?.youtube) {
return window.api.youtube.search(query);
@@ -11,7 +13,11 @@ export const youtubeQueries = {
search: (args: { query: string }) => {
return queryOptions({
gcTime: 1000 * 60 * 1,
queryFn: () => searchYoutube(args.query),
queryFn: async () => {
const results = await searchYoutube(args.query);
logFn.debug('Youtube API queried', { meta: { query: args.query, results } });
return results;
},
queryKey: ['youtube', 'search', args.query],
staleTime: 1000 * 60 * 1,
});
@@ -3,7 +3,7 @@ import { useMemo, useRef } from 'react';
import { api } from '/@/renderer/api';
import { youtubeQueries } from '/@/renderer/features/musicbrainz/api/youtube-api';
import { TranscodingConfig } from '/@/renderer/store';
import { TranscodingConfig, useSettingsStore } from '/@/renderer/store';
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
const YOUTUBE_WATCH_BASE = 'https://www.youtube.com/watch?v=';
@@ -16,12 +16,13 @@ export function useSongUrl(
const prior = useRef(['', '']);
const isExternal = song?._serverType === ServerType.EXTERNAL;
const youtubeEnabled = useSettingsStore((state) => state.integrations.youtube);
const searchQuery =
song && isExternal ? `${song.artistName ?? ''} ${song.name ?? ''}`.trim() : '';
song && isExternal ? buildYoutubeSearchQuery(song.name, song.artistName) : '';
const youtubeSearch = useQuery({
...youtubeQueries.search({ query: searchQuery }),
enabled: Boolean(song && isExternal && searchQuery),
enabled: Boolean(song && isExternal && searchQuery && youtubeEnabled),
});
const externalUrl = useMemo(() => {
@@ -77,6 +78,16 @@ export function useSongUrl(
]);
}
function buildYoutubeSearchQuery(
title: string | undefined,
artistName: string | undefined,
): string {
const t = (title ?? '').trim();
const a = (artistName ?? '').trim();
if (t && a) return `${t} by ${a}`;
return t || a || '';
}
function getYoutubeUrlFromSearchResults(
results: Array<{ type: string; videoId?: string }> | undefined,
): string | undefined {
@@ -112,10 +123,11 @@ export async function getSongUrlAsync(
}
if (song._serverType === ServerType.EXTERNAL) {
if (typeof window === 'undefined' || !window.api?.youtube) {
const youtubeEnabled = useSettingsStore.getState().integrations?.youtube ?? true;
if (!youtubeEnabled || typeof window === 'undefined' || !window.api?.youtube) {
return undefined;
}
const searchQuery = `${song.artistName ?? ''} ${song.name ?? ''}`.trim();
const searchQuery = buildYoutubeSearchQuery(song.name, song.artistName);
if (!searchQuery) {
return undefined;
}
@@ -0,0 +1,138 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import {
useGeneralSettings,
useIntegrationsSettings,
useSettingsStoreActions,
} from '/@/renderer/store';
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
const MUSICBRAINZ_RELEASE_TYPES = [
'album',
'single',
'ep',
'broadcast',
'compilation',
'live',
'remix',
'appears-on',
'audiobook',
'audio drama',
'demo',
'dj-mix',
'field recording',
'interview',
'mixtape/street',
'other',
'soundtrack',
'spokenword',
];
export const IntegrationsTab = memo(() => {
const { t } = useTranslation();
const { musicBrainz } = useGeneralSettings();
const settings = useIntegrationsSettings();
const { setSettings } = useSettingsStoreActions();
const updateIntegrations = (updates: Partial<typeof settings>) => {
setSettings({
integrations: {
...settings,
...updates,
},
});
};
const options: SettingOption[] = [
{
control: (
<Switch
aria-label={t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' })}
defaultChecked={settings.musicBrainz}
onChange={(e) => updateIntegrations({ musicBrainz: e.currentTarget.checked })}
/>
),
description: t('setting.musicBrainzQueries', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' }),
},
{
control: (
<MultiSelect
aria-label={t('setting.musicbrainzExcludeReleaseTypes', {
postProcess: 'sentenceCase',
})}
clearable
data={MUSICBRAINZ_RELEASE_TYPES}
defaultValue={settings.musicBrainzExcludeReleaseTypes}
onChange={(value) =>
updateIntegrations({ musicBrainzExcludeReleaseTypes: value })
}
width={300}
/>
),
description: t('setting.musicbrainzExcludeReleaseTypes', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !musicBrainz || !settings.musicBrainz,
title: t('setting.musicbrainzExcludeReleaseTypes', { postProcess: 'sentenceCase' }),
},
{
control: (
<TextInput
defaultValue={settings.musicBrainzPrioritizeCountries.join(', ')}
key={settings.musicBrainzPrioritizeCountries.join(',')}
onBlur={(e) => {
const value = e.currentTarget.value
.split(/[,;\s]+/)
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
updateIntegrations({ musicBrainzPrioritizeCountries: value });
}}
placeholder="e.g. US, GB, DE"
width={300}
/>
),
description: t('setting.musicbrainzPrioritizeCountries', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !musicBrainz || !settings.musicBrainz,
title: t('setting.musicbrainzPrioritizeCountries', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
aria-label={t('setting.youtube', { postProcess: 'sentenceCase' })}
defaultChecked={settings.youtube}
onChange={(e) => updateIntegrations({ youtube: e.currentTarget.checked })}
/>
),
description: t('setting.youtube', {
context: 'description',
postProcess: 'sentenceCase',
}),
title: t('setting.youtube', { postProcess: 'sentenceCase' }),
},
];
return (
<Stack gap="md">
<SettingsSection
options={options}
title={t('page.setting.integrationsTab', { postProcess: 'sentenceCase' })}
/>
</Stack>
);
});
@@ -24,6 +24,14 @@ const HotkeysTab = lazy(() =>
})),
);
const IntegrationsTab = lazy(() =>
import('/@/renderer/features/settings/components/integrations/integrations-tab').then(
(module) => ({
default: module.IntegrationsTab,
}),
),
);
const WindowTab = lazy(() =>
import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({
default: module.WindowTab,
@@ -61,6 +69,9 @@ export const SettingsContent = () => {
<Tabs.Tab value="hotkeys">
{t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
<Tabs.Tab value="integrations">
{t('page.setting.integrationsTab', { postProcess: 'sentenceCase' })}
</Tabs.Tab>
{isElectron() && (
<Tabs.Tab value="window">
{t('page.setting.windowTab', { postProcess: 'sentenceCase' })}
@@ -85,6 +96,11 @@ export const SettingsContent = () => {
<HotkeysTab />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="integrations">
<Suspense fallback={null}>
<IntegrationsTab />
</Suspense>
</Tabs.Panel>
{isElectron() && (
<Tabs.Panel value="window">
<Suspense fallback={null}>
+45 -5
View File
@@ -440,8 +440,6 @@ export const GeneralSettingsSchema = z.object({
lastFM: z.boolean(),
lastfmApiKey: z.string(),
musicBrainz: z.boolean(),
musicBrainzExcludeReleaseTypes: z.array(z.string()),
musicBrainzPrioritizeCountries: z.array(z.string()),
nativeAspectRatio: z.boolean(),
passwordStore: z.string().optional(),
pathReplace: z.string(),
@@ -615,6 +613,13 @@ const QueryBuilderSettingsSchema = z.object({
tag: z.array(QueryBuilderCustomFieldSchema),
});
const IntegrationsSettingsSchema = z.object({
musicBrainz: z.boolean(),
musicBrainzExcludeReleaseTypes: z.array(z.string()),
musicBrainzPrioritizeCountries: z.array(z.string()),
youtube: z.boolean(),
});
const AutoDJSettingsSchema = z.object({
enabled: z.boolean(),
itemCount: z.number(),
@@ -631,6 +636,7 @@ export const ValidationSettingsStateSchema = z.object({
font: FontSettingsSchema,
general: GeneralSettingsSchema,
hotkeys: HotkeysSettingsSchema,
integrations: IntegrationsSettingsSchema,
lists: z.record(z.nativeEnum(ItemListKey), ItemListConfigSchema),
lyrics: LyricsSettingsSchema,
lyricsDisplay: z.record(z.string(), LyricsDisplaySettingsSchema),
@@ -640,6 +646,7 @@ export const ValidationSettingsStateSchema = z.object({
tab: z.union([
z.literal('general'),
z.literal('hotkeys'),
z.literal('integrations'),
z.literal('playback'),
z.literal('window'),
z.string(),
@@ -1015,8 +1022,6 @@ const initialState: SettingsState = {
lastFM: true,
lastfmApiKey: '',
musicBrainz: true,
musicBrainzExcludeReleaseTypes: [],
musicBrainzPrioritizeCountries: [],
nativeAspectRatio: false,
passwordStore: undefined,
pathReplace: '',
@@ -1099,6 +1104,12 @@ const initialState: SettingsState = {
},
globalMediaHotkeys: true,
},
integrations: {
musicBrainz: true,
musicBrainzExcludeReleaseTypes: [],
musicBrainzPrioritizeCountries: [],
youtube: true,
},
lists: {
['albumDetail']: {
display: ListDisplayType.TABLE,
@@ -2093,10 +2104,36 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
}
}
if (version <= 24) {
// Move MusicBrainz release/country options to Integrations; keep musicBrainz in general
const general = state.general as Record<string, unknown>;
state.integrations = {
musicBrainz: initialState.integrations.musicBrainz,
musicBrainzExcludeReleaseTypes:
(general?.musicBrainzExcludeReleaseTypes as string[]) ??
initialState.integrations.musicBrainzExcludeReleaseTypes,
musicBrainzPrioritizeCountries:
(general?.musicBrainzPrioritizeCountries as string[]) ??
initialState.integrations.musicBrainzPrioritizeCountries,
youtube: initialState.integrations.youtube,
};
delete general.musicBrainzExcludeReleaseTypes;
delete general.musicBrainzPrioritizeCountries;
}
if (version <= 25) {
// Add integrations.musicBrainz to enable/disable MusicBrainz API queries
state.integrations = {
...state.integrations,
musicBrainz:
(state.integrations as { musicBrainz?: boolean })?.musicBrainz ?? true,
};
}
return persistedState;
},
name: 'store_settings',
version: 24,
version: 26,
},
),
);
@@ -2252,6 +2289,9 @@ export const useAlbumBackground = () =>
shallow,
);
export const useIntegrationsSettings = () =>
useSettingsStore((state) => state.integrations, shallow);
export const useExternalLinks = () =>
useSettingsStore(
(state) => ({
+6 -5
View File
@@ -34,17 +34,18 @@ export const idbStateStorage: StateStorage = {
const settingsKeys = [
'store_settings_autoDJ',
'store_settings_general',
'store_settings_lists',
'store_settings_hotkeys',
'store_settings_playback',
'store_settings_integrations',
'store_settings_lists',
'store_settings_lyrics',
'store_settings_playback',
'store_settings_queryBuilder',
'store_settings_remote',
'store_settings_tab',
'store_settings_window',
'store_settings_discord',
'store_settings_font',
'store_settings_css',
'store_settings_remote',
'store_settings_queryBuilder',
'store_settings_tab',
];
export const splitSettingsStorage: StateStorage = {