Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen1234 2026-07-17 21:37:23 -04:00
commit cefee6a4be
No known key found for this signature in database
13 changed files with 277 additions and 101 deletions

View file

@ -55,6 +55,11 @@ export const CspPolicies: PolicyMap = {
// Function Specific
"api.github.com": ConnectSrc, // used for updating Vencord itself
"ws.audioscrobbler.com": ConnectSrc, // Last.fm API
"musicbrainz.org": ConnectSrc,
"*.listenbrainz.org": ConnectSrc,
"coverartarchive.org": ConnectSrc,
"archive.org": ConnectSrc,
"*.archive.org": ConnectSrc,
"translate-pa.googleapis.com": ConnectSrc, // Google Translate API
"*.vencord.dev": ImageSrc, // VenCloud (api.vencord.dev) and Badges (badges.vencord.dev)
"manti.vendicated.dev": ImageSrc, // ReviewDB API

View file

@ -27,7 +27,7 @@ export default definePlugin({
{
find: "#{intl::MESSAGE_UTILITIES_A11Y_LABEL}",
replacement: {
match: /(?<=\]\}\)),(.{0,40}togglePopout:.+?\}\))\]\}\):null,(?<=\((\i\.\i),\{label:.+?:null,(\i)\?\(0,\i\.jsxs?\)\(\i\.Fragment.+?message:(\i).+?)/,
match: /(?<=\]\}\)),(.{0,40}togglePopout:.+?\}\))\]\}\):null,(?<=\((\i),\{label:.+?:null,(\i)\?\(0,\i\.jsxs?\)\(\i\.Fragment.+?message:(\i).+?)/,
replace: (_, ReactButton, ButtonComponent, showReactButton, message) => "" +
`]}):null,Vencord.Api.MessagePopover._buildPopoverElements(${ButtonComponent},${message}),${showReactButton}?${ReactButton}:null,`
}

View file

@ -274,7 +274,7 @@ export default definePlugin({
predicate: () => settings.store.closeAllHomeButton,
replacement: {
// Close all folders when clicking the home button
match: /(?<=onClick:(\(\)=>|function\(\)){)(?=.{0,300}"discodo")/,
match: /(?<=onClick:(?:function)?\(\)(?:=>)?{)(?=.{0,300}"discodo")/,
replace: "$self.closeFolders();"
}
}

View file

@ -55,8 +55,8 @@ export default definePlugin({
replace: "$self.DecorationGridItem=$1;",
},
{
match: /(?=function (\i)\(\i\){let{user:\i,avatarDecoration)/,
replace: "$self.DecorationGridDecoration=$1;",
match: /(?<=(?:(\i)=)?)(?:\i=>|function (\i)\(\i\)){let{user:\i,avatarDecoration/,
replace: (m, arrowFunctionName, functionName) => `$self.DecorationGridDecoration=${arrowFunctionName ?? functionName}${arrowFunctionName ? "" : ";"}${m}`,
},
// Remove NEW label from decor avatar decorations
{

View file

@ -24,8 +24,6 @@ import { definePluginSettings } from "@api/Settings";
import { getUserSettingLazy } from "@api/UserSettings";
import { BaseText } from "@components/BaseText";
import { Devs } from "@utils/constants.js";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import { Queue } from "@utils/Queue";
import definePlugin, { OptionType } from "@utils/types";
import { Channel, Message } from "@vencord/discord-types";
@ -45,7 +43,6 @@ import {
} from "@webpack/common";
import { ComponentType, JSX } from "react";
const cl = classNameFactory("vc-message-link-embeds-");
const messageCache = new Map<string, {
message?: Message;
fetched: boolean;
@ -306,7 +303,12 @@ function ChannelMessageEmbedAccessory({ message, channel }: MessageEmbedProps):
}
}}
renderDescription={() => (
<div key={message.id} className={classes(cl("message"), settings.store.messageBackgroundColor && cl("search-result"))}>
<div key={message.id} style={!settings.store.messageBackgroundColor ? undefined : {
backgroundColor: "var(--background-base-lower)",
border: "1px solid var(--border-subtle)",
borderRadius: "8px",
paddingBottom: "8px",
}}>
<ChannelMessage
id={`message-link-embeds-${message.id}`}
message={message}

View file

@ -16,25 +16,38 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { definePluginSettings, migratePluginSetting, migratePluginSettings } from "@api/Settings";
import { LinkButton } from "@components/Button";
import { Card } from "@components/Card";
import { Heading } from "@components/Heading";
import { Margins } from "@components/margins";
import { Paragraph } from "@components/Paragraph";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { Activity, ActivityAssets, ActivityButton } from "@vencord/discord-types";
import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord/discord-types/enums";
import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common";
interface TrackData {
import { LastFMScrobbler } from "./lastfm";
import { ListenBrainzScrobbler } from "./listenbrainz";
export interface TrackData {
name: string;
album: string;
artist: string;
url: string;
imageUrl?: string;
trackURL?: string;
artistURL?: string;
albumURL?: string;
imageURL?: string;
serviceName?: string;
}
export interface ScrobblerBackend {
name: string,
id: string,
fetchTrackData(username: string, apiKey?: string): Promise<TrackData | null>;
getUserURL(username: string): string;
}
const enum NameFormat {
@ -43,18 +56,15 @@ const enum NameFormat {
SongFirst = "song-first",
ArtistOnly = "artist",
SongOnly = "song",
AlbumName = "album"
AlbumName = "album",
ServiceName = "service-name"
}
// Last.fm API keys are essentially public information and have no access to your account, so including one here is fine.
const API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
const LASTFM_API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
const DISCORD_APP_ID = "1108588077900898414";
const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f";
const logger = new Logger("LastFMRichPresence");
let updateInterval: NodeJS.Timeout | undefined;
async function getApplicationAsset(key: string): Promise<string> {
return (await ApplicationAssetUtils.fetchAssetIds(DISCORD_APP_ID, [key]))[0];
}
@ -68,17 +78,32 @@ function setActivity(activity: Activity | null) {
}
const settings = definePluginSettings({
scrobblerBackend: {
description: "The scrobbler backend to use.",
type: OptionType.SELECT,
options: [
{
"label": "Last.FM",
"value": "lastfm",
"default": true
},
{
"label": "ListenBrainz",
"value": "listenbrainz"
}
] as const
},
apiKey: {
displayName: "API Key",
description: "Custom Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key",
type: OptionType.STRING,
},
username: {
description: "Last.fm username",
description: "Username",
type: OptionType.STRING,
},
shareUsername: {
description: "Show link to Last.fm profile",
description: "Show link to scrobbler profile",
type: OptionType.BOOLEAN,
default: false,
},
@ -88,12 +113,12 @@ const settings = definePluginSettings({
default: true,
},
hideWithSpotify: {
description: "Hide Last.fm presence if spotify is running",
description: "Hide presence if Spotify is running",
type: OptionType.BOOLEAN,
default: true,
},
hideWithActivity: {
description: "Hide Last.fm presence if you have any other presence",
description: "Hide presence if you have any other presence",
type: OptionType.BOOLEAN,
default: false,
},
@ -130,6 +155,10 @@ const settings = definePluginSettings({
value: NameFormat.StatusName,
default: true
},
{
label: "Use music service name (falls back to custom status text)",
value: NameFormat.ServiceName
},
{
label: "Use format 'artist - song'",
value: NameFormat.ArtistFirst
@ -162,8 +191,8 @@ const settings = definePluginSettings({
type: OptionType.SELECT,
options: [
{
label: "Use large Last.fm logo",
value: "lastfmLogo",
label: "Use large scrobbler logo",
value: "logo",
default: true
},
{
@ -172,30 +201,34 @@ const settings = definePluginSettings({
}
],
},
showLastFmLogo: {
displayName: "Show Last.fm Logo",
description: "Show the Last.fm logo by the album cover",
showLogo: {
displayName: "Show Scrobbler Logo",
description: "Show the scrobbler service logo by the album cover",
type: OptionType.BOOLEAN,
default: true,
},
showAlbumCover: {
description: "Show album cover. Disabling this will display a placeholder. Useful if your Music has inappropriate art",
description: "Show album cover. Disabling this will display a placeholder. Useful if your music has inappropriate art",
type: OptionType.BOOLEAN,
default: true,
}
});
migratePluginSettings("MusicRichPresence", "LastFMRichPresence");
migratePluginSetting("MusicRichPresence", "showLastFmLogo", "showLogo");
export default definePlugin({
name: "LastFMRichPresence",
description: "Little plugin for Last.fm rich presence",
name: "MusicRichPresence",
description: "Rich Presence for Last.FM/Listenbrainz",
tags: ["Activity", "Media"],
authors: [Devs.dzshn, Devs.RuiNtD, Devs.blahajZip, Devs.archeruwu],
searchTerms: ["lastfm", "LastFMRichPresence"],
authors: [Devs.Rini, Devs.Ven, Devs.angelcube, Devs.RuiNtD, Devs.blahajZip, Devs.archeruwu],
settings,
settingsAboutComponent() {
return (
<Card>
<Heading tag="h2">Last.FM</Heading>
<Heading tag="h5">How to create an API key</Heading>
<Paragraph>Set <strong>Application name</strong> and <strong>Application description</strong> to anything and leave the rest blank.</Paragraph>
<LinkButton size="small" href="https://www.last.fm/api/account/create" className={Margins.top8}>Create API Key</LinkButton>
@ -205,54 +238,11 @@ export default definePlugin({
start() {
this.updatePresence();
updateInterval = setInterval(() => { this.updatePresence(); }, 16000);
this.updateInterval = setInterval(() => { this.updatePresence(); }, 16000);
},
stop() {
clearInterval(updateInterval);
updateInterval = undefined;
},
async fetchTrackData(): Promise<TrackData | null> {
if (!settings.store.username)
return null;
try {
const params = new URLSearchParams({
method: "user.getrecenttracks",
api_key: settings.store.apiKey || API_KEY,
user: settings.store.username,
limit: "1",
format: "json"
});
const res = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`);
if (!res.ok) throw `${res.status} ${res.statusText}`;
const json = await res.json();
if (json.error) {
logger.error("Error from Last.fm API", `${json.error}: ${json.message}`);
return null;
}
const trackData = json.recenttracks?.track[0];
if (!trackData?.["@attr"]?.nowplaying)
return null;
// why does the json api have xml structure
return {
name: trackData.name || "Unknown",
album: trackData.album["#text"],
artist: trackData.artist["#text"] || "Unknown",
url: trackData.url,
imageUrl: trackData.image?.find((x: any) => x.size === "large")?.["#text"]
};
} catch (e) {
logger.error("Failed to query Last.fm API", e);
// will clear the rich presence if API fails
return null;
}
clearInterval(this.updateInterval);
},
async updatePresence() {
@ -260,14 +250,17 @@ export default definePlugin({
},
getLargeImage(track: TrackData): string | undefined {
if (settings.store.showAlbumCover && track.imageUrl && !track.imageUrl.includes(LASTFM_PLACEHOLDER_IMAGE_HASH))
return track.imageUrl;
if (settings.store.showAlbumCover && track.imageURL && !track.imageURL.includes(LASTFM_PLACEHOLDER_IMAGE_HASH))
return track.imageURL;
if (settings.store.missingArt === "placeholder")
return "placeholder";
},
async getActivity(): Promise<Activity | null> {
if (!settings.store.username)
return null;
if (settings.store.hideWithActivity) {
if (PresenceStore.getActivities(AuthenticationStore.getId()).some(a => a.application_id !== DISCORD_APP_ID && a.type !== ActivityType.CUSTOM_STATUS)) {
return null;
@ -281,7 +274,9 @@ export default definePlugin({
}
}
const trackData = await this.fetchTrackData();
const scrobbler = settings.store.scrobblerBackend === "lastfm" ? LastFMScrobbler : ListenBrainzScrobbler;
const trackData = await scrobbler.fetchTrackData(settings.store.username, settings.store.apiKey || LASTFM_API_KEY);
if (!trackData) return null;
const largeImage = this.getLargeImage(trackData);
@ -289,22 +284,23 @@ export default definePlugin({
{
large_image: await getApplicationAsset(largeImage),
large_text: trackData.album || undefined,
...(settings.store.showLastFmLogo && {
small_image: await getApplicationAsset("lastfm-small"),
small_text: "Last.fm"
...(settings.store.showLogo && {
small_image: await getApplicationAsset(`${scrobbler.id}-small`),
small_text: scrobbler.id
}),
} : {
large_image: await getApplicationAsset("lastfm-large"),
large_image: await getApplicationAsset(`${scrobbler.id}-large`),
large_text: trackData.album || undefined,
};
const buttons: ActivityButton[] = [];
if (settings.store.shareUsername)
if (settings.store.shareUsername) {
buttons.push({
label: "Last.fm Profile",
url: `https://www.last.fm/user/${settings.store.username}`,
label: `${scrobbler.name} Profile`,
url: scrobbler.getUserURL(settings.store.username!)
});
}
const statusName = (() => {
switch (settings.store.nameFormat) {
@ -321,6 +317,11 @@ export default definePlugin({
.replaceAll("{artist}", trackData.artist || "")
.replaceAll("{album}", trackData.album || "")
.replaceAll("{title}", trackData.name || "");
case NameFormat.ServiceName:
return trackData.serviceName || settings.store.statusName
.replaceAll("{artist}", trackData.artist || "")
.replaceAll("{album}", trackData.album || "")
.replaceAll("{title}", trackData.name || "");
default:
return settings.store.statusName
.replaceAll("{artist}", trackData.artist || "")
@ -353,11 +354,11 @@ export default definePlugin({
};
if (settings.store.clickableLinks) {
activity.details_url = trackData.url;
activity.state_url = `https://www.last.fm/music/${encodeURIComponent(trackData.artist)}`;
activity.details_url = trackData.trackURL;
activity.state_url = trackData.artistURL;
if (trackData.album) {
activity.assets!.large_url = `https://www.last.fm/music/${encodeURIComponent(trackData.artist)}/${encodeURIComponent(trackData.album)}`;
activity.assets!.large_url = trackData.albumURL;
}
}

View file

@ -0,0 +1,63 @@
/*
* 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 { ScrobblerBackend, TrackData } from ".";
const logger = new Logger("MusicRichPresence/LastFM");
const url = (path: string) => `https://www.last.fm${path}`;
export const LastFMScrobbler: ScrobblerBackend = {
name: "Last.FM",
id: "lastfm",
async fetchTrackData(username: string, apiKey: string): Promise<TrackData | null> {
try {
const params = new URLSearchParams({
method: "user.getrecenttracks",
api_key: apiKey,
user: username,
limit: "1",
format: "json"
});
const res = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`);
if (!res.ok) throw `${res.status} ${res.statusText}`;
const json = await res.json();
if (json.error) {
logger.error("Error from Last.fm API", `${json.error}: ${json.message}`);
return null;
}
const trackData = json.recenttracks?.track[0];
if (!trackData?.["@attr"]?.nowplaying)
return null;
// why does the json api have xml structure
return {
name: trackData.name || "Unknown",
album: trackData.album["#text"],
artist: trackData.artist["#text"] || "Unknown",
trackURL: trackData.url,
artistURL: trackData.artist["#text"] ? url(`/music/${encodeURIComponent(trackData.artist["#text"])}`) : undefined,
albumURL: url(`/music/${encodeURIComponent(trackData.artist["#text"])}/${encodeURIComponent(trackData.album["#text"])}`),
imageURL: trackData.image?.find((x: any) => x.size === "large")?.["#text"]
} as TrackData;
} catch (e) {
logger.error("Failed to query Last.FM API", e);
// will clear the rich presence if API fails
return null;
}
},
getUserURL(username: string): string {
return url(`/user/${username}`);
}
};

View file

@ -0,0 +1,105 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
import { Logger } from "@utils/Logger";
import { ScrobblerBackend, TrackData } from ".";
const logger = new Logger("AudioScrobblerRichPresence/ListenBrainz");
const url = (path: string) => `https://listenbrainz.org${path}`;
async function fetchCoverArt(releaseGroupMBID: string) {
const res = await fetch(`https://coverartarchive.org/release-group/${releaseGroupMBID}`);
if (!res.ok) return null;
return res.json().then(json => json.images[0].thumbnails.large);
}
async function getUrls(additionalInfo: Record<string, string> | undefined, trackName: string, artistName: string, releaseName: string): Promise<Partial<TrackData>> {
// Well tagged music will have MBIDs which we can use directly. These are optional but highly recommended in ListenBrainz scrobbles.
// If your music doesn't have these, it's highly recommended to use https://picard.musicbrainz.org/ to automatically add them
if (additionalInfo?.recording_mbid) {
const { release_group_mbid, release_mbid, recording_mbid, artist_mbids } = additionalInfo;
return {
imageURL: release_group_mbid ? await fetchCoverArt(release_group_mbid) : undefined,
trackURL: recording_mbid ? url(`/track/${recording_mbid}`) : undefined,
albumURL: release_group_mbid
? url(`/release-group/${release_group_mbid}`)
: release_mbid
? url(`/release/${release_mbid}`)
: undefined,
artistURL: artist_mbids?.length ? url(`/artist/${artist_mbids[0]}`) : undefined,
};
}
// If no MBIDs are present, try to search for the track on MusicBrainz
// this needs to be encoded separately—URLSearchParams encodes spaces as "+"
const query = encodeURIComponent(`artist:"${artistName}" AND recording:"${trackName}" AND album:${releaseName}`);
const params = new URLSearchParams({
fmt: "json",
limit: "1"
});
const metadata = await fetch("https://musicbrainz.org/ws/2/recording/?" + params + "&query=" + query, {
headers: { "User-Agent": VENCORD_USER_AGENT }
})
.then(res => res.ok ? res.json() : Promise.reject(new Error(`${res.status} ${res.statusText}`)))
.then(json => json.recordings?.[0]);
if (!metadata) {
return {};
}
const artist = metadata["artist-credit"]?.[0]?.artist;
const release = metadata.releases?.[0];
return {
imageURL: release?.["release-group"] ? await fetchCoverArt(release["release-group"].id) : undefined,
trackURL: url(`/track/${metadata.id}/`),
albumURL: release?.id ? url(`/release/${release.id}/`) : release?.["release-group"]?.id ? url(`/release-group/${release["release-group"].id}/`) : undefined,
artistURL: artist?.id ? url(`/artist/${artist.id}/`) : undefined,
};
}
export const ListenBrainzScrobbler: ScrobblerBackend = {
name: "ListenBrainz",
id: "listenbrainz",
async fetchTrackData(username: string, _apiKey?: string): Promise<TrackData | null> {
try {
const res = await fetch(`https://api.listenbrainz.org/1/user/${username}/playing-now`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json().then(json => json.payload?.listens[0]);
if (!data?.playing_now || !data?.track_metadata)
return null;
const { track_name, artist_name, release_name, additional_info } = data.track_metadata;
const trackData = {
name: track_name || "Unknown",
artist: artist_name,
album: release_name || "Unknown",
serviceName: additional_info?.music_service_name || additional_info?.submission_client,
...await getUrls(additional_info, track_name, artist_name, release_name)
} as TrackData;
return trackData;
} catch (e) {
logger.error("Failed to query ListenBrainz API", e);
// will clear the rich presence if API fails
return null;
}
},
getUserURL(username: string): string {
return url(`/user/${username}`);
}
};

View file

@ -23,7 +23,7 @@ export default definePlugin({
name: "NoUnblockToJump",
description: "Allows you to jump to messages of blocked or ignored users and likely spammers without unblocking them",
tags: ["Utility"],
authors: [Devs.dzshn],
authors: [Devs.Rini],
patches: [
{
find: "#{intl::UNIGNORE_TO_JUMP_BODY}",

View file

@ -142,7 +142,7 @@ export default definePlugin({
name: "ServerListIndicators",
description: "Add online friend count or server count in the server list",
tags: ["Servers", "Appearance"],
authors: [Devs.dzshn, EquicordDevs.Panniku],
authors: [Devs.Rini, EquicordDevs.Panniku],
dependencies: ["ServerListAPI"],
settings,

View file

@ -83,7 +83,7 @@ export default definePlugin({
name: "ShowHiddenChannels",
description: "Show channels that you do not have access to view.",
tags: ["Servers", "Utility"],
authors: [Devs.BigDuck, Devs.AverageReactEnjoyer, Devs.D3SOX, Devs.Ven, Devs.Nuckyz, Devs.Nickyux, Devs.dzshn, EquicordDevs.Oggetto],
authors: [Devs.BigDuck, Devs.AverageReactEnjoyer, Devs.D3SOX, Devs.Ven, Devs.Nuckyz, Devs.Nickyux, Devs.Rini, EquicordDevs.Oggetto],
isModified: true,
settings,

View file

@ -45,7 +45,7 @@ export default definePlugin({
replacement: [
{
// if (inlinedCalculatePlayingCount(a,b) >= limit) return;
match: /(BURST_REACTION_EFFECT_PLAY:(\i=>|function\(\i\)){.+?if\()(\(\(\i,\i\)=>.+?\(\i,\i\))>=5+?(?=\))/,
match: /(BURST_REACTION_EFFECT_PLAY:(?:\i=>|function\(\i\)){.+?if\()(\(?(?:function)?\(\i,\i\)(?:=>)?{.+?\(\i,\i\))>=5+?(?=\))/,
replace: (_, rest, playingCount) => `${rest}!$self.shouldPlayBurstReaction(${playingCount})`
}
]

View file

@ -192,10 +192,6 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "kemo",
id: 715746190813298788n
},
dzshn: {
name: "dzshn",
id: 310449948011528192n
},
Ducko: {
name: "Ducko",
id: 506482395269169153n
@ -374,8 +370,8 @@ export const Devs = /* #__PURE__*/ Object.freeze({
id: 354191516979429376n
},
Rini: {
name: "Rini",
id: 1079479184478441643n
name: "rini",
id: 0n
},
castdrian: {
name: "castdrian",
@ -515,7 +511,7 @@ export const Devs = /* #__PURE__*/ Object.freeze({
},
Byeoon: {
name: "byeoon",
id: 1167275288036655133n
id: 495683688474869781n
},
Kaitlyn: {
name: "kaitlyn",
@ -666,6 +662,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "NightmareSan",
id: 304239816466235392n
},
angelcube: {
name: "angelcube",
id: 958505257288208446n
},
Lunascape: {
name: "Lunascape",
id: 383365021415243776n