diff --git a/package.json b/package.json index 893cb41ff..18d34dcbf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa8bb4848..8e7809a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/equicordplugins/richPresence/SettingsPanel.tsx b/src/equicordplugins/richPresence/SettingsPanel.tsx index 55f7a5d67..80ac6e09a 100644 --- a/src/equicordplugins/richPresence/SettingsPanel.tsx +++ b/src/equicordplugins/richPresence/SettingsPanel.tsx @@ -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 ( - - { setValue(v); (settings.store[settingsKey] as boolean) = v; onServiceChange?.(); }} /> + + { setValue(v); (settings.store[settingsKey] as boolean) = v; }} + /> ); } @@ -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 ( @@ -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 ( + <> + + + + + + + {nd_albumArtMode === "lastfm" && ( + + )} + + + + + {Number(nd_activityType ?? 2) !== 0 && ( + + )} + + + + + { setRefreshInterval(v); settings.store.nd_refreshInterval = v; }} + onValueRender={v => `${v}s`} + stickToMarkers + /> + + + ); +} + const TAB_COMPONENTS: Record = { [ServiceTab.AudioBookShelf]: AudioBookShelfSettings, [ServiceTab.Tosu]: TosuSettings, @@ -186,6 +240,7 @@ const TAB_COMPONENTS: Record = { [ServiceTab.Jellyfin]: JellyfinSettings, [ServiceTab.ListenBrainz]: ListenBrainzSettings, [ServiceTab.GensokyoRadio]: GensokyoRadioSettings, + [ServiceTab.Navidrome]: NavidromeSettings, }; const TAB_LABELS: Record = { @@ -195,6 +250,7 @@ const TAB_LABELS: Record = { [ServiceTab.Jellyfin]: "Jellyfin", [ServiceTab.ListenBrainz]: "ListenBrainz", [ServiceTab.GensokyoRadio]: "Gensokyo Radio", + [ServiceTab.Navidrome]: "Navidrome", }; const ENABLE_KEYS: Record = { @@ -204,6 +260,7 @@ const ENABLE_KEYS: Record = { [ServiceTab.Jellyfin]: "jf_enabled", [ServiceTab.ListenBrainz]: "lb_enabled", [ServiceTab.GensokyoRadio]: "gr_enabled", + [ServiceTab.Navidrome]: "nd_enabled", }; const TABS = Object.values(ServiceTab); diff --git a/src/equicordplugins/richPresence/index.tsx b/src/equicordplugins/richPresence/index.tsx index d5d90f5bb..ecda47809 100644 --- a/src/equicordplugins/richPresence/index.tsx +++ b/src/equicordplugins/richPresence/index.tsx @@ -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 = { +const services: Record = { [ServiceTab.AudioBookShelf]: abs, [ServiceTab.Tosu]: tosu, [ServiceTab.StatsFm]: statsfm, [ServiceTab.Jellyfin]: jellyfin, [ServiceTab.ListenBrainz]: listenbrainz, [ServiceTab.GensokyoRadio]: gensokyoRadio, + [ServiceTab.Navidrome]: navidrome, }; const enableKeys: Record = { @@ -38,6 +40,7 @@ const enableKeys: Record = { [ServiceTab.Jellyfin]: "jf_enabled", [ServiceTab.ListenBrainz]: "lb_enabled", [ServiceTab.GensokyoRadio]: "gr_enabled", + [ServiceTab.Navidrome]: "nd_enabled", }; const activeServices = new Set(); @@ -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, diff --git a/src/equicordplugins/richPresence/services/navidrome.ts b/src/equicordplugins/richPresence/services/navidrome.ts new file mode 100644 index 000000000..11b0401a9 --- /dev/null +++ b/src/equicordplugins/richPresence/services/navidrome.ts @@ -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(); + +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 { + 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 { + 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 { + 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; + if (resolvedCoverArtUrl) { + largeImagePromise = getAsset(appId, resolvedCoverArtUrl).catch(() => "navidrome"); + } else { + largeImagePromise = getAsset(appId, "navidrome").catch(() => "navidrome"); + } + + let smallImagePromise: Promise | 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); +} diff --git a/src/equicordplugins/richPresence/settings.ts b/src/equicordplugins/richPresence/settings.ts index fddfa926d..32723cc46 100644 --- a/src/equicordplugins/richPresence/settings.ts +++ b/src/equicordplugins/richPresence/settings.ts @@ -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"]; diff --git a/src/equicordplugins/richPresence/types/index.ts b/src/equicordplugins/richPresence/types/index.ts index 27d51173d..bfeba0172 100644 --- a/src/equicordplugins/richPresence/types/index.ts +++ b/src/equicordplugins/richPresence/types/index.ts @@ -11,6 +11,7 @@ export enum ServiceTab { Jellyfin = "jellyfin", ListenBrainz = "listenbrainz", GensokyoRadio = "gensokyoRadio", + Navidrome = "navidrome", } export const enum NameFormat { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 6175799ca..0396b4278 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -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); // iife so #__PURE__ works correctly