From 10cbc682db6d0375145472243049a3a8c9ea3bde Mon Sep 17 00:00:00 2001 From: vMohammad <62218284+vMohammad24@users.noreply.github.com> Date: Mon, 9 Jun 2025 05:02:00 +0300 Subject: [PATCH] audio book shelf rpc and jellyfin rpc (#283) * Add JellyfinRichPresence plugin * audio book shelf plugin * add to the readme * happy indi? * Update README.md --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com> --- README.md | 4 +- src/equicordplugins/ABSRPC/index.tsx | 250 ++++++++++++++++++ .../jellyfinRichPresence/index.tsx | 215 +++++++++++++++ 3 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 src/equicordplugins/ABSRPC/index.tsx create mode 100644 src/equicordplugins/jellyfinRichPresence/index.tsx diff --git a/README.md b/README.md index 330c7b5d..eb0fb395 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch ### Extra included plugins
-184 additional plugins +186 additional plugins ### All Platforms @@ -20,6 +20,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch - AlwaysExpandProfile by thororen - AmITyping by MrDiamond - Anammox by Kyuuhachi +- AudiobookShelfRPC by vMohammad - AtSomeone by Joona - BannersEverywhere by ImLvna & AutumnVN - BetterActivities by D3SOX, Arjix, AutumnVN @@ -95,6 +96,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch - InRole by nin0dev - InstantScreenshare by HAHALOSAH & thororen - IRememberYou by zoodogood +- JellyfinRichPresence by vMohammad - Jumpscare by Surgedevs - JumpToStart by Samwich - KeyboardSounds by HypedDomi diff --git a/src/equicordplugins/ABSRPC/index.tsx b/src/equicordplugins/ABSRPC/index.tsx new file mode 100644 index 00000000..48a71d0c --- /dev/null +++ b/src/equicordplugins/ABSRPC/index.tsx @@ -0,0 +1,250 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// alot of the code is from JellyfinRPC +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin, { OptionType } from "@utils/types"; +import { ApplicationAssetUtils, FluxDispatcher, Forms, showToast } from "@webpack/common"; + + +interface ActivityAssets { + large_image?: string; + large_text?: string; + small_image?: string; + small_text?: string; +} + +interface Activity { + state: string; + details?: string; + timestamps?: { + start?: number; + }; + assets?: ActivityAssets; + name: string; + application_id: string; + metadata?: { + button_urls?: Array; + }; + type: number; + flags: number; +} + +interface MediaData { + name: string; + type: string; + author?: string; + series?: string; + duration?: number; + currentTime?: number; + progress?: number; + url?: string; + imageUrl?: string; + isFinished?: boolean; +} + + + +const settings = definePluginSettings({ + serverUrl: { + description: "AudioBookShelf server URL (e.g., https://abs.example.com)", + type: OptionType.STRING, + }, + username: { + description: "AudioBookShelf username", + type: OptionType.STRING, + }, + password: { + description: "AudioBookShelf password", + type: OptionType.STRING, + }, +}); + +const applicationId = "1381423044907503636"; + +const logger = new Logger("AudioBookShelfRichPresence"); + +let authToken: string | null = null; + +async function getApplicationAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ + type: "LOCAL_ACTIVITY_UPDATE", + activity, + socketId: "ABSRPC", + }); +} + +export default definePlugin({ + name: "AudioBookShelfRichPresence", + description: "Rich presence for AudioBookShelf media server", + authors: [EquicordDevs.vmohammad], + + settingsAboutComponent: () => ( + <> + How to connect to AudioBookShelf + + Enter your AudioBookShelf server URL, username, and password to display your currently playing audiobooks as Discord Rich Presence. +

+ The plugin will automatically authenticate and fetch your listening progress. +
+ + ), + + settings, + + start() { + this.updatePresence(); + this.updateInterval = setInterval(() => { this.updatePresence(); }, 10000); + }, + + stop() { + clearInterval(this.updateInterval); + }, + + async authenticate(): Promise { + if (!settings.store.serverUrl || !settings.store.username || !settings.store.password) { + logger.warn("AudioBookShelf server URL, username, or password is not set in settings."); + showToast("AudioBookShelf RPC is not configured.", "failure", { + duration: 15000, + }); + return false; + } + + try { + const baseUrl = settings.store.serverUrl.replace(/\/$/, ""); + const url = `${baseUrl}/login`; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: settings.store.username, + password: settings.store.password, + }), + }); + + if (!res.ok) throw `${res.status} ${res.statusText}`; + + const data = await res.json(); + authToken = data.user?.token; + return !!authToken; + } catch (e) { + logger.error("Failed to authenticate with AudioBookShelf", e); + authToken = null; + return false; + } + }, + + async fetchMediaData(): Promise { + if (!authToken && !(await this.authenticate())) { + return null; + } + + const isPlayingNow = session => { + const now = Date.now(); + const lastUpdate = session.updatedAt; + const diffSeconds = (now - lastUpdate) / 1000; + return diffSeconds <= 30; + }; + try { + const baseUrl = settings.store.serverUrl!.replace(/\/$/, ""); + const url = `${baseUrl}/api/me/listening-sessions`; + + const res = await fetch(url, { + headers: { + "Authorization": `Bearer ${authToken}`, + }, + }); + + if (!res.ok) { + if (res.status === 401) { + authToken = null; + if (await this.authenticate()) { + return this.fetchMediaData(); + } + } + throw `${res.status} ${res.statusText}`; + } + + const { sessions } = await res.json(); + const activeSession = sessions.find((session: any) => + session.updatedAt && !session.isFinished + ); + + if (!activeSession || !isPlayingNow(activeSession)) return null; + + const { mediaMetadata: media, mediaType, duration, currentTime, libraryItemId } = activeSession; + if (!media) return null; + console.log(media); + return { + name: media.title || "Unknown", + type: mediaType || "book", + author: media.author || media.publisher, + series: media.series[0]?.name, + duration, + currentTime, + imageUrl: libraryItemId ? `${baseUrl}/api/items/${libraryItemId}/cover` : undefined, + isFinished: activeSession.isFinished || false, + }; + } catch (e) { + logger.error("Failed to query AudioBookShelf API", e); + return null; + } + }, + + async updatePresence() { + setActivity(await this.getActivity()); + }, + + async getActivity(): Promise { + const mediaData = await this.fetchMediaData(); + if (!mediaData || mediaData.isFinished) return null; + + const largeImage = mediaData.imageUrl; + console.log("Large Image URL:", largeImage); + const assets: ActivityAssets = { + large_image: largeImage ? await getApplicationAsset(largeImage) : await getApplicationAsset("audiobookshelf"), + large_text: mediaData.series || mediaData.author || undefined, + }; + + const getDetails = () => { + return mediaData.name; + }; + + const getState = () => { + if (mediaData.series && mediaData.author) { + return `${mediaData.series} • ${mediaData.author}`; + } + return mediaData.author || "AudioBook"; + }; + + const timestamps = mediaData.currentTime && mediaData.duration ? { + start: Date.now() - (mediaData.currentTime * 1000), + end: Date.now() + ((mediaData.duration - mediaData.currentTime) * 1000) + } : undefined; + + return { + application_id: applicationId, + name: "AudioBookShelf", + + details: getDetails(), + state: getState(), + assets, + timestamps, + + type: 2, + flags: 1, + }; + } +}); diff --git a/src/equicordplugins/jellyfinRichPresence/index.tsx b/src/equicordplugins/jellyfinRichPresence/index.tsx new file mode 100644 index 00000000..aa27bedf --- /dev/null +++ b/src/equicordplugins/jellyfinRichPresence/index.tsx @@ -0,0 +1,215 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +// alot of the code is from LastFMRichPresence +import { definePluginSettings } from "@api/Settings"; +import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin, { OptionType } from "@utils/types"; +import { ApplicationAssetUtils, FluxDispatcher, Forms, showToast } from "@webpack/common"; + + +interface ActivityAssets { + large_image?: string; + large_text?: string; + small_image?: string; + small_text?: string; +} + +interface Activity { + state: string; + details?: string; + timestamps?: { + start?: number; + }; + assets?: ActivityAssets; + name: string; + application_id: string; + metadata?: { + button_urls?: Array; + }; + type: number; + flags: number; +} + +interface MediaData { + name: string; + type: string; + artist?: string; + album?: string; + seriesName?: string; + seasonNumber?: number; + episodeNumber?: number; + year?: number; + url?: string; + imageUrl?: string; + duration?: number; + position?: number; +} + + + +const settings = definePluginSettings({ + serverUrl: { + description: "Jellyfin server URL (e.g., https://jellyfin.example.com)", + type: OptionType.STRING, + }, + apiKey: { + description: "Jellyfin API key obtained from your Jellyfin administration dashboard", + type: OptionType.STRING, + }, + userId: { + description: "Jellyfin user ID obtained from your user profile URL", + type: OptionType.STRING, + }, +}); + +const applicationId = "1381368130164625469"; + +const logger = new Logger("JellyfinRichPresence"); + +async function getApplicationAsset(key: string): Promise { + return (await ApplicationAssetUtils.fetchAssetIds(applicationId, [key]))[0]; +} + +function setActivity(activity: Activity | null) { + FluxDispatcher.dispatch({ + type: "LOCAL_ACTIVITY_UPDATE", + activity, + socketId: "Jellyfin", + }); +} + +export default definePlugin({ + name: "JellyfinRichPresence", + description: "Rich presence for Jellyfin media server", + authors: [EquicordDevs.vmohammad], + + settingsAboutComponent: () => ( + <> + How to get an API key + + An API key is required to fetch your current media. To get one, go to your + Jellyfin dashboard, navigate to Administration {">"} API Keys and + create a new API key.

