mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-13 08:33:01 -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
|
||||
|
||||
<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
|
||||
|
|
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