Feature: Add song and artist links to discord RPC (#1160)

* Add song and artist links to discord RPC

* use first artist name for artist link, full artist name for song link

* use first album artist for song link

* add discord rpc links setting

* simplify discord link settings

* fix setting description

* add musicbrainz links

* fix callback missing dependency

* use encodeURIComponent for lastfm links

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>

* split musicbrainz ids

* combine link settings

---------

Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
This commit is contained in:
Evelyn Gravett 2025-10-04 04:27:59 +01:00 committed by GitHub
parent f1a75d8e81
commit 1b278cb33a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 108 additions and 6 deletions

View file

@ -544,6 +544,10 @@
"discordDisplayType_description": "changes what you are listening to in your status", "discordDisplayType_description": "changes what you are listening to in your status",
"discordDisplayType_songname": "song name", "discordDisplayType_songname": "song name",
"discordDisplayType_artistname": "artist name(s)", "discordDisplayType_artistname": "artist name(s)",
"discordLinkType": "{{discord}} presence links",
"discordLinkType_description": "adds external links to {{lastfm}} or {{musicbrainz}} to the song and artist fields in {{discord}} rich presence. {{musicbrainz}} is the most accurate but requires tags and doesn't provide artist links while {{lastfm}} should always provide a link. makes no extra network requests",
"discordLinkType_none": "$t(common.none)",
"discordLinkType_mbz_lastfm": "{{musicbrainz}} with {{lastfm}} fallback",
"doubleClickBehavior": "queue all searched tracks when double clicking", "doubleClickBehavior": "queue all searched tracks when double clicking",
"doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued", "doubleClickBehavior_description": "if true, all matching tracks in a track search will be queued. otherwise, only the clicked one will be queued",
"enableRemote": "enable remote control server", "enableRemote": "enable remote control server",

View file

@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react';
import { controller } from '/@/renderer/api/controller'; import { controller } from '/@/renderer/api/controller';
import { import {
DiscordDisplayType, DiscordDisplayType,
DiscordLinkType,
getServerById, getServerById,
useAppStore, useAppStore,
useDiscordSettings, useDiscordSettings,
@ -77,6 +78,34 @@ export const useDiscordRpc = () => {
type: discordSettings.showAsListening ? 2 : 0, type: discordSettings.showAsListening ? 2 : 0,
}; };
if (
(discordSettings.linkType == DiscordLinkType.LAST_FM ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) &&
song?.artistName
) {
activity.stateUrl =
'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name);
activity.detailsUrl =
'https://www.last.fm/music/' +
encodeURIComponent(song.albumArtists[0].name) +
'/' +
encodeURIComponent(song.album || '_') +
'/' +
encodeURIComponent(song.name);
}
if (
discordSettings.linkType == DiscordLinkType.MBZ ||
discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM
) {
if (song?.mbzTrackId) {
activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId;
} else if (song?.mbzRecordingId) {
activity.detailsUrl =
'https://musicbrainz.org/recording/' + song.mbzRecordingId;
}
}
if ((current[2] as PlayerStatus) === PlayerStatus.PLAYING) { if ((current[2] as PlayerStatus) === PlayerStatus.PLAYING) {
if (start && end) { if (start && end) {
activity.startTimestamp = start; activity.startTimestamp = start;
@ -145,6 +174,7 @@ export const useDiscordRpc = () => {
generalSettings.lastfmApiKey, generalSettings.lastfmApiKey,
discordSettings.clientId, discordSettings.clientId,
discordSettings.displayType, discordSettings.displayType,
discordSettings.linkType,
lastUniqueId, lastUniqueId,
], ],
); );

View file

@ -7,6 +7,7 @@ import {
} from '/@/renderer/features/settings/components/settings-section'; } from '/@/renderer/features/settings/components/settings-section';
import { import {
DiscordDisplayType, DiscordDisplayType,
DiscordLinkType,
useDiscordSettings, useDiscordSettings,
useGeneralSettings, useGeneralSettings,
useSettingsStoreActions, useSettingsStoreActions,
@ -162,6 +163,54 @@ export const DiscordSettings = () => {
}), }),
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.discordDisplayType', { title: t('setting.discordDisplayType', {
discord: 'Discord',
musicbrainz: 'musicbrainz',
postProcess: 'sentenceCase',
}),
},
{
control: (
<Select
aria-label={t('setting.discordLinkType')}
clearable={false}
data={[
{
label: t('setting.discordLinkType_none', {
postProcess: 'sentenceCase',
}),
value: DiscordLinkType.NONE,
},
{ label: 'last.fm', value: DiscordLinkType.LAST_FM },
{ label: 'musicbrainz', value: DiscordLinkType.MBZ },
{
label: t('setting.discordLinkType_mbz_lastfm', {
lastfm: 'last.fm',
musicbrainz: 'musicbrainz',
}),
value: DiscordLinkType.MBZ_LAST_FM,
},
]}
defaultValue={settings.linkType}
onChange={(e) => {
if (!e) return;
setSettings({
discord: {
...settings,
linkType: e as DiscordLinkType,
},
});
}}
/>
),
description: t('setting.discordLinkType', {
context: 'description',
discord: 'Discord',
lastfm: 'last.fm',
musicbrainz: 'musicbrainz',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.discordLinkType', {
discord: 'Discord', discord: 'Discord',
postProcess: 'sentenceCase', postProcess: 'sentenceCase',
}), }),

