mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 04:50:12 +02:00
add settings configuration for integrations
This commit is contained in:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user