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

This commit is contained in:
thororen1234 2026-07-23 11:12:09 -04:00
commit e491a21145
No known key found for this signature in database
9 changed files with 170 additions and 42 deletions

View file

@ -1,4 +1,4 @@
import { Channel, Guild, GuildMember, Message, User } from ".";
import { AvatarDecorationData, Channel, Guild, GuildMember, Message, User } from ".";
import type { ReactNode } from "react";
import { LiteralUnion } from "type-fest";
@ -204,7 +204,7 @@ export interface IconUtils {
getUserAvatarURL(user: User, canAnimate?: boolean, size?: number, format?: string): string;
getDefaultAvatarURL(id: string, discriminator?: string): string;
getUserBannerURL(data: { id: string, banner: string, canAnimate?: boolean, size: number; }): string | undefined;
getAvatarDecorationURL(data: { avatarDecoration: string, size: number; canCanimate?: boolean; }): string | undefined;
getAvatarDecorationURL(data: { avatarDecoration: AvatarDecorationData, size: number; canAnimate?: boolean; }): string | undefined;
getGuildMemberAvatarURL(member: GuildMember, canAnimate?: string): string | null;
getGuildMemberAvatarURLSimple(data: { guildId: string, userId: string, avatar: string, canAnimate?: boolean; size?: number; }): string;

View file

@ -22,8 +22,7 @@ import definePlugin from "@utils/types";
export default definePlugin({
name: "BetterGifAltText",
authors: [Devs.Ven],
description:
"Change GIF alt text from simply being 'GIF' to containing the gif tags / filename",
description: "Change GIF alt text from simply being 'GIF' to containing the gif tags / filename",
tags: ["Media", "Accessibility", "Customisation"],
patches: [
{
@ -46,6 +45,8 @@ export default definePlugin({
],
altify(props: any) {
if (props.contentType && props.contentType !== "image/gif") return props.alt;
props.alt ??= "GIF";
if (props.alt !== "GIF") return props.alt;

View file

@ -569,16 +569,12 @@ export default definePlugin({
if (!newMessage.attachments?.length) {
return oldMessage.attachments.map((a): MLAttachment => ({ ...a, deleted: true }));
}
const attachments: MLAttachment[] = [];
for (const oldAttachment of oldMessage.attachments) {
const wasDeleted = newMessage.attachments.every(a => a.id !== oldAttachment.id);
if (wasDeleted) {
attachments.push({ ...oldAttachment, deleted: true });
} else {
attachments.push(oldAttachment);
}
}
return attachments;
return oldMessage.attachments
.map((oldAttachment): MLAttachment =>
newMessage.attachments.find(a => a.id === oldAttachment.id)
?? { ...oldAttachment, deleted: true }
)
.concat(newMessage.attachments.filter(a => !oldMessage.attachments.some(o => o.id === a.id)));
},
handleDelete(cache: any, data: { ids: string[], id: string; mlDeleted?: boolean; }, isBulk: boolean) {

View file

@ -29,7 +29,7 @@ import { ActivityFlags, ActivityStatusDisplayType, ActivityType } from "@vencord
import { ApplicationAssetUtils, AuthenticationStore, FluxDispatcher, PresenceStore } from "@webpack/common";
import { LastFMScrobbler } from "./lastfm";
import { ListenBrainzScrobbler } from "./listenbrainz";
import { invalidateListenBrainzCache, ListenBrainzScrobbler } from "./listenbrainz";
export interface TrackData {
name: string;
@ -46,7 +46,7 @@ export interface ScrobblerBackend {
name: string,
id: string,
fetchTrackData(username: string, apiKey?: string): Promise<TrackData | null>;
fetchTrackData(): Promise<TrackData | null>;
getUserURL(username: string): string;
}
@ -60,8 +60,6 @@ const enum NameFormat {
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 LASTFM_API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
const DISCORD_APP_ID = "1108588077900898414";
const LASTFM_PLACEHOLDER_IMAGE_HASH = "2a96cbd8b46e442fc41c2b86b821562f";
@ -77,7 +75,7 @@ function setActivity(activity: Activity | null) {
});
}
const settings = definePluginSettings({
export const settings = definePluginSettings({
scrobblerBackend: {
description: "The scrobbler backend to use.",
type: OptionType.SELECT,
@ -90,12 +88,28 @@ const settings = definePluginSettings({
{
"label": "ListenBrainz",
"value": "listenbrainz"
},
{
"label": "ListenBrainz Compatible (self-hosted)",
"value": "listenbrainz-compatible"
}
] as const
},
instanceBaseURL: {
description: "The base url of your ListenBrainz instance.",
type: OptionType.STRING,
placeholder: "https://example.org",
onChange: invalidateListenBrainzCache
},
instanceAPIBaseUrl: {
description: "The base url of your ListenBrainz API.",
type: OptionType.STRING,
placeholder: "https://api.example.org",
onChange: invalidateListenBrainzCache
},
apiKey: {
displayName: "API Key",
description: "Custom Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key",
description: "Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key",
type: OptionType.STRING,
},
username: {
@ -212,6 +226,10 @@ const settings = definePluginSettings({
type: OptionType.BOOLEAN,
default: true,
}
}, {
apiKey: { hidden() { return this.store.scrobblerBackend !== "lastfm"; } },
instanceBaseURL: { hidden() { return this.store.scrobblerBackend !== "listenbrainz-compatible"; } },
instanceAPIBaseUrl: { hidden() { return this.store.scrobblerBackend !== "listenbrainz-compatible"; } },
});
migratePluginSettings("MusicRichPresence", "LastFMRichPresence");
@ -226,6 +244,9 @@ export default definePlugin({
settings,
settingsAboutComponent() {
if (settings.store.scrobblerBackend !== "lastfm")
return null;
return (
<Card>
<Heading tag="h2">Last.FM</Heading>
@ -246,6 +267,11 @@ export default definePlugin({
},
async updatePresence() {
const { username, scrobblerBackend, instanceAPIBaseUrl, instanceBaseURL } = settings.store;
if (!username) return;
if (scrobblerBackend === "listenbrainz-compatible" && (!instanceAPIBaseUrl || !instanceBaseURL)) return;
setActivity(await this.getActivity());
},
@ -258,8 +284,6 @@ export default definePlugin({
},
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)) {
@ -276,7 +300,7 @@ export default definePlugin({
const scrobbler = settings.store.scrobblerBackend === "lastfm" ? LastFMScrobbler : ListenBrainzScrobbler;
const trackData = await scrobbler.fetchTrackData(settings.store.username, settings.store.apiKey || LASTFM_API_KEY);
const trackData = await scrobbler.fetchTrackData();
if (!trackData) return null;
const largeImage = this.getLargeImage(trackData);

View file

@ -6,7 +6,10 @@
import { Logger } from "@utils/Logger";
import { ScrobblerBackend, TrackData } from ".";
import { ScrobblerBackend, settings, TrackData } from ".";
// Last.fm API keys are essentially public information and have no access to your account, so including one here is fine.
const LASTFM_API_KEY = "790c37d90400163a5a5fe00d6ca32ef0";
const logger = new Logger("MusicRichPresence/LastFM");
@ -16,12 +19,12 @@ export const LastFMScrobbler: ScrobblerBackend = {
name: "Last.FM",
id: "lastfm",
async fetchTrackData(username: string, apiKey: string): Promise<TrackData | null> {
async fetchTrackData(): Promise<TrackData | null> {
try {
const params = new URLSearchParams({
method: "user.getrecenttracks",
api_key: apiKey,
user: username,
api_key: settings.store.apiKey || LASTFM_API_KEY,
user: settings.store.username!,
limit: "1",
format: "json"
});

View file

@ -6,27 +6,59 @@
import { VENCORD_USER_AGENT } from "@shared/vencordUserAgent";
import { Logger } from "@utils/Logger";
import { TTLMap } from "@utils/TTLMap";
import { ScrobblerBackend, TrackData } from ".";
import { ScrobblerBackend, settings, TrackData } from ".";
const logger = new Logger("AudioScrobblerRichPresence/ListenBrainz");
const url = (path: string) => `https://listenbrainz.org${path}`;
// 15 minutes
const coverArtCache = new TTLMap<string, string>(15 * 60 * 1000);
const metadataCache = new TTLMap<string, Partial<TrackData> | null>(15 * 60 * 1000);
const isCustomInstance = () => settings.store.scrobblerBackend === "listenbrainz-compatible";
const url = (path: string) => `${isCustomInstance() ? settings.store.instanceBaseURL : "https://listenbrainz.org"}${path}`;
const apiUrl = (path: string) => `${isCustomInstance() ? settings.store.instanceAPIBaseUrl : "https://api.listenbrainz.org"}${path}`;
export function invalidateListenBrainzCache() {
coverArtCache.clear();
metadataCache.clear();
}
const YoutubeVideoURLRegex = /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(?:-nocookie)?\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|live\/|v\/)?)([\w-]+)(\S+)?$/;
function fallbackToYoutubeThumbnail(originUrl: string | undefined): string | undefined {
if (!originUrl) return undefined;
const match = originUrl.match(YoutubeVideoURLRegex);
return match ? `https://i.ytimg.com/vi/${match[5]}/maxresdefault.jpg` : undefined;
}
async function fetchCoverArt(releaseGroupMBID: string, originUrl?: string): Promise<string | undefined> {
if (!releaseGroupMBID) return fallbackToYoutubeThumbnail(originUrl);
if (coverArtCache.has(releaseGroupMBID)) {
return coverArtCache.get(releaseGroupMBID);
}
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);
if (!res.ok) return fallbackToYoutubeThumbnail(originUrl);
const url = await res.json()
.then(json => json.images[0].thumbnails.large ?? fallbackToYoutubeThumbnail(originUrl));
coverArtCache.set(releaseGroupMBID, url);
return url;
}
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;
const { release_group_mbid, release_mbid, recording_mbid, artist_mbids, origin_url } = additionalInfo;
return {
imageURL: release_group_mbid ? await fetchCoverArt(release_group_mbid) : undefined,
imageURL: await fetchCoverArt(release_group_mbid, origin_url),
trackURL: recording_mbid ? url(`/track/${recording_mbid}`) : undefined,
albumURL: release_group_mbid
? url(`/release-group/${release_group_mbid}`)
@ -39,8 +71,14 @@ async function getUrls(additionalInfo: Record<string, string> | undefined, track
// 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}`);
let rawQuery = `artist:"${artistName}" AND recording:"${trackName}"`;
if (releaseName)
rawQuery += ` AND album:"${releaseName}"`;
const query = encodeURIComponent(rawQuery);
if (metadataCache.has(query)) {
return metadataCache.get(query) ?? {};
}
const params = new URLSearchParams({
fmt: "json",
@ -54,27 +92,32 @@ async function getUrls(additionalInfo: Record<string, string> | undefined, track
.then(json => json.recordings?.[0]);
if (!metadata) {
return {};
const data = additionalInfo?.origin_url ? { imageURL: fallbackToYoutubeThumbnail(additionalInfo.origin_url) } : {};
metadataCache.set(query, data);
return data;
}
const artist = metadata["artist-credit"]?.[0]?.artist;
const release = metadata.releases?.[0];
return {
imageURL: release?.["release-group"] ? await fetchCoverArt(release["release-group"].id) : undefined,
const data: Partial<TrackData> = {
imageURL: await fetchCoverArt(release?.["release-group"]?.id, additionalInfo?.origin_url),
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,
};
metadataCache.set(query, data);
return data;
}
export const ListenBrainzScrobbler: ScrobblerBackend = {
name: "ListenBrainz",
id: "listenbrainz",
async fetchTrackData(username: string, _apiKey?: string): Promise<TrackData | null> {
async fetchTrackData(): Promise<TrackData | null> {
try {
const res = await fetch(`https://api.listenbrainz.org/1/user/${username}/playing-now`);
const res = await fetch(apiUrl(`/1/user/${settings.store.username}/playing-now`));
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json().then(json => json.payload?.listens[0]);

View file

@ -98,6 +98,8 @@ const UserContext: NavContextMenuPatchCallback = (children, { user, guildId }: U
if (!user) return;
const memberAvatar = GuildMemberStore.getMember(guildId!, user.id)?.avatar || null;
const avatarDecoration = user.avatarDecorationData ?? user.avatarDecoration;
children.splice(-1, 0, (
<Menu.MenuGroup>
<Menu.MenuItem
@ -119,6 +121,18 @@ const UserContext: NavContextMenuPatchCallback = (children, { user, guildId }: U
icon={ImageIcon}
/>
)}
{avatarDecoration && (
<Menu.MenuItem
id="view-avatar-decoration"
label="View Avatar Decoration"
action={() => openAvatar(IconUtils.getAvatarDecorationURL({
avatarDecoration,
size: 1024,
canAnimate: true
})!)}
icon={ImageIcon}
/>
)}
</Menu.MenuGroup>
));
};
@ -179,7 +193,7 @@ const GroupDMContext: NavContextMenuPatchCallback = (children, { channel }: Grou
export default definePlugin({
name: "ViewIcons",
authors: [Devs.Ven, Devs.TheKodeToad, Devs.Nuckyz, Devs.nyx],
description: "Makes avatars and banners in user profiles clickable, adds View Icon/Banner entries in the user, server and group channel context menu.",
description: "Makes avatars and banners in user profiles clickable, adds View Icon/Banner/Avatar Decoration entries in the user, server and group channel context menu.",
tags: ["Media", "Servers", "Appearance"],
searchTerms: ["ImageUtilities"],
dependencies: ["DynamicImageModalAPI"],

43
src/utils/TTLMap.ts Normal file
View file

@ -0,0 +1,43 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/**
* A Map whose entries expire after a given amount of time. When an entry expires, it is automatically removed from the map and an optional callback is called.
*/
export class TTLMap<K, V> extends Map<K, V> {
private readonly _timers = new Map<K, NodeJS.Timeout>();
public constructor(public readonly expiryMs: number, private readonly onExpire?: (key: K, value: V) => void) {
super();
}
public set(key: K, value: V) {
const timeoutId = setTimeout(() => {
this.delete(key);
this.onExpire?.(key, value);
}, this.expiryMs);
this._timers.set(key, timeoutId);
return super.set(key, value);
}
public delete(key: K) {
if (this._timers.has(key)) {
clearTimeout(this._timers.get(key));
this._timers.delete(key);
}
return super.delete(key);
}
clear(): void {
for (const timeoutId of this._timers.values())
clearTimeout(timeoutId);
this._timers.clear();
return super.clear();
}
}

View file

@ -669,6 +669,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
Lunascape: {
name: "Lunascape",
id: 383365021415243776n
},
yuna0x0: {
name: "yuna0x0",
id: 213656926414831616n
}
} satisfies Record<string, Dev>);