View file

@ -164,6 +164,13 @@ export enum DiscordDisplayType {
SONG_NAME = 'song', SONG_NAME = 'song',
} }
export enum DiscordLinkType {
LAST_FM = 'last_fm',
MBZ = 'musicbrainz',
MBZ_LAST_FM = 'musicbrainz_last_fm',
NONE = 'none',
}
export enum GenreTarget { export enum GenreTarget {
ALBUM = 'album', ALBUM = 'album',
TRACK = 'track', TRACK = 'track',
@ -207,6 +214,7 @@ export interface SettingsState {
clientId: string; clientId: string;
displayType: DiscordDisplayType; displayType: DiscordDisplayType;
enabled: boolean; enabled: boolean;
linkType: DiscordLinkType;
showAsListening: boolean; showAsListening: boolean;
showPaused: boolean; showPaused: boolean;
showServerImage: boolean; showServerImage: boolean;
@ -364,6 +372,7 @@ const initialState: SettingsState = {
clientId: '1165957668758900787', clientId: '1165957668758900787',
displayType: DiscordDisplayType.FEISHIN, displayType: DiscordDisplayType.FEISHIN,
enabled: false, enabled: false,
linkType: DiscordLinkType.NONE,
showAsListening: false, showAsListening: false,
showPaused: true, showPaused: true,
showServerImage: false, showServerImage: false,

View file

@ -261,6 +261,8 @@ const normalizeSong = (
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null, lyrics: null,
mbzRecordingId: null,
mbzTrackId: item.ProviderIds?.MusicBrainzTrack || null,
name: item.Name, name: item.Name,
participants: getPeople(item), participants: getPeople(item),
path, path,

View file

@ -393,6 +393,12 @@ const participant = z.object({
Type: z.string().optional(), Type: z.string().optional(),
}); });
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
MusicBrainzTrack: z.string().optional(),
});
const songDetailParameters = baseParameters; const songDetailParameters = baseParameters;
const song = z.object({ const song = z.object({
@ -425,6 +431,7 @@ const song = z.object({
PlaylistItemId: z.string().optional(), PlaylistItemId: z.string().optional(),
PremiereDate: z.string().optional(), PremiereDate: z.string().optional(),
ProductionYear: z.number(), ProductionYear: z.number(),
ProviderIds: providerIds.optional(),
RunTimeTicks: z.number(), RunTimeTicks: z.number(),
ServerId: z.string(), ServerId: z.string(),
SortName: z.string(), SortName: z.string(),
@ -433,11 +440,6 @@ const song = z.object({
UserData: userData.optional(), UserData: userData.optional(),
}); });
const providerIds = z.object({
MusicBrainzAlbum: z.string().optional(),
MusicBrainzArtist: z.string().optional(),
});
const albumArtist = z.object({ const albumArtist = z.object({
AlbumCount: z.number().optional(), AlbumCount: z.number().optional(),
BackdropImageTags: z.array(z.string()), BackdropImageTags: z.array(z.string()),

View file

@ -180,6 +180,8 @@ const normalizeSong = (
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: normalizePlayDate(item), lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null, lyrics: item.lyrics ? item.lyrics : null,
mbzRecordingId: item.mbzReleaseTrackId || null,
mbzTrackId: item.mbzReleaseTrackId || null,
name: item.title, name: item.title,
// Thankfully, Windows is merciful and allows a mix of separators. So, we can use the // Thankfully, Windows is merciful and allows a mix of separators. So, we can use the
// POSIX separator here instead // POSIX separator here instead

View file

@ -214,7 +214,7 @@ const song = z.object({
mbzAlbumArtistId: z.string().optional(), mbzAlbumArtistId: z.string().optional(),
mbzAlbumId: z.string().optional(), mbzAlbumId: z.string().optional(),
mbzArtistId: z.string().optional(), mbzArtistId: z.string().optional(),
mbzTrackId: z.string().optional(), mbzReleaseTrackId: z.string().optional(),
mediumImageUrl: z.string().optional(), mediumImageUrl: z.string().optional(),
orderAlbumArtistName: z.string(), orderAlbumArtistName: z.string(),
orderAlbumName: z.string(), orderAlbumName: z.string(),

View file

@ -160,6 +160,8 @@ const normalizeSong = (
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
lastPlayedAt: null, lastPlayedAt: null,
lyrics: null, lyrics: null,
mbzRecordingId: item.musicBrainzId || null,
mbzTrackId: null,
name: item.title, name: item.title,
participants: getParticipants(item), participants: getParticipants(item),
path: item.path, path: item.path,

View file

@ -340,6 +340,8 @@ export type Song = {
itemType: LibraryItem.SONG; itemType: LibraryItem.SONG;
lastPlayedAt: null | string; lastPlayedAt: null | string;
lyrics: null | string; lyrics: null | string;
mbzRecordingId: null | string;
mbzTrackId: null | string;
name: string; name: string;
participants: null | Record<string, RelatedArtist[]>; participants: null | Record<string, RelatedArtist[]>;
path: null | string; path: null | string;