mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-15 01:23:03 -04:00
audio book shelf rpc and jellyfin rpc (#283)
Some checks are pending
Test / Test (push) Waiting to run
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:
parent
c90df61b88
commit
10cbc682db
3 changed files with 468 additions and 1 deletions
|
@ -11,7 +11,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
||||||
### Extra included plugins
|
### Extra included plugins
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>184 additional plugins</summary>
|
<summary>186 additional plugins</summary>
|
||||||
|
|
||||||
### All Platforms
|
### All Platforms
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ You can join our [discord server](https://discord.gg/5Xh2W87egW) for commits, ch
|
||||||
- AlwaysExpandProfile by thororen
|
- AlwaysExpandProfile by thororen
|
||||||
- AmITyping by MrDiamond
|
- AmITyping by MrDiamond
|
||||||
- Anammox by Kyuuhachi
|
- Anammox by Kyuuhachi
|
||||||
|
- AudiobookShelfRPC by vMohammad
|
||||||
- AtSomeone by Joona
|
- AtSomeone by Joona
|
||||||
- BannersEverywhere by ImLvna & AutumnVN
|
- BannersEverywhere by ImLvna & AutumnVN
|
||||||
- BetterActivities by D3SOX, Arjix, 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
|
- InRole by nin0dev
|
||||||
- InstantScreenshare by HAHALOSAH & thororen
|
- InstantScreenshare by HAHALOSAH & thororen
|
||||||
- IRememberYou by zoodogood
|
- IRememberYou by zoodogood
|
||||||
|
- JellyfinRichPresence by vMohammad
|
||||||
- Jumpscare by Surgedevs
|
- Jumpscare by Surgedevs
|
||||||
- JumpToStart by Samwich
|
- JumpToStart by Samwich
|
||||||
- KeyboardSounds by HypedDomi
|
- KeyboardSounds by HypedDomi
|
||||||
|
|
250
src/equicordplugins/ABSRPC/index.tsx
Normal file
250
src/equicordplugins/ABSRPC/index.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
215
src/equicordplugins/jellyfinRichPresence/index.tsx
Normal file
215
src/equicordplugins/jellyfinRichPresence/index.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue