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:
parent
8e81bc4bfa
commit
cd4ddebf23
8 changed files with 590 additions and 21 deletions
|
|
@ -84,6 +84,7 @@
|
|||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"highlight.js": "11.11.1",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"md5": "^2.3.0",
|
||||
"moment": "^2.22.2",
|
||||
"p-limit": "^7.3.0",
|
||||
"puppeteer-core": "^25.2.1",
|
||||
|
|
|
|||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
|
|
@ -132,6 +132,9 @@ importers:
|
|||
html-minifier-terser:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
md5:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
moment:
|
||||
specifier: ^2.22.2
|
||||
version: 2.30.1
|
||||
|
|
@ -899,6 +902,9 @@ packages:
|
|||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
charenc@0.0.2:
|
||||
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
||||
|
||||
chromium-bidi@16.0.1:
|
||||
resolution: {integrity: sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==}
|
||||
engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'}
|
||||
|
|
@ -954,6 +960,9 @@ packages:
|
|||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypt@0.0.2:
|
||||
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
||||
|
||||
css-functions-list@3.3.3:
|
||||
resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -1535,6 +1544,9 @@ packages:
|
|||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-buffer@1.1.6:
|
||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||
|
||||
is-callable@1.2.7:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -1741,6 +1753,9 @@ packages:
|
|||
mathml-tag-names@4.0.0:
|
||||
resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==}
|
||||
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
mdn-data@2.0.28:
|
||||
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
||||
|
||||
|
|
@ -3002,9 +3017,9 @@ snapshots:
|
|||
|
||||
array.prototype.flatmap@1.3.3:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.24.1
|
||||
es-abstract: 1.24.2
|
||||
es-shim-unscopables: 1.1.0
|
||||
|
||||
array.prototype.tosorted@1.1.4:
|
||||
|
|
@ -3018,9 +3033,9 @@ snapshots:
|
|||
arraybuffer.prototype.slice@1.0.4:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.24.1
|
||||
es-abstract: 1.24.2
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.5
|
||||
|
|
@ -3109,6 +3124,8 @@ snapshots:
|
|||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
charenc@0.0.2: {}
|
||||
|
||||
chromium-bidi@16.0.1(devtools-protocol@0.0.1638949):
|
||||
dependencies:
|
||||
devtools-protocol: 0.0.1638949
|
||||
|
|
@ -3158,6 +3175,8 @@ snapshots:
|
|||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypt@0.0.2: {}
|
||||
|
||||
css-functions-list@3.3.3: {}
|
||||
|
||||
css-select@5.2.2:
|
||||
|
|
@ -3409,7 +3428,7 @@ snapshots:
|
|||
array-buffer-byte-length: 1.0.2
|
||||
arraybuffer.prototype.slice: 1.0.4
|
||||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
call-bound: 1.0.4
|
||||
data-view-buffer: 1.0.2
|
||||
data-view-byte-length: 1.0.2
|
||||
|
|
@ -3977,7 +3996,7 @@ snapshots:
|
|||
|
||||
is-array-buffer@3.0.5:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
call-bound: 1.0.4
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
|
|
@ -4000,6 +4019,8 @@ snapshots:
|
|||
call-bound: 1.0.4
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
is-buffer@1.1.6: {}
|
||||
|
||||
is-callable@1.2.7: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
|
|
@ -4193,6 +4214,12 @@ snapshots:
|
|||
|
||||
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.12.2: {}
|
||||
|
|
@ -4291,9 +4318,9 @@ snapshots:
|
|||
|
||||
object.fromentries@2.0.8:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.24.1
|
||||
es-abstract: 1.24.2
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
object.groupby@1.0.3:
|
||||
|
|
@ -4304,7 +4331,7 @@ snapshots:
|
|||
|
||||
object.values@1.2.1:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
call-bound: 1.0.4
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
|
@ -4432,9 +4459,9 @@ snapshots:
|
|||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.24.1
|
||||
es-abstract: 1.24.2
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
|
@ -4674,7 +4701,7 @@ snapshots:
|
|||
|
||||
string.prototype.trimstart@1.0.8:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
|
|
@ -4839,7 +4866,7 @@ snapshots:
|
|||
|
||||
typed-array-byte-length@1.0.3:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
for-each: 0.3.5
|
||||
gopd: 1.2.0
|
||||
has-proto: 1.2.0
|
||||
|
|
@ -4848,7 +4875,7 @@ snapshots:
|
|||
typed-array-byte-offset@1.0.4:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.8
|
||||
call-bind: 1.0.9
|
||||
for-each: 0.3.5
|
||||
gopd: 1.2.0
|
||||
has-proto: 1.2.0
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Margins } from "@utils/margins";
|
|||
import { classes } from "@utils/misc";
|
||||
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";
|
||||
|
||||
type SettingsKey = keyof SettingsStore;
|
||||
|
|
@ -18,8 +18,11 @@ type SettingsKey = keyof SettingsStore;
|
|||
function SwitchSetting({ name, description, settingsKey }: { name: string; description: string; settingsKey: SettingsKey; }) {
|
||||
const [value, setValue] = useState(settings.store[settingsKey] ?? false);
|
||||
return (
|
||||
<SettingsSection id={name} tag="label" name={name} description={description} inlineSetting>
|
||||
<Switch checked={!!value} onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; onServiceChange?.(); }} />
|
||||
<SettingsSection tag="label" inlineSetting id={name} name={name} description={description}>
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onChange={v => { setValue(v); (settings.store[settingsKey] as boolean) = v; }}
|
||||
/>
|
||||
</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);
|
||||
return (
|
||||
<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> = {
|
||||
[ServiceTab.AudioBookShelf]: AudioBookShelfSettings,
|
||||
[ServiceTab.Tosu]: TosuSettings,
|
||||
|
|
@ -186,6 +240,7 @@ const TAB_COMPONENTS: Record<ServiceTab, React.ComponentType> = {
|
|||
[ServiceTab.Jellyfin]: JellyfinSettings,
|
||||
[ServiceTab.ListenBrainz]: ListenBrainzSettings,
|
||||
[ServiceTab.GensokyoRadio]: GensokyoRadioSettings,
|
||||
[ServiceTab.Navidrome]: NavidromeSettings,
|
||||
};
|
||||
|
||||
const TAB_LABELS: Record<ServiceTab, string> = {
|
||||
|
|
@ -195,6 +250,7 @@ const TAB_LABELS: Record<ServiceTab, string> = {
|
|||
[ServiceTab.Jellyfin]: "Jellyfin",
|
||||
[ServiceTab.ListenBrainz]: "ListenBrainz",
|
||||
[ServiceTab.GensokyoRadio]: "Gensokyo Radio",
|
||||
[ServiceTab.Navidrome]: "Navidrome",
|
||||
};
|
||||
|
||||
const ENABLE_KEYS: Record<ServiceTab, SettingsKey> = {
|
||||
|
|
@ -204,6 +260,7 @@ const ENABLE_KEYS: Record<ServiceTab, SettingsKey> = {
|
|||
[ServiceTab.Jellyfin]: "jf_enabled",
|
||||
[ServiceTab.ListenBrainz]: "lb_enabled",
|
||||
[ServiceTab.GensokyoRadio]: "gr_enabled",
|
||||
[ServiceTab.Navidrome]: "nd_enabled",
|
||||
};
|
||||
|
||||
const TABS = Object.values(ServiceTab);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import * as abs from "./services/audiobookshelf";
|
|||
import * as gensokyoRadio from "./services/gensokyoRadio";
|
||||
import * as jellyfin from "./services/jellyfin";
|
||||
import * as listenbrainz from "./services/listenbrainz";
|
||||
import * as navidrome from "./services/navidrome";
|
||||
import * as statsfm from "./services/statsfm";
|
||||
import * as tosu from "./services/tosu";
|
||||
import { setOnServiceChange, settings, SettingsStore } from "./settings";
|
||||
|
|
@ -22,13 +23,14 @@ type SettingsKey = keyof SettingsStore;
|
|||
|
||||
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.Tosu]: tosu,
|
||||
[ServiceTab.StatsFm]: statsfm,
|
||||
[ServiceTab.Jellyfin]: jellyfin,
|
||||
[ServiceTab.ListenBrainz]: listenbrainz,
|
||||
[ServiceTab.GensokyoRadio]: gensokyoRadio,
|
||||
[ServiceTab.Navidrome]: navidrome,
|
||||
};
|
||||
|
||||
const enableKeys: Record<string, SettingsKey> = {
|
||||
|
|
@ -38,6 +40,7 @@ const enableKeys: Record<string, SettingsKey> = {
|
|||
[ServiceTab.Jellyfin]: "jf_enabled",
|
||||
[ServiceTab.ListenBrainz]: "lb_enabled",
|
||||
[ServiceTab.GensokyoRadio]: "gr_enabled",
|
||||
[ServiceTab.Navidrome]: "nd_enabled",
|
||||
};
|
||||
|
||||
const activeServices = new Set<string>();
|
||||
|
|
@ -58,6 +61,8 @@ function syncServices() {
|
|||
logger.info(`Stopping ${id} service`);
|
||||
service.stop();
|
||||
activeServices.delete(id);
|
||||
} else if (shouldRun && isRunning && service.forceUpdate) {
|
||||
service.forceUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +77,7 @@ function stopAllServices() {
|
|||
|
||||
export default definePlugin({
|
||||
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"],
|
||||
authors: [
|
||||
EquicordDevs.vmohammad,
|
||||
|
|
@ -84,6 +89,7 @@ export default definePlugin({
|
|||
Devs.RyanCaoDev,
|
||||
EquicordDevs.Prince527,
|
||||
EquicordDevs.creations,
|
||||
EquicordDevs.Star123451,
|
||||
],
|
||||
reporterTestable: ReporterTestable.None,
|
||||
|
||||
|
|
|
|||
350
src/equicordplugins/richPresence/services/navidrome.ts
Normal file
350
src/equicordplugins/richPresence/services/navidrome.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -70,6 +70,13 @@ export const settings = definePluginSettings({
|
|||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
nd_enabled: {
|
||||
description: "Enable Navidrome presence.",
|
||||
type: OptionType.BOOLEAN,
|
||||
default: false,
|
||||
hidden: true,
|
||||
onChange: () => onServiceChange?.(),
|
||||
},
|
||||
|
||||
// AudioBookShelf
|
||||
abs_serverUrl: {
|
||||
|
|
@ -346,6 +353,122 @@ export const settings = definePluginSettings({
|
|||
default: 15,
|
||||
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"];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export enum ServiceTab {
|
|||
Jellyfin = "jellyfin",
|
||||
ListenBrainz = "listenbrainz",
|
||||
GensokyoRadio = "gensokyoRadio",
|
||||
Navidrome = "navidrome",
|
||||
}
|
||||
|
||||
export const enum NameFormat {
|
||||
|
|
|
|||
|
|
@ -1378,6 +1378,10 @@ export const EquicordDevs = Object.freeze({
|
|||
name: "kat",
|
||||
id: 665120188047556609n
|
||||
},
|
||||
Star123451: {
|
||||
name: "Star123451",
|
||||
id: 1037526188039618560n
|
||||
},
|
||||
auggeeo: {
|
||||
name: "August (auggeeo)",
|
||||
id: 594864203102158859n
|
||||
|
|
@ -1385,7 +1389,7 @@ export const EquicordDevs = Object.freeze({
|
|||
bastih18: {
|
||||
name: "bastih18",
|
||||
id: 273797150880563201n
|
||||
}
|
||||
},
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
// iife so #__PURE__ works correctly
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue