From e40a175e12a17bcf7216ebc737fc31dc47b4d8bd Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 17 Mar 2026 21:10:24 -0700 Subject: [PATCH] add qobuz and listenbrainz external links --- docs/ENV_SETTINGS.md | 2 + settings.js.template | 2 + src/i18n/locales/en.json | 6 + .../components/album-detail-content.tsx | 88 ++++++++- .../album-artist-detail-content.tsx | 103 ++++++++--- .../general/application-settings.tsx | 104 ----------- .../general/external-links-settings.tsx | 171 ++++++++++++++++++ .../components/general/general-tab.tsx | 2 + src/renderer/store/env-settings-overrides.ts | 2 + src/renderer/store/settings.store.ts | 6 + 10 files changed, 352 insertions(+), 134 deletions(-) create mode 100644 src/renderer/features/settings/components/general/external-links-settings.tsx diff --git a/docs/ENV_SETTINGS.md b/docs/ENV_SETTINGS.md index 4c1087593..83acd95ef 100644 --- a/docs/ENV_SETTINGS.md +++ b/docs/ENV_SETTINGS.md @@ -29,12 +29,14 @@ These variables override app settings **on first run** when no persisted setting | `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). | | `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. | | `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. | +| `general.listenBrainz` | `true` | `FS_GENERAL_LISTEN_BRAINZ` | `true` / `false` — ListenBrainz links. | | `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. | | `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. | | `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). | | `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. | | `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. | | `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). | +| `general.qobuz` | `true` | `FS_GENERAL_QOBUZ` | `true` / `false` — Qobuz links. | | `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. | | `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. | | `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. | diff --git a/settings.js.template b/settings.js.template index 50157056b..fd8b65708 100644 --- a/settings.js.template +++ b/settings.js.template @@ -24,12 +24,14 @@ window.FS_GENERAL_HOME_FEATURE_STYLE = "${FS_GENERAL_HOME_FEATURE_STYLE}"; window.FS_GENERAL_LANGUAGE = "${FS_GENERAL_LANGUAGE}"; window.FS_GENERAL_LAST_FM = "${FS_GENERAL_LAST_FM}"; window.FS_GENERAL_LASTFM_API_KEY = "${FS_GENERAL_LASTFM_API_KEY}"; +window.FS_GENERAL_LISTEN_BRAINZ = "${FS_GENERAL_LISTEN_BRAINZ}"; window.FS_GENERAL_MUSIC_BRAINZ = "${FS_GENERAL_MUSIC_BRAINZ}"; window.FS_GENERAL_NATIVE_ASPECT_RATIO = "${FS_GENERAL_NATIVE_ASPECT_RATIO}"; window.FS_GENERAL_PATH_REPLACE = "${FS_GENERAL_PATH_REPLACE}"; window.FS_GENERAL_PATH_REPLACE_WITH = "${FS_GENERAL_PATH_REPLACE_WITH}"; window.FS_GENERAL_PLAYERBAR_OPEN_DRAWER = "${FS_GENERAL_PLAYERBAR_OPEN_DRAWER}"; window.FS_GENERAL_PRIMARY_SHADE = "${FS_GENERAL_PRIMARY_SHADE}"; +window.FS_GENERAL_QOBUZ = "${FS_GENERAL_QOBUZ}"; window.FS_GENERAL_RESUME = "${FS_GENERAL_RESUME}"; window.FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR = "${FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR}"; window.FS_GENERAL_SHOW_RATINGS = "${FS_GENERAL_SHOW_RATINGS}"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0ee7b4ca9..906c77f56 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -37,7 +37,9 @@ "openApplicationDirectory": "open application directory", "openIn": { "lastfm": "Open in Last.fm", + "listenbrainz": "Open in ListenBrainz", "musicbrainz": "Open in MusicBrainz", + "qobuz": "Open in Qobuz", "spotify": "Open in Spotify" } }, @@ -898,6 +900,8 @@ "language_description": "sets the language for the application ($t(common.restartRequired))", "lastfm_description": "show links to Last.fm on artist/album pages", "lastfm": "show last.fm links", + "listenbrainz_description": "show links to ListenBrainz on artist/album pages", + "listenbrainz": "show ListenBrainz links", "lastfmApiKey_description": "the API key for {{lastfm}}. required for cover art", "lastfmApiKey": "{{lastfm}} API key", "lyricFetch_description": "fetch lyrics from various internet sources", @@ -925,6 +929,8 @@ "mpvExtraParameters_help": "one per line", "musicbrainz_description": "show links to MusicBrainz on artist/album pages, where MusicBrainz ID exists", "musicbrainz": "show MusicBrainz links", + "qobuz_description": "show links to Qobuz on artist/album pages", + "qobuz": "show Qobuz links", "spotify_description": "show links to Spotify on artist/album pages", "spotify": "show Spotify links", "nativeSpotify_description": "open in the Spotify app instead of your browser", diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 56961d860..82a82f09c 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -296,25 +296,60 @@ interface AlbumMetadataExternalLinksProps { albumName?: string; externalLinks: boolean; lastFM: boolean; + listenBrainz: boolean; mbzId?: null | string; + mbzReleaseGroupId?: null | string; musicBrainz: boolean; nativeSpotify: boolean; + qobuz: boolean; spotify: boolean; } +const getListenBrainzUrl = ( + mbzReleaseGroupId: null | string, + albumArtist?: string, + albumName?: string, +) => { + if (mbzReleaseGroupId) { + return `https://listenbrainz.org/album/${mbzReleaseGroupId}`; + } + + if (albumArtist || albumName) { + return `https://listenbrainz.org/search/?search_term=${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`; + } + + return null; +}; + +const getQobuzUrl = (albumArtist?: string, albumName?: string) => { + if (albumArtist || albumName) { + return `https://www.qobuz.com/us-en/search/albums/${encodeURIComponent([albumArtist, albumName].filter(Boolean).join(' ').trim())}`; + } + + return null; +}; + const AlbumMetadataExternalLinks = ({ albumArtist, albumName, externalLinks, lastFM, + listenBrainz, mbzId, + mbzReleaseGroupId, musicBrainz, nativeSpotify, + qobuz, spotify, }: AlbumMetadataExternalLinksProps) => { const { t } = useTranslation(); - if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null; + const listenBrainzUrl = getListenBrainzUrl(mbzReleaseGroupId || null, albumArtist, albumName); + const qobuzUrl = getQobuzUrl(albumArtist, albumName); + + if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) { + return null; + } return ( @@ -323,7 +358,7 @@ const AlbumMetadataExternalLinks = ({ postProcess: 'sentenceCase', })} - + {lastFM && ( ) : null} + {listenBrainz && listenBrainzUrl && ( + + )} + {qobuz && qobuzUrl && ( + + )} {spotify && ( { albumQueries.detail({ query: { id: albumId }, serverId: server.id }), ); - const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks(); + const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } = + useExternalLinks(); const comment = detailQuery?.data?.comment; @@ -427,9 +494,12 @@ export const AlbumDetailContent = () => { albumName={detailQuery?.data?.name} externalLinks={externalLinks} lastFM={lastFM} + listenBrainz={listenBrainz} mbzId={mbzId || undefined} + mbzReleaseGroupId={detailQuery?.data?.mbzReleaseGroupId} musicBrainz={musicBrainz} nativeSpotify={nativeSpotify} + qobuz={qobuz} spotify={spotify} /> 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 76c0740ba..1980e39d6 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -888,26 +888,54 @@ interface AlbumArtistMetadataExternalLinksProps { artistName?: string; externalLinks: boolean; lastFM: boolean; + listenBrainz: boolean; mbzId?: null | string; musicBrainz: boolean; nativeSpotify: boolean; order?: number; + qobuz: boolean; spotify: boolean; } +const getListenBrainzUrl = (mbzId: null | string, artistName?: string) => { + if (mbzId) { + return `https://listenbrainz.org/artist/${mbzId}`; + } + + if (artistName) { + return `https://listenbrainz.org/search/?search_term=${encodeURIComponent(artistName)}`; + } + + return null; +}; + +const getQobuzUrl = (artistName?: string) => { + if (artistName) { + return `https://www.qobuz.com/us-en/search/artists/${encodeURIComponent(artistName)}`; + } + + return null; +}; + const AlbumArtistMetadataExternalLinks = ({ artistName, externalLinks, lastFM, + listenBrainz, mbzId, musicBrainz, nativeSpotify, order, + qobuz, spotify, }: AlbumArtistMetadataExternalLinksProps) => { const { t } = useTranslation(); + const listenBrainzUrl = getListenBrainzUrl(mbzId || null, artistName); + const qobuzUrl = getQobuzUrl(artistName); - if (!externalLinks || (!lastFM && !musicBrainz && !spotify)) return null; + if (!externalLinks || (!lastFM && !listenBrainz && !musicBrainz && !qobuz && !spotify)) { + return null; + } return ( @@ -917,15 +945,14 @@ const AlbumArtistMetadataExternalLinks = ({ postProcess: 'sentenceCase', })} - + {lastFM && ( ) : null} + {listenBrainz && listenBrainzUrl && ( + + )} + {qobuz && qobuzUrl && ( + + )} {spotify && ( { const artistItems = useArtistItems(); const artistRadioCount = useArtistRadioCount(); - const { externalLinks, lastFM, musicBrainz, nativeSpotify, spotify } = useExternalLinks(); + const { externalLinks, lastFM, listenBrainz, musicBrainz, nativeSpotify, qobuz, spotify } = + useExternalLinks(); const { albumArtistId, artistId } = useParams() as { albumArtistId?: string; artistId?: string; @@ -1161,18 +1219,21 @@ export const AlbumArtistDetailContent = ({ genres={detailQuery.data?.genres} order={genresOrder} /> - {externalLinks && (lastFM || musicBrainz || spotify) && ( - - )} + {externalLinks && + (lastFM || listenBrainz || musicBrainz || qobuz || spotify) && ( + + )} {enabledItem.biography && ( { isHidden: settings.sideQueueType !== 'sideQueue', title: t('setting.sidePlayQueueLayout', { postProcess: 'sentenceCase' }), }, - { - control: ( - { - setSettings({ - general: { - ...settings, - externalLinks: e.currentTarget.checked, - }, - }); - }} - /> - ), - description: t('setting.externalLinks', { - context: 'description', - postProcess: 'sentenceCase', - }), - title: t('setting.externalLinks', { postProcess: 'sentenceCase' }), - }, - { - control: ( - { - setSettings({ - general: { - ...settings, - lastFM: e.currentTarget.checked, - }, - }); - }} - /> - ), - description: t('setting.lastfm', { - context: 'description', - postProcess: 'sentenceCase', - }), - isHidden: !settings.externalLinks, - title: t('setting.lastfm', { postProcess: 'sentenceCase' }), - }, - { - control: ( - { - setSettings({ - general: { - ...settings, - musicBrainz: e.currentTarget.checked, - }, - }); - }} - /> - ), - description: t('setting.musicbrainz', { - context: 'description', - postProcess: 'sentenceCase', - }), - isHidden: !settings.externalLinks, - title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }), - }, - { - control: ( - { - setSettings({ - general: { - ...settings, - spotify: e.currentTarget.checked, - }, - }); - }} - /> - ), - description: t('setting.spotify', { - context: 'description', - postProcess: 'sentenceCase', - }), - isHidden: !settings.externalLinks, - title: t('setting.spotify', { postProcess: 'sentenceCase' }), - }, - { - control: ( - { - setSettings({ - general: { - ...settings, - nativeSpotify: e.currentTarget.checked, - }, - }); - }} - /> - ), - description: t('setting.nativeSpotify', { - context: 'description', - postProcess: 'sentenceCase', - }), - isHidden: !settings.externalLinks || !settings.spotify, - title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }), - }, { control: ( { + const { t } = useTranslation(); + const settings = useGeneralSettings(); + const { setSettings } = useSettingsStoreActions(); + + const options: SettingOption[] = [ + { + control: ( + { + setSettings({ + general: { + ...settings, + externalLinks: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.externalLinks', { + context: 'description', + postProcess: 'sentenceCase', + }), + title: t('setting.externalLinks', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + general: { + ...settings, + lastFM: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.lastfm', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !settings.externalLinks, + title: t('setting.lastfm', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + general: { + ...settings, + listenBrainz: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.listenbrainz', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !settings.externalLinks, + title: t('setting.listenbrainz', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + general: { + ...settings, + musicBrainz: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.musicbrainz', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !settings.externalLinks, + title: t('setting.musicbrainz', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + general: { + ...settings, + qobuz: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.qobuz', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !settings.externalLinks, + title: t('setting.qobuz', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + general: { + ...settings, + spotify: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.spotify', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !settings.externalLinks, + title: t('setting.spotify', { postProcess: 'sentenceCase' }), + }, + { + control: ( + { + setSettings({ + general: { + ...settings, + nativeSpotify: e.currentTarget.checked, + }, + }); + }} + /> + ), + description: t('setting.nativeSpotify', { + context: 'description', + postProcess: 'sentenceCase', + }), + isHidden: !settings.externalLinks || !settings.spotify, + title: t('setting.nativeSpotify', { postProcess: 'sentenceCase' }), + }, + ]; + + return ( + + ); +}); diff --git a/src/renderer/features/settings/components/general/general-tab.tsx b/src/renderer/features/settings/components/general/general-tab.tsx index f439842b9..67d609f52 100644 --- a/src/renderer/features/settings/components/general/general-tab.tsx +++ b/src/renderer/features/settings/components/general/general-tab.tsx @@ -3,6 +3,7 @@ import { Fragment } from 'react/jsx-runtime'; import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings'; import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings'; +import { ExternalLinksSettings } from '/@/renderer/features/settings/components/general/external-links-settings'; import { LyricSettings } from '/@/renderer/features/settings/components/general/lyric-settings'; import { QueryBuilderSettings } from '/@/renderer/features/settings/components/general/query-builder-settings'; import { ScrobbleSettings } from '/@/renderer/features/settings/components/general/scrobble-settings'; @@ -22,6 +23,7 @@ export const GeneralTab = memo(() => { const baseSections = [ { component: ThemeSettings, key: 'theme' }, { component: ApplicationSettings, key: 'application' }, + { component: ExternalLinksSettings, key: 'externalLinks' }, { component: ControlSettings, key: 'control' }, { component: SidebarSettings, key: 'sidebar' }, { component: ScrobbleSettings, key: 'scrobble' }, diff --git a/src/renderer/store/env-settings-overrides.ts b/src/renderer/store/env-settings-overrides.ts index 591218707..75508b1a8 100644 --- a/src/renderer/store/env-settings-overrides.ts +++ b/src/renderer/store/env-settings-overrides.ts @@ -154,7 +154,9 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ { key: 'FS_GENERAL_PATH_REPLACE_WITH', path: ['general', 'pathReplaceWith'], type: 'string' }, { key: 'FS_GENERAL_LASTFM_API_KEY', path: ['general', 'lastfmApiKey'], type: 'string' }, { key: 'FS_GENERAL_LAST_FM', path: ['general', 'lastFM'], type: 'bool' }, + { key: 'FS_GENERAL_LISTEN_BRAINZ', path: ['general', 'listenBrainz'], type: 'bool' }, { key: 'FS_GENERAL_MUSIC_BRAINZ', path: ['general', 'musicBrainz'], type: 'bool' }, + { key: 'FS_GENERAL_QOBUZ', path: ['general', 'qobuz'], type: 'bool' }, { key: 'FS_GENERAL_SPOTIFY', path: ['general', 'spotify'], type: 'bool' }, { key: 'FS_GENERAL_SPOTIFY_NATIVE_APP', path: ['general', 'nativeSpotify'], type: 'bool' }, { key: 'FS_GENERAL_NATIVE_ASPECT_RATIO', path: ['general', 'nativeAspectRatio'], type: 'bool' }, diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index a40b20fd4..eed6d4a24 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -476,6 +476,7 @@ export const GeneralSettingsSchema = z.object({ language: z.string(), lastFM: z.boolean(), lastfmApiKey: z.string(), + listenBrainz: z.boolean(), musicBrainz: z.boolean(), nativeAspectRatio: z.boolean(), nativeSpotify: z.boolean(), @@ -488,6 +489,7 @@ export const GeneralSettingsSchema = z.object({ playerItems: z.array(SortableItemSchema(PlayerItemSchema)), playlistTarget: PlaylistTargetSchema, primaryShade: z.number().min(0).max(9), + qobuz: z.boolean(), resume: z.boolean(), showLyricsInSidebar: z.boolean(), showRatings: z.boolean(), @@ -1132,6 +1134,7 @@ const initialState: SettingsState = { language: 'en', lastFM: true, lastfmApiKey: '', + listenBrainz: true, musicBrainz: true, nativeAspectRatio: false, nativeSpotify: false, @@ -1150,6 +1153,7 @@ const initialState: SettingsState = { playerItems, playlistTarget: PlaylistTarget.TRACK, primaryShade: 6, + qobuz: true, resume: true, showLyricsInSidebar: true, showRatings: true, @@ -2565,8 +2569,10 @@ export const useExternalLinks = () => (state) => ({ externalLinks: state.general.externalLinks, lastFM: state.general.lastFM, + listenBrainz: state.general.listenBrainz, musicBrainz: state.general.musicBrainz, nativeSpotify: state.general.nativeSpotify, + qobuz: state.general.qobuz, spotify: state.general.spotify, }), shallow,