feat(RichPresence): Add Navidrome RPC plugin (#1223)

* feat(RichPresence): Add Navidrome RPC integration

* fix: Update Star123451 ID

* Implement Subsonic GetNowPlaying for album art

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(Navidrome): address PR review comments, remove Uguu.se plugin violation

* fix(Navidrome) Fix clearing activities, add standard music RPC default config

* fix(Navidrome) Patched Credential Leak in cover art fallback code, fix non-selectable settings entries

* fix(Navidrome) fix settings cache bug, implement typescript hygiene suggestion, and make fetching album art a toggle, added note that grabbing album art exposes server URL and Navidrome username to Discord.

* fix(Navidrome): Changed cachedSettingsJSON to only affect Navidrome-specific settings, updated polling interval slider to take affect during runtime, not just start up

* feat(RichPresence): overhaul Navidrome RPC with formatting system and activity types, add fast.fm album art fetching, update settings to instantly update

* fix(RichPresence) Fix last.fm cache leak, add privacy disclosure for last.fm album fetch method, remove dead settings

* fix(RichPresence): remove unused import and fix default activity type inconsistency

* fix(RichPresence): add parseUrl valdiation to serverUrl/publicUrl, fix SwitchSetting regression, strip auth param from getCoverArt, added abort signal to prevent accidental presence revival when disabled, remove "as const" rule violation for settings.use()

* fix(RichPresense): Fix forceUpdate orphan timer leaks, correct MD5 behavior for passwords using accented characters

* fix(RichPresence): remove MD5 auth for simpler hex auth, include optional space to provide a last.fm API key, implement lodash.debounce in place of setTimeout, fixed navidrome intersepting settings from other RPC included in the plugin

* fix(RichPresence): fix hex auth code to support special characters

* fix(RichPresence): fix lastFmCache invalidation code

* fix(RichPresence): remove unused lodash import from SettingsPanel.tsx

* chore(richPresence): remove violating comments in navidrome.ts

* chore(richPresence): update navidrome.ts

* fix(richPresence): change settings to use settings.plain, fix flicker found when changing non-related plugin settings, correct || vs ?? rule violations, fetch large and small image concurrently to speed up presence update

* feat(richPresence): add status display type option for navidrome rich presence

* fix(richPresence): add lastMinutesAgo cache to detect if the same song is on repeat in order to reset song progress bar

* fix(richPresence): Fix to only show music of the username given in navidrome settings

* fix(richPresence): remove stale onServiceChange import

* fix(richPresence): fix stale activity cache when same track is replayed

* fix(richPresence): omit end property when no media duration is known

* chore(richPresence): add instance-based album art fetching disclaimer for local instances

* chore(richPresence): centralize get_activity() and forceUpdate() JSON.stringify usage

* fix(richPresence): removed unused settings migration in migrartion.ts, remove empty lines in SettingsPanel, revert syncServices to use settings.store, remove local ip instances support

* fix(richPresence): revert to md5 implementation in utils.ts for secure auth, fix album fetching from navidrome instances

* chore(richPrecence): switch to ts-md5 source for a robust MD5 implementation

* richPresence(fix): simplify public instance checking

* boom

* richPresence(fix): fix md5 implementation and revert back to getCoverArt for navidrome instance album art fetching

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
Star 2026-07-09 18:08:23 -05:00 • committed by GitHub
parent 8e81bc4bfa
commit cd4ddebf23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 590 additions and 21 deletions

View file

@ -84,6 +84,7 @@
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"html-minifier-terser": "^7.2.0", "html-minifier-terser": "^7.2.0",
"md5": "^2.3.0",
"moment": "^2.22.2", "moment": "^2.22.2",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"puppeteer-core": "^25.2.1", "puppeteer-core": "^25.2.1",

55
pnpm-lock.yaml generated
View file

@ -132,6 +132,9 @@ importers:
html-minifier-terser: html-minifier-terser:
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0 version: 7.2.0
md5:
specifier: ^2.3.0
version: 2.3.0
moment: moment:
specifier: ^2.22.2 specifier: ^2.22.2
version: 2.30.1 version: 2.30.1
@ -899,6 +902,9 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
chromium-bidi@16.0.1: chromium-bidi@16.0.1:
resolution: {integrity: sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==} resolution: {integrity: sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==}
engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'} engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'}
@ -954,6 +960,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
css-functions-list@3.3.3: css-functions-list@3.3.3:
resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1535,6 +1544,9 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-callable@1.2.7: is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -1741,6 +1753,9 @@ packages:
mathml-tag-names@4.0.0: mathml-tag-names@4.0.0:
resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==}
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
mdn-data@2.0.28: mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
@ -3002,9 +3017,9 @@ snapshots:
array.prototype.flatmap@1.3.3: array.prototype.flatmap@1.3.3:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
define-properties: 1.2.1 define-properties: 1.2.1
es-abstract: 1.24.1 es-abstract: 1.24.2
es-shim-unscopables: 1.1.0 es-shim-unscopables: 1.1.0
array.prototype.tosorted@1.1.4: array.prototype.tosorted@1.1.4:
@ -3018,9 +3033,9 @@ snapshots:
arraybuffer.prototype.slice@1.0.4: arraybuffer.prototype.slice@1.0.4:
dependencies: dependencies:
array-buffer-byte-length: 1.0.2 array-buffer-byte-length: 1.0.2
call-bind: 1.0.8 call-bind: 1.0.9
define-properties: 1.2.1 define-properties: 1.2.1
es-abstract: 1.24.1 es-abstract: 1.24.2
es-errors: 1.3.0 es-errors: 1.3.0
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
@ -3109,6 +3124,8 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
charenc@0.0.2: {}
chromium-bidi@16.0.1(devtools-protocol@0.0.1638949): chromium-bidi@16.0.1(devtools-protocol@0.0.1638949):
dependencies: dependencies:
devtools-protocol: 0.0.1638949 devtools-protocol: 0.0.1638949
@ -3158,6 +3175,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypt@0.0.2: {}
css-functions-list@3.3.3: {} css-functions-list@3.3.3: {}
css-select@5.2.2: css-select@5.2.2:
@ -3409,7 +3428,7 @@ snapshots:
array-buffer-byte-length: 1.0.2 array-buffer-byte-length: 1.0.2
arraybuffer.prototype.slice: 1.0.4 arraybuffer.prototype.slice: 1.0.4
available-typed-arrays: 1.0.7 available-typed-arrays: 1.0.7
call-bind: 1.0.8 call-bind: 1.0.9
call-bound: 1.0.4 call-bound: 1.0.4
data-view-buffer: 1.0.2 data-view-buffer: 1.0.2
data-view-byte-length: 1.0.2 data-view-byte-length: 1.0.2
@ -3977,7 +3996,7 @@ snapshots:
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
call-bound: 1.0.4 call-bound: 1.0.4
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
@ -4000,6 +4019,8 @@ snapshots:
call-bound: 1.0.4 call-bound: 1.0.4
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
is-buffer@1.1.6: {}
is-callable@1.2.7: {} is-callable@1.2.7: {}
is-core-module@2.16.1: is-core-module@2.16.1:
@ -4193,6 +4214,12 @@ snapshots:
mathml-tag-names@4.0.0: {} mathml-tag-names@4.0.0: {}
md5@2.3.0:
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
mdn-data@2.0.28: {} mdn-data@2.0.28: {}
mdn-data@2.12.2: {} mdn-data@2.12.2: {}
@ -4291,9 +4318,9 @@ snapshots:
object.fromentries@2.0.8: object.fromentries@2.0.8:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
define-properties: 1.2.1 define-properties: 1.2.1
es-abstract: 1.24.1 es-abstract: 1.24.2
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
object.groupby@1.0.3: object.groupby@1.0.3:
@ -4304,7 +4331,7 @@ snapshots:
object.values@1.2.1: object.values@1.2.1:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
call-bound: 1.0.4 call-bound: 1.0.4
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
@ -4432,9 +4459,9 @@ snapshots:
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
define-properties: 1.2.1 define-properties: 1.2.1
es-abstract: 1.24.1 es-abstract: 1.24.2
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
@ -4674,7 +4701,7 @@ snapshots:
string.prototype.trimstart@1.0.8: string.prototype.trimstart@1.0.8:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
@ -4839,7 +4866,7 @@ snapshots:
typed-array-byte-length@1.0.3: typed-array-byte-length@1.0.3:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.9
for-each: 0.3.5 for-each: 0.3.5
gopd: 1.2.0 gopd: 1.2.0
has-proto: 1.2.0 has-proto: 1.2.0
@ -4848,7 +4875,7 @@ snapshots:
typed-array-byte-offset@1.0.4: typed-array-byte-offset@1.0.4:
dependencies: dependencies:
available-typed-arrays: 1.0.7 available-typed-arrays: 1.0.7
call-bind: 1.0.8 call-bind: 1.0.9
for-each: 0.3.5 for-each: 0.3.5
gopd: 1.2.0 gopd: 1.2.0
has-proto: 1.2.0 has-proto: 1.2.0