+ + You'll also need your User ID, which can be found in the url of your user profile page. +
+ + ), + + settings, + + start() { + this.updatePresence(); + this.updateInterval = setInterval(() => { this.updatePresence(); }, 10000); + }, + + stop() { + clearInterval(this.updateInterval); + }, + + async fetchMediaData(): Promise { + if (!settings.store.serverUrl || !settings.store.apiKey || !settings.store.userId) { + logger.warn("Jellyfin server URL, API key, or user ID is not set in settings."); + showToast("JellyfinRPC is not configured.", "failure", { + duration: 15000, + }); + return null; + } + + try { + const baseUrl = settings.store.serverUrl.replace(/\/$/, ""); + const url = `${baseUrl}/Sessions?api_key=${settings.store.apiKey}`; + + const res = await fetch(url); + if (!res.ok) throw `${res.status} ${res.statusText}`; + + const sessions = await res.json(); + const userSession = sessions.find((session: any) => + session.UserId === settings.store.userId && session.NowPlayingItem + ); + + if (!userSession || !userSession.NowPlayingItem) return null; + + const item = userSession.NowPlayingItem; + const playState = userSession.PlayState; + + if (playState?.IsPaused) return null; + + const imageUrl = item.ImageTags?.Primary + ? `${baseUrl}/Items/${item.Id}/Images/Primary` + : undefined; + + return { + name: item.Name || "Unknown", + type: item.Type, + artist: item.Artists?.[0] || item.AlbumArtist, + album: item.Album, + seriesName: item.SeriesName, + seasonNumber: item.ParentIndexNumber, + episodeNumber: item.IndexNumber, + year: item.ProductionYear, + url: `${baseUrl}/web/#!/details?id=${item.Id}`, + imageUrl, + duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined, + position: playState?.PositionTicks ? Math.floor(playState.PositionTicks / 10000000) : undefined + }; + } catch (e) { + logger.error("Failed to query Jellyfin API", e); + return null; + } + }, + + async updatePresence() { + setActivity(await this.getActivity()); + }, + + async getActivity(): Promise { + const mediaData = await this.fetchMediaData(); + if (!mediaData) return null; + + const largeImage = mediaData.imageUrl; + const assets: ActivityAssets = { + large_image: largeImage ? await getApplicationAsset(largeImage) : await getApplicationAsset("jellyfin"), + large_text: mediaData.album || mediaData.seriesName || undefined, + }; + + const getDetails = () => { + if (mediaData.type === "Episode" && mediaData.seriesName) { + return mediaData.name; + } + return mediaData.name; + }; + + const getState = () => { + if (mediaData.type === "Episode" && mediaData.seriesName) { + const season = mediaData.seasonNumber ? `S${mediaData.seasonNumber}` : ""; + const episode = mediaData.episodeNumber ? `E${mediaData.episodeNumber}` : ""; + return `${mediaData.seriesName} ${season}${episode}`.trim(); + } + return mediaData.artist || (mediaData.year ? `(${mediaData.year})` : undefined); + }; + + const timestamps = mediaData.position && mediaData.duration ? { + start: Date.now() - (mediaData.position * 1000), + end: Date.now() + ((mediaData.duration - mediaData.position) * 1000) + } : undefined; + + return { + application_id: applicationId, + name: "Jellyfin", + + details: getDetails(), + state: getState() || "something", + assets, + timestamps, + + type: 3, + flags: 1, + }; + } +});