diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b5d6c7990..135d292a4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -583,6 +583,7 @@ "analytics": "analytics", "generalTab": "general", "hotkeysTab": "hotkeys", + "integrationsTab": "integrations", "playbackTab": "playback", "windowTab": "window", "updates": "update", @@ -893,6 +894,14 @@ "mpvExtraParameters_help": "one per line", "musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists", "musicbrainz": "show MusicBrainz links", + "musicBrainzQueries": "enable MusicBrainz integration", + "musicBrainzQueries_description": "the integration will query MusicBrainz for missing artist releases and other miscellaneous data", + "musicbrainzExcludeReleaseTypes": "exclude MusicBrainz release types", + "musicbrainzExcludeReleaseTypes_description": "release types to exclude when loading MusicBrainz artist releases (e.g. album, single, ep)", + "musicbrainzPrioritizeCountries": "prioritize MusicBrainz countries", + "musicbrainzPrioritizeCountries_description": "country codes to prioritize when ordering MusicBrainz releases, comma separated and non case-sensitive (e.g. us, gb, de)", + "youtube": "enable youtube integration", + "youtube_description": "external songs will attempt to use YouTube to resolve stream URLs for playback", "neteaseTranslation_description": "When enabled, fetches and displays translated lyrics from NetEase if available", "neteaseTranslation": "Enable NetEase translations", "notify": "enable song notifications", diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index e82f5bbf3..e037a2c01 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -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 || [], diff --git a/src/renderer/features/musicbrainz/api/musicbrainz-api.ts b/src/renderer/features/musicbrainz/api/musicbrainz-api.ts index 816424c5c..4b4d453b6 100644 --- a/src/renderer/features/musicbrainz/api/musicbrainz-api.ts +++ b/src/renderer/features/musicbrainz/api/musicbrainz-api.ts @@ -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 { - 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, + }; } diff --git a/src/renderer/features/musicbrainz/api/youtube-api.ts b/src/renderer/features/musicbrainz/api/youtube-api.ts index 2caa47776..22d0172b1 100644 --- a/src/renderer/features/musicbrainz/api/youtube-api.ts +++ b/src/renderer/features/musicbrainz/api/youtube-api.ts @@ -1,5 +1,7 @@ import { queryOptions } from '@tanstack/react-query'; +import { logFn } from '/@/renderer/utils/logger'; + async function searchYoutube(query: string): Promise> { 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, }); diff --git a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx index 7666d3a21..c341e1b81 100644 --- a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx +++ b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx @@ -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; } diff --git a/src/renderer/features/settings/components/integrations/integrations-tab.tsx b/src/renderer/features/settings/components/integrations/integrations-tab.tsx new file mode 100644 index 000000000..55dd3162e --- /dev/null +++ b/src/renderer/features/settings/components/integrations/integrations-tab.tsx @@ -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) => { + setSettings({ + integrations: { + ...settings, + ...updates, + }, + }); + }; + + const options: SettingOption[] = [ + { + control: ( + updateIntegrations({ musicBrainz: e.currentTarget.checked })} + /> + ), + description: t('setting.musicBrainzQueries', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.musicBrainzQueries', { postProcess: 'sentenceCase' }), + }, + { + control: ( + + updateIntegrations({ musicBrainzExcludeReleaseTypes: value }) + } + width={300} + /> + ), + description: t('setting.musicbrainzExcludeReleaseTypes', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !musicBrainz || !settings.musicBrainz, + title: t('setting.musicbrainzExcludeReleaseTypes', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + 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: ( + updateIntegrations({ youtube: e.currentTarget.checked })} + /> + ), + description: t('setting.youtube', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.youtube', { postProcess: 'sentenceCase' }), + }, + ]; + + return ( + + + + ); +}); diff --git a/src/renderer/features/settings/components/settings-content.tsx b/src/renderer/features/settings/components/settings-content.tsx index 1b39660aa..34d229caa 100644 --- a/src/renderer/features/settings/components/settings-content.tsx +++ b/src/renderer/features/settings/components/settings-content.tsx @@ -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 = () => { {t('page.setting.hotkeysTab', { postProcess: 'sentenceCase' })} + + {t('page.setting.integrationsTab', { postProcess: 'sentenceCase' })} + {isElectron() && ( {t('page.setting.windowTab', { postProcess: 'sentenceCase' })} @@ -85,6 +96,11 @@ export const SettingsContent = () => { + + + + + {isElectron() && ( diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index eb59cfee6..246984728 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -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()( } } + if (version <= 24) { + // Move MusicBrainz release/country options to Integrations; keep musicBrainz in general + const general = state.general as Record; + 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) => ({ diff --git a/src/renderer/store/utils.ts b/src/renderer/store/utils.ts index 40aa00426..6c30cf564 100644 --- a/src/renderer/store/utils.ts +++ b/src/renderer/store/utils.ts @@ -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 = {