View file

@ -10,7 +10,7 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
import { Select, Slider, TabBar, TextInput, useState } from "@webpack/common"; import { Select, Slider, TabBar, TextInput, useState } from "@webpack/common";
import { onServiceChange, settings, SettingsStore } from "./settings"; import { settings, SettingsStore } from "./settings";
import { NameFormat, ServiceTab } from "./types"; import { NameFormat, ServiceTab } from "./types";
type SettingsKey = keyof SettingsStore; type SettingsKey = keyof SettingsStore;
@ -18,8 +18,11 @@ type SettingsKey = keyof SettingsStore;
function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) { function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) {
const [value, setValue] = useState(settings.store[settingsKey] ?? false); const [value, setValue] = useState(settings.store[settingsKey] ?? false);
return ( return (
<SettingsSection id={name} tag="label" name={name} description={description} inlineSetting> <SettingsSection tag="label" inlineSetting id={name} name={name} description={description}>
<Switch checked={!!value} onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; onServiceChange?.(); }} /> <Switch
checked={Boolean(value)}
onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; }}
/>
</SettingsSection> </SettingsSection>
); );
} }
@ -38,7 +41,7 @@ function TextSetting({ name, description, settingsKey, placeholder }: { name: st
); );
} }
function SelectSetting({ name, description, settingsKey, options }: { name: string; description: string; settingsKey: SettingsKey; options: { label: string; value: string; }[]; }) { function SelectSetting({ name, description, settingsKey, options }: { name: string; description: string; settingsKey: SettingsKey; options: { label: string; value: string | number; }[]; }) {
const [value, setValue] = useState(settings.store[settingsKey] ?? options[0]?.value); const [value, setValue] = useState(settings.store[settingsKey] ?? options[0]?.value);
return ( return (
<SettingsSection id={name} name={name} description={description}> <SettingsSection id={name} name={name} description={description}>
@ -179,6 +182,57 @@ function GensokyoRadioSettings() {
); );
} }
const ND_DYNAMIC_KEYS: SettingsKey[] = ["nd_activityType", "nd_albumArtMode"];
function NavidromeSettings() {
const [refreshInterval, setRefreshInterval] = useState(settings.store.nd_refreshInterval ?? 10);
const { nd_activityType, nd_albumArtMode } = settings.use(ND_DYNAMIC_KEYS);
return (
<>
<SettingsSection id="navidrome-settings" name="" description="Show what you're currently listening to via Navidrome." />
<TextSetting name="Server URL" description="Navidrome Server URL (must be a public domain, e.g. https://navidrome.example.com)." settingsKey="nd_serverUrl" placeholder="https://navidrome.example.com" />
<TextSetting name="Username" description="Navidrome username." settingsKey="nd_username" />
<TextSetting name="Password" description="Navidrome password." settingsKey="nd_password" />
<TextSetting name="Client ID" description="Optional Discord Application Client ID." settingsKey="nd_clientId" placeholder="1470554657506984069" />
<SelectSetting name="Album Art Mode" description="How to fetch album art." settingsKey="nd_albumArtMode" options={[
{ label: "None", value: "none" },
{ label: "Navidrome Instance (Exposes Server URL. One-time auth sent.)", value: "instance" },
{ label: "Last.fm API (Sends Metadata to last.fm)", value: "lastfm" },
]} />
{nd_albumArtMode === "lastfm" && (
<TextSetting name="Last.fm API Key" description="Optional: Provide your own Last.fm API Key (leaves default if empty)." settingsKey="nd_lastfmApiKey" placeholder="feff915bf5987580c9dc354d523dc6b9" />
)}
<SwitchSetting name="Show Small Image" description="Show Navidrome logo in bottom right of album art." settingsKey="nd_showSmallImage" />
<SwitchSetting name="Show Album" description="Show album name in presence." settingsKey="nd_showAlbum" />
<SelectSetting name="Activity Type" description="Which type of activity to display." settingsKey="nd_activityType" options={[
{ label: "Listening", value: 2 },
{ label: "Playing", value: 0 },
{ label: "Watching", value: 3 }
]} />
<SelectSetting name="Status Display Type" description="Show the state / details line contents in the member list" settingsKey="nd_statusDisplayType" options={[
{ label: "Don't show (shows generic listening message)", value: "off" },
{ label: "Show state line content", value: "artist" },
{ label: "Show details line content", value: "track" }
]} />
{Number(nd_activityType ?? 2) !== 0 && (
<TextSetting name="Activity Name Format" description="The 'Listening to' text (e.g. {artist})" settingsKey="nd_nameString" placeholder="Navidrome" />
)}
<TextSetting name="Details Format" description="The main line showing what song is playing (e.g. {song})" settingsKey="nd_detailsString" placeholder="{song}" />
<TextSetting name="State Format" description="The line below the song name (e.g. {artist})" settingsKey="nd_stateString" placeholder="{artist}" />
<TextSetting name="Large Text Format" description="The album line (e.g. {album})" settingsKey="nd_largeTextString" placeholder="{album}" />
<SettingsSection id="nd-refresh-interval" name="Refresh Interval" description="Refresh interval in seconds.">
<Slider
markers={[1, 2, 5, 10, 15]}
initialValue={refreshInterval}
onValueChange={v => { setRefreshInterval(v); settings.store.nd_refreshInterval = v; }}
onValueRender={v => `${v}s`}
stickToMarkers
/>
</SettingsSection>
</>
);
}
const TAB_COMPONENTS: Record<ServiceTab, React.ComponentType> = { const TAB_COMPONENTS: Record<ServiceTab, React.ComponentType> = {
[ServiceTab.AudioBookShelf]: AudioBookShelfSettings, [ServiceTab.AudioBookShelf]: AudioBookShelfSettings,
[ServiceTab.Tosu]: TosuSettings, [ServiceTab.Tosu]: TosuSettings,
@ -186,6 +240,7 @@ const TAB_COMPONENTS: Record<ServiceTab, React.ComponentType> = {
[ServiceTab.Jellyfin]: JellyfinSettings, [ServiceTab.Jellyfin]: JellyfinSettings,
[ServiceTab.ListenBrainz]: ListenBrainzSettings, [ServiceTab.ListenBrainz]: ListenBrainzSettings,
[ServiceTab.GensokyoRadio]: GensokyoRadioSettings, [ServiceTab.GensokyoRadio]: GensokyoRadioSettings,
[ServiceTab.Navidrome]: NavidromeSettings,
}; };
const TAB_LABELS: Record<ServiceTab, string> = { const TAB_LABELS: Record<ServiceTab, string> = {
@ -195,6 +250,7 @@ const TAB_LABELS: Record<ServiceTab, string> = {
[ServiceTab.Jellyfin]: "Jellyfin", [ServiceTab.Jellyfin]: "Jellyfin",
[ServiceTab.ListenBrainz]: "ListenBrainz", [ServiceTab.ListenBrainz]: "ListenBrainz",
[ServiceTab.GensokyoRadio]: "Gensokyo Radio", [ServiceTab.GensokyoRadio]: "Gensokyo Radio",
[ServiceTab.Navidrome]: "Navidrome",
}; };
const ENABLE_KEYS: Record<ServiceTab, SettingsKey> = { const ENABLE_KEYS: Record<ServiceTab, SettingsKey> = {
@ -204,6 +260,7 @@ const ENABLE_KEYS: Record<ServiceTab, SettingsKey> = {
[ServiceTab.Jellyfin]: "jf_enabled", [ServiceTab.Jellyfin]: "jf_enabled",
[ServiceTab.ListenBrainz]: "lb_enabled", [ServiceTab.ListenBrainz]: "lb_enabled",
[ServiceTab.GensokyoRadio]: "gr_enabled", [ServiceTab.GensokyoRadio]: "gr_enabled",
[ServiceTab.Navidrome]: "nd_enabled",
}; };
const TABS = Object.values(ServiceTab); const TABS = Object.values(ServiceTab);

View file

@ -13,6 +13,7 @@ import * as abs from "./services/audiobookshelf";
import * as gensokyoRadio from "./services/gensokyoRadio"; import * as gensokyoRadio from "./services/gensokyoRadio";
import * as jellyfin from "./services/jellyfin"; import * as jellyfin from "./services/jellyfin";
import * as listenbrainz from "./services/listenbrainz"; import * as listenbrainz from "./services/listenbrainz";
import * as navidrome from "./services/navidrome";
import * as statsfm from "./services/statsfm"; import * as statsfm from "./services/statsfm";
import * as tosu from "./services/tosu"; import * as tosu from "./services/tosu";
import { setOnServiceChange, settings, SettingsStore } from "./settings"; import { setOnServiceChange, settings, SettingsStore } from "./settings";
@ -22,13 +23,14 @@ type SettingsKey = keyof SettingsStore;
const logger = new Logger("RichPresence"); const logger = new Logger("RichPresence");
const services: Record<string, { start(): void; stop(): void; }> = { const services: Record<string, { start(): void; stop(): void; forceUpdate?(): void; }> = {
[ServiceTab.AudioBookShelf]: abs, [ServiceTab.AudioBookShelf]: abs,
[ServiceTab.Tosu]: tosu, [ServiceTab.Tosu]: tosu,
[ServiceTab.StatsFm]: statsfm, [ServiceTab.StatsFm]: statsfm,
[ServiceTab.Jellyfin]: jellyfin, [ServiceTab.Jellyfin]: jellyfin,
[ServiceTab.ListenBrainz]: listenbrainz, [ServiceTab.ListenBrainz]: listenbrainz,
[ServiceTab.GensokyoRadio]: gensokyoRadio, [ServiceTab.GensokyoRadio]: gensokyoRadio,
[ServiceTab.Navidrome]: navidrome,
}; };
const enableKeys: Record<string, SettingsKey> = { const enableKeys: Record<string, SettingsKey> = {
@ -38,6 +40,7 @@ const enableKeys: Record<string, SettingsKey> = {
[ServiceTab.Jellyfin]: "jf_enabled", [ServiceTab.Jellyfin]: "jf_enabled",
[ServiceTab.ListenBrainz]: "lb_enabled", [ServiceTab.ListenBrainz]: "lb_enabled",
[ServiceTab.GensokyoRadio]: "gr_enabled", [ServiceTab.GensokyoRadio]: "gr_enabled",
[ServiceTab.Navidrome]: "nd_enabled",
}; };
const activeServices = new Set<string>(); const activeServices = new Set<string>();
@ -58,6 +61,8 @@ function syncServices() {
logger.info(`Stopping ${id} service`); logger.info(`Stopping ${id} service`);
service.stop(); service.stop();
activeServices.delete(id); activeServices.delete(id);
} else if (shouldRun && isRunning && service.forceUpdate) {
service.forceUpdate();
} }
} }
} }
@ -72,7 +77,7 @@ function stopAllServices() {
export default definePlugin({ export default definePlugin({
name: "RichPresence", name: "RichPresence",
description: "Unified rich presence hub for AudioBookShelf, osu!, stats.fm, Jellyfin, ListenBrainz, and Gensokyo Radio.", description: "Unified rich presence hub for AudioBookShelf, osu!, stats.fm, Jellyfin, ListenBrainz, Navidrome, and Gensokyo Radio.",
tags: ["Activity"], tags: ["Activity"],
authors: [ authors: [
EquicordDevs.vmohammad, EquicordDevs.vmohammad,
@ -84,6 +89,7 @@ export default definePlugin({
Devs.RyanCaoDev, Devs.RyanCaoDev,
EquicordDevs.Prince527, EquicordDevs.Prince527,
EquicordDevs.creations, EquicordDevs.creations,
EquicordDevs.Star123451,
], ],
reporterTestable: ReporterTestable.None, reporterTestable: ReporterTestable.None,

View file

@ -0,0 +1,350 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { parseUrl } from "@utils/misc";
import { Activity } from "@vencord/discord-types";
import { ActivityFlags, ActivityStatusDisplayType } from "@vencord/discord-types/enums";
import { ApplicationAssetUtils, FluxDispatcher } from "@webpack/common";
import md5 from "md5";
import { settings } from "../settings";
function md5Hex(str: string): string {
return md5(str);
}
const SOCKET_ID = "RichPresence_Navidrome";
const logger = new Logger("RichPresence:Navidrome");
let updateTimer: NodeJS.Timeout | undefined;
let abortController: AbortController | undefined;
let currentTrackId: string | undefined;
let cachedStartTimestamp: number | undefined;
let lastMinutesAgo: number | undefined;
let cachedActivity: Activity | undefined;
let cachedSettingsJSON: string | undefined;
const lastFmCache = new Map<string, string | null>();
interface NdTrack {
id: string;
title?: string;
artist?: string;
album?: string;
year?: number;
suffix?: string;
bitRate?: number;
duration?: number;
minutesAgo?: number;
coverArt?: string;
username?: string;
}
function customFormat(formatStr: string | undefined, track: NdTrack): string {
if (!formatStr) return "";
return formatStr
.replaceAll("{song}", track.title ?? "")
.replaceAll("{artist}", track.artist ?? "")
.replaceAll("{album}", track.album ?? "")
.replaceAll("{year}", track.year ? `${track.year}` : "")
.replaceAll("{quality}", track.suffix ? `${track.suffix.toUpperCase()}${track.bitRate ? " " + track.bitRate + "kbps" : ""}` : "");
}
async function getAsset(applicationId: string, key: string): Promise<string> {
return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0];
}
function setActivity(activity: Activity | null) {
FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity, socketId: SOCKET_ID });
}
async function fetchNowPlaying(signal?: AbortSignal): Promise<NdTrack | null> {
const { nd_serverUrl, nd_username, nd_password } = settings.store;
if (!nd_serverUrl || !nd_username || !nd_password) {
logger.warn("Navidrome server URL, username, or password is not set.");
return null;
}
try {
const parsedUrl = parseUrl(nd_serverUrl);
if (!parsedUrl) {
logger.warn("Navidrome server URL is invalid.");
return null;
}
if (parsedUrl.protocol !== "https:") {
logger.warn("Navidrome server URL must use HTTPS.");
return null;
}
const salt = Math.random().toString(36).substring(2, 8);
const token = md5Hex((nd_password ?? "") + salt);
const baseUrl = parsedUrl.href.replace(/\/$/, "");
const queryParams = `u=${encodeURIComponent(nd_username)}&t=${token}&s=${salt}&v=1.12.0&c=equicord-rpc&f=json`;
const res = await fetch(`${baseUrl}/rest/getNowPlaying?${queryParams}`, { signal });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
if (data["subsonic-response"]?.status === "failed") {
logger.warn("Navidrome API error:", data["subsonic-response"].error?.message);
return null;
}
const entries = data["subsonic-response"]?.nowPlaying?.entry;
if (!entries || !Array.isArray(entries) || entries.length === 0) return null;
const myEntry = entries.find((e: NdTrack) => e.username?.toLowerCase() === nd_username.toLowerCase());
return myEntry ?? null;
} catch (e: unknown) {
if (e instanceof Error && e.name === "AbortError") throw e;
logger.error("Failed to fetch from Navidrome API", e);
return null;
}
}
function getSettingsJSON() {
return JSON.stringify({
nd_clientId: settings.store.nd_clientId,
nd_showSmallImage: settings.store.nd_showSmallImage,
nd_username: settings.store.nd_username,
nd_password: settings.store.nd_password,
nd_serverUrl: settings.store.nd_serverUrl,
nd_nameString: settings.store.nd_nameString,
nd_detailsString: settings.store.nd_detailsString,
nd_stateString: settings.store.nd_stateString,
nd_largeTextString: settings.store.nd_largeTextString,
nd_activityType: settings.store.nd_activityType,
nd_statusDisplayType: settings.store.nd_statusDisplayType,
nd_albumArtMode: settings.store.nd_albumArtMode,
nd_lastfmApiKey: settings.store.nd_lastfmApiKey,
nd_showAlbum: settings.store.nd_showAlbum
});
}
async function getActivity(signal?: AbortSignal): Promise<Activity | null> {
const track = await fetchNowPlaying(signal);
if (signal?.aborted) {
const e = new Error("Aborted");
e.name = "AbortError";
throw e;
}
if (!track) return null;
const currentSettingsJSON = getSettingsJSON();
if (track.id === currentTrackId && cachedActivity && cachedSettingsJSON === currentSettingsJSON) {
return cachedActivity;
}
const { nd_clientId, nd_showSmallImage, nd_serverUrl, nd_showAlbum, nd_nameString, nd_detailsString, nd_stateString, nd_largeTextString, nd_activityType, nd_statusDisplayType, nd_lastfmApiKey } = settings.store;
const _clientId = nd_clientId?.trim();
const appId = _clientId === "" ? "1470554657506984069" : (_clientId ?? "1470554657506984069");
const _serverUrl = nd_serverUrl?.trim();
const parsedExternalUrl = parseUrl(_serverUrl ?? "");
const externalBaseUrl = parsedExternalUrl ? parsedExternalUrl.href.replace(/\/$/, "") : null;
const durationMs = (track.duration ?? 0) * 1000;
const trackMinutesAgo = track.minutesAgo ?? 0;
if (track.id !== currentTrackId || !cachedStartTimestamp) {
currentTrackId = track.id;
const elapsedMs = trackMinutesAgo * 60 * 1000;
cachedStartTimestamp = Date.now() - elapsedMs;
lastMinutesAgo = trackMinutesAgo;
} else {
if (trackMinutesAgo < (lastMinutesAgo ?? 0) || trackMinutesAgo > (lastMinutesAgo ?? 0) + 1) {
const elapsedMs = trackMinutesAgo * 60 * 1000;
cachedStartTimestamp = Date.now() - elapsedMs;
}
lastMinutesAgo = trackMinutesAgo;
}
const endTimestamp = cachedStartTimestamp + durationMs;
const isPlaying = Number(nd_activityType ?? 2) === 0;
const nameString = !isPlaying ? customFormat(nd_nameString || "Navidrome", track) : "Navidrome";
const detailsString = customFormat(nd_detailsString, track);
let stateString = customFormat(nd_stateString, track);
const assets: Activity["assets"] = {};
if (nd_showAlbum && nd_largeTextString) {
const largeText = customFormat(nd_largeTextString, track);
if (largeText) {
if (Number(nd_activityType ?? 2) === 0) {
stateString = stateString ? `${stateString} • ${largeText}` : largeText;
} else {
assets.large_text = largeText;
}
}
}
const albumArtMode = settings.store.nd_albumArtMode ?? "none";
let resolvedCoverArtUrl: string | null = null;
if (albumArtMode === "instance" && track.coverArt && externalBaseUrl) {
const { nd_username, nd_password } = settings.store;
const salt = Math.random().toString(36).substring(2, 8);
const token = md5Hex((nd_password ?? "") + salt);
resolvedCoverArtUrl = `${externalBaseUrl}/rest/getCoverArt?id=${encodeURIComponent(track.coverArt)}&u=${encodeURIComponent(nd_username ?? "")}&t=${token}&s=${salt}&v=1.12.0&c=equicord-rpc`;
} else if (albumArtMode === "lastfm" && track.artist) {
const trimmedKey = nd_lastfmApiKey?.trim();
const apiKey = trimmedKey || "feff915bf5987580c9dc354d523dc6b9";
const cacheKey = `${track.id}:${apiKey}`;
if (lastFmCache.has(cacheKey)) {
resolvedCoverArtUrl = lastFmCache.get(cacheKey) ?? null;
} else {
try {
const artist = encodeURIComponent(track.artist);
let image: string | undefined;
if (track.album) {
const album = encodeURIComponent(track.album);
const res = await fetch(`https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${apiKey}&artist=${artist}&album=${album}&format=json`, { signal });
const json = await res.json();
image = json?.album?.image?.at(-1)?.["#text"];
}
if (!image && track.title) {
const title = encodeURIComponent(track.title);
const res = await fetch(`https://ws.audioscrobbler.com/2.0/?method=track.getinfo&api_key=${apiKey}&artist=${artist}&track=${title}&format=json`, { signal });
const json = await res.json();
image = json?.track?.album?.image?.at(-1)?.["#text"];
}
resolvedCoverArtUrl = image ?? null;
lastFmCache.set(cacheKey, resolvedCoverArtUrl);
} catch (e: unknown) {
if (e instanceof Error && e.name === "AbortError") throw e;
resolvedCoverArtUrl = null;
lastFmCache.set(cacheKey, null);
}
}
}
let largeImagePromise: Promise<string>;
if (resolvedCoverArtUrl) {
largeImagePromise = getAsset(appId, resolvedCoverArtUrl).catch(() => "navidrome");
} else {
largeImagePromise = getAsset(appId, "navidrome").catch(() => "navidrome");
}
let smallImagePromise: Promise<string> | undefined;
if (nd_showSmallImage) {
smallImagePromise = getAsset(appId, "navidrome").catch(() => "navidrome");
assets.small_text = "Navidrome";
}
const [largeImage, smallImage] = await Promise.all([
largeImagePromise,
smallImagePromise ?? Promise.resolve("")
]);
assets.large_image = largeImage;
if (nd_showSmallImage && smallImage) {
assets.small_image = smallImage;
}
if (signal?.aborted) {
const e = new Error("Aborted");
e.name = "AbortError";
throw e;
}
const activity: Activity = {
application_id: appId,
name: nameString || "Navidrome",
details: detailsString || undefined,
state: stateString || undefined,
status_display_type: nd_statusDisplayType ? {
"off": ActivityStatusDisplayType.NAME,
"artist": ActivityStatusDisplayType.STATE,
"track": ActivityStatusDisplayType.DETAILS
}[nd_statusDisplayType as "off" | "artist" | "track"] : undefined,
type: Number(nd_activityType ?? 2),
flags: ActivityFlags.INSTANCE,
timestamps: {
start: cachedStartTimestamp,
end: durationMs > 0 ? endTimestamp : undefined,
},
assets,
};
cachedSettingsJSON = currentSettingsJSON;
cachedActivity = activity;
return activity;
}
async function updatePresence() {
try {
const activity = await getActivity(abortController?.signal);
setActivity(activity);
if (!activity) {
currentTrackId = undefined;
cachedStartTimestamp = undefined;
lastMinutesAgo = undefined;
cachedActivity = undefined;
cachedSettingsJSON = undefined;
}
} catch (e: unknown) {
if (e instanceof Error && e.name === "AbortError") return;
logger.error("Failed to update presence", e);
setActivity(null);
currentTrackId = undefined;
cachedStartTimestamp = undefined;
lastMinutesAgo = undefined;
cachedActivity = undefined;
cachedSettingsJSON = undefined;
}
if (abortController && !abortController.signal.aborted) {
const interval = (settings.store.nd_refreshInterval as number) ?? 10;
updateTimer = setTimeout(updatePresence, interval * 1000);
}
}
export function start() {
abortController = new AbortController();
updatePresence();
}
export function forceUpdate() {
const currentSettingsJSON = getSettingsJSON();
if (cachedSettingsJSON === currentSettingsJSON) {
return;
}
if (abortController && !abortController.signal.aborted) {
abortController.abort();
clearTimeout(updateTimer);
abortController = new AbortController();
updatePresence();
}
}
export function stop() {
abortController?.abort();
abortController = undefined;
clearTimeout(updateTimer);
lastFmCache.clear();
updateTimer = undefined;
currentTrackId = undefined;
cachedStartTimestamp = undefined;
lastMinutesAgo = undefined;
cachedActivity = undefined;
cachedSettingsJSON = undefined;
setActivity(null);
}

View file

@ -70,6 +70,13 @@ export const settings = definePluginSettings({
hidden: true, hidden: true,
onChange: () => onServiceChange?.(), onChange: () => onServiceChange?.(),
}, },
nd_enabled: {
description: "Enable Navidrome presence.",
type: OptionType.BOOLEAN,
default: false,
hidden: true,
onChange: () => onServiceChange?.(),
},
// AudioBookShelf // AudioBookShelf
abs_serverUrl: { abs_serverUrl: {
@ -346,6 +353,122 @@ export const settings = definePluginSettings({
default: 15, default: 15,
hidden: true, hidden: true,
}, },
// Navidrome
nd_serverUrl: {
description: "Navidrome Server URL (e.g. https://navidrome.example.com)",
type: OptionType.STRING,
default: "",
hidden: true,
},
nd_username: {
description: "Navidrome Username",
type: OptionType.STRING,
default: "",
hidden: true,
},
nd_password: {
description: "Navidrome Password",
type: OptionType.STRING,
default: "",
hidden: true,
},
nd_clientId: {
description: "Optional Discord Application Client ID",
type: OptionType.STRING,
default: "",
hidden: true,
},
nd_showSmallImage: {
description: "Show Navidrome logo in bottom right of album art.",
type: OptionType.BOOLEAN,
default: false,
hidden: true,
},
nd_showAlbum: {
description: "Show album name in presence.",
type: OptionType.BOOLEAN,
default: true,
hidden: true,
},
nd_albumArtMode: {
description: "How to fetch album art.",
type: OptionType.SELECT,
options: [
{ label: "None", value: "none", default: true },
{ label: "Navidrome Instance (Exposes Server URL to Discord, no auth sent)", value: "instance" },
{ label: "Last.fm API (Sends track metadata to Last.fm)", value: "lastfm" },
],
hidden: true,
},
nd_lastfmApiKey: {
description: "Optional Last.fm API Key",
type: OptionType.STRING,
default: "",
hidden: true,
},
nd_refreshInterval: {
description: "Refresh interval in seconds.",
type: OptionType.SLIDER,
markers: [1, 2, 5, 10, 15],
default: 10,
hidden: true,
},
nd_activityType: {
type: OptionType.SELECT,
description: "Which type of activity",
options: [
{ label: "Listening", value: 2, default: true },
{ label: "Playing (Fixes hidden lines)", value: 0 },
{ label: "Watching", value: 3 }
],
hidden: true,
},
nd_nameString: {
type: OptionType.STRING,
description: "Activity name format string",
default: "Navidrome",
hidden: true,
},
nd_detailsString: {
type: OptionType.STRING,
description: "Activity details format string",
default: "{song}",
hidden: true,
},
nd_stateString: {
type: OptionType.STRING,
description: "Activity state format string",
default: "{artist}",
hidden: true,
},
nd_largeTextString: {
type: OptionType.STRING,
description: "Activity large text format string",
default: "{album}",
hidden: true,
},
nd_statusDisplayType: {
description: "Show the track / artist name in the member list",
type: OptionType.SELECT,
options: [
{
label: "Don't show (shows generic listening message)",
value: "off"
},
{
label: "Show artist name",
value: "artist",
default: true
},
{
label: "Show track name",
value: "track"
}
],
hidden: true,
},
}); });
export type SettingsStore = typeof settings["store"]; export type SettingsStore = typeof settings["store"];

View file

@ -11,6 +11,7 @@ export enum ServiceTab {
Jellyfin = "jellyfin", Jellyfin = "jellyfin",
ListenBrainz = "listenbrainz", ListenBrainz = "listenbrainz",
GensokyoRadio = "gensokyoRadio", GensokyoRadio = "gensokyoRadio",
Navidrome = "navidrome",
} }
export const enum NameFormat { export const enum NameFormat {

View file

@ -1378,6 +1378,10 @@ export const EquicordDevs = Object.freeze({
name: "kat", name: "kat",
id: 665120188047556609n id: 665120188047556609n
}, },
Star123451: {
name: "Star123451",
id: 1037526188039618560n
},
auggeeo: { auggeeo: {
name: "August (auggeeo)", name: "August (auggeeo)",
id: 594864203102158859n id: 594864203102158859n
@ -1385,7 +1389,7 @@ export const EquicordDevs = Object.freeze({
bastih18: { bastih18: {
name: "bastih18", name: "bastih18",
id: 273797150880563201n id: 273797150880563201n
} },
} satisfies Record<string, Dev>); } satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly // iife so #__PURE__ works correctly