audio book shelf rpc and jellyfin rpc (#283)
Some checks are pending
Test / Test (push) Waiting to run

* 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>
This commit is contained in:
vMohammad 2025-06-09 05:02:00 +03:00 committed by GitHub
parent c90df61b88
commit 10cbc682db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 468 additions and 1 deletions

View file

@ -11,7 +11,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
### Extra included plugins
<details>
<summary>184 additional plugins</summary>
<summary>186 additional plugins</summary>
### 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

View file

@ -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<string>;
};
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<string> {
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: () => (
<>
<Forms.FormTitle tag="h3">How to connect to AudioBookShelf</Forms.FormTitle>
<Forms.FormText>
Enter your AudioBookShelf server URL, username, and password to display your currently playing audiobooks as Discord Rich Presence.
<br /><br />
The plugin will automatically authenticate and fetch your listening progress.
</Forms.FormText>
</>
),
settings,
start() {
this.updatePresence();
this.updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
},
stop() {
clearInterval(this.updateInterval);
},
async authenticate(): Promise<boolean> {
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<MediaData | null> {
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<Activity | null> {
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,
};
}
});

View file

@ -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<string>;
};
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<string> {
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: () => (
<>
<Forms.FormTitle tag="h3">How to get an API key</Forms.FormTitle>
<Forms.FormText>
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. <br /> <br />
You'll also need your User ID, which can be found in the url of your user profile page.
</Forms.FormText>
</>
),
settings,
start() {
this.updatePresence();
this.updateInterval = setInterval(() => { this.updatePresence(); }, 10000);
},
stop() {
clearInterval(this.updateInterval);
},
async fetchMediaData(): Promise<MediaData | null> {
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<Activity | null> {
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,
};
}
});