NextFire / apple-music-discord-rpc

🎵 Discord Rich Presence Client for the macOS Apple Music app and iTunes
MIT License
598 stars 39 forks source link

Suggestion: Change "Playing a game" to "Listening to" #85

Closed maxoliinyk closed 9 months ago

maxoliinyk commented 9 months ago

There are some Apple Music RPCs that show "Listening to Apple Music", and it makes more sense than "Playing Apple Music"

image
NextFire commented 9 months ago

AFAIK https://github.com/NextFire/apple-music-discord-rpc/issues/28#issuecomment-1185226351 and https://discord.com/developers/docs/game-sdk/activities#data-models-activitytype-enum

ActivityType is strictly for the purpose of handling events that you receive from Discord; though the SDK/our API will not reject a payload with an ActivityType sent, it will be discarded and will not change anything in the client.

Either this person is not using the RPC protocol, or is using an unofficial endpoint, or is a selfbot. The second and last ones do not comply with Discord's ToS.

NextFire commented 9 months ago

I tried to set type: 2 and it still doesn't work

NextFire commented 9 months ago

Also see https://github.com/discordjs/RPC/pull/149#issuecomment-907485454

maxoliinyk commented 9 months ago

Well, that's unfortunate

Btw, that person uses Cider, and it's closed-source sadly, so no way to know how it works that way

towerofnix commented 3 months ago

It's perhaps possible to review the behavior in RicherCider-Vencord ? Um, I think it's a client-side patch though LOL... Just, like, guessing by these bits mostly.

That probably means whoever took the screenshot (maybe you/OP?) is using that plugin, or similar. Here it's hard-coded to a set of app IDs. I don't know how this repo sets up its rich presence but you could probably do a similar client-side patch by, uh, just checking if the message is something like "Apple Music". And of course, it would only affect your client (and other people using the same plugin).

Endy3032 commented 3 months ago

Also see discordjs/RPC#149 (comment)

activity types were just supported recently, see this comment from the same issue thread and advaith's tweet

edit: i tried editing the script directly to use npm:@xhayper/discord-rpc with some minor adjustments, listening status works but seems like end timestamp is not yet supported image

Endy3032 commented 3 months ago

for anyone who wants to use this early, here you go :)
just edit the file directly and run brew services restart apple-music-discord-rpc

music-rpc.ts ```ts #!/usr/bin/env deno run --allow-env --allow-run --allow-net --allow-read --allow-write --unstable-ffi --allow-ffi import { Client, SetActivity } from "npm:@xhayper/discord-rpc"; import type {} from "https://raw.githubusercontent.com/NextFire/jxa/v0.0.5/run/global.d.ts"; import { run } from "https://raw.githubusercontent.com/NextFire/jxa/v0.0.5/run/mod.ts"; import type { iTunes } from "https://raw.githubusercontent.com/NextFire/jxa/v0.0.5/run/types/core.d.ts"; // Cache class Cache { static VERSION = 5; static CACHE_FILE = "cache.json"; static #data: Map = new Map(); static get(key: string) { return this.#data.get(key); } static set(key: string, value: TrackExtras) { this.#data.set(key, value); this.saveCache(); } static async loadCache() { try { const text = await Deno.readTextFile(this.CACHE_FILE); const data = JSON.parse(text); if (data.version !== this.VERSION) throw new Error("Old cache"); this.#data = new Map(data.data); } catch (err) { console.error( err, `No valid ${this.CACHE_FILE} found, generating a new cache...` ); } } static async saveCache() { try { await Deno.writeTextFile( this.CACHE_FILE, JSON.stringify({ version: this.VERSION, data: Array.from(this.#data.entries()), }) ); } catch (err) { console.error(err); } } } // Main part const MACOS_VER = await getMacOSVersion(); const IS_APPLE_MUSIC = MACOS_VER >= 10.15; const APP_NAME: iTunesAppName = IS_APPLE_MUSIC ? "Music" : "iTunes"; const CLIENT_ID = IS_APPLE_MUSIC ? "773825528921849856" : "979297966739300416"; const DEFAULT_TIMEOUT = 15e3; start(); async function start() { await Cache.loadCache(); const rpc = new Client({ clientId: CLIENT_ID }); while (true) { try { await main(rpc); } catch (err) { console.error(err); await new Promise((resolve) => setTimeout(resolve, DEFAULT_TIMEOUT)); } } } async function main(rpc: Client) { await rpc.connect(); console.log(rpc); while (true) { const timeout = await setActivity(rpc); await new Promise((resolve) => setTimeout(resolve, timeout)); } } // macOS/JXA functions async function getMacOSVersion(): Promise { const cmd = new Deno.Command("sw_vers", { args: ["-productVersion"] }); const output = await cmd.output(); const decoded = new TextDecoder().decode(output.stdout); const version = parseFloat(decoded.match(/\d+\.\d+/)![0]); return version; } function isOpen(): Promise { return run((appName: iTunesAppName) => { return Application("System Events").processes[appName].exists(); }, APP_NAME); } function getState(): Promise { return run((appName: iTunesAppName) => { const music = Application(appName) as unknown as iTunes; return music.playerState(); }, APP_NAME); } function getProps(): Promise { return run((appName: iTunesAppName) => { const music = Application(appName) as unknown as iTunes; return { ...music.currentTrack().properties(), playerPosition: music.playerPosition(), }; }, APP_NAME); } async function getTrackExtras(props: iTunesProps): Promise { const { name, artist, album } = props; const cacheIndex = `${name} ${artist} ${album}`; let infos = Cache.get(cacheIndex); if (!infos) { infos = await _getTrackExtras(name, artist, album); Cache.set(cacheIndex, infos); } return infos; } // iTunes Search API async function _getTrackExtras( song: string, artist: string, album: string ): Promise { // Asterisks tend to result in no songs found, and songs are usually able to be found without it const query = `${song} ${artist} ${album}`.replace("*", ""); const params = new URLSearchParams({ media: "music", entity: "song", term: query, }); const resp = await fetch(`https://itunes.apple.com/search?${params}`); const json: iTunesSearchResponse = await resp.json(); let result: iTunesSearchResult | undefined; if (json.resultCount === 1) { result = json.results[0]; } else if (json.resultCount > 1) { // If there are multiple results, find the right album // Use includes as imported songs may format it differently // Also put them all to lowercase in case of differing capitalisation result = json.results.find( (r) => r.collectionName.toLowerCase().includes(album.toLowerCase()) && r.trackName.toLowerCase().includes(song.toLowerCase()) ); } else if (album.match(/\(.*\)$/)) { // If there are no results, try to remove the part // of the album name in parentheses (e.g. "Album (Deluxe Edition)") return await _getTrackExtras( song, artist, album.replace(/\(.*\)$/, "").trim() ); } const artworkUrl = result?.artworkUrl100 ?? (await _getMBArtwork(artist, song, album)) ?? null; const iTunesUrl = result?.trackViewUrl ?? null; return { artworkUrl, iTunesUrl }; } // MusicBrainz Artwork Getter const MB_EXCLUDED_NAMES = ["", "Various Artist"]; const luceneEscape = (term: string) => term.replace(/([+\-&|!(){}\[\]^"~*?:\\])/g, "\\$1"); const removeParenthesesContent = (term: string) => term.replace(/\([^)]*\)/g, "").trim(); async function _getMBArtwork( artist: string, song: string, album: string ): Promise { const queryTerms = []; if (!MB_EXCLUDED_NAMES.every((elem) => artist.includes(elem))) { queryTerms.push( `artist:"${luceneEscape(removeParenthesesContent(artist))}"` ); } if (!MB_EXCLUDED_NAMES.every((elem) => album.includes(elem))) { queryTerms.push(`release:"${luceneEscape(album)}"`); } else { queryTerms.push(`recording:"${luceneEscape(song)}"`); } const query = queryTerms.join(" "); const params = new URLSearchParams({ fmt: "json", limit: "10", query, }); let resp: Response; let result: string | undefined; resp = await fetch(`https://musicbrainz.org/ws/2/release?${params}`); const json: MBReleaseLookupResponse = await resp.json(); for (const release of json.releases) { resp = await fetch( `https://coverartarchive.org/release/${release.id}/front` ); if (resp.ok) { result = resp.url; break; } } return result; } // Activity setter async function setActivity(rpc: Client): Promise { const open = await isOpen(); console.log("isOpen:", open); if (!open) { await rpc.user?.clearActivity(); return DEFAULT_TIMEOUT; } const state = await getState(); console.log("state:", state); switch (state) { case "playing": { const props = await getProps(); console.log("props:", props); let delta; let end; if (props.duration) { delta = (props.duration - props.playerPosition) * 1000; end = Math.ceil(Date.now() + delta); } // EVERYTHING must be less than or equal to 128 chars long const activity: SetActivity = { details: formatStr(props.name), endTimestamp: end, largeImageKey: "appicon", }; if (props.artist.length > 0) { activity.state = formatStr(`by ${props.artist}`); } // album.length == 0 for radios if (props.album.length > 0) { const buttons: SetActivity["buttons"] = []; const infos = await getTrackExtras(props); console.log("infos:", infos); activity.largeImageKey = infos.artworkUrl ?? "appicon" activity.largeImageText = formatStr(props.album) ?? "" if (infos.iTunesUrl) { buttons.push({ label: "Play on Apple Music", url: infos.iTunesUrl, }); } const query = encodeURIComponent( `artist:${props.artist} track:${props.name}` ); const spotifyUrl = `https://open.spotify.com/search/${query}?si`; if (spotifyUrl.length <= 512) { buttons.push({ label: "Search on Spotify", url: spotifyUrl, }); } if (buttons.length > 0) activity.buttons = buttons; } await rpc.user?.setActivity({ ...activity, type: 2 }); return Math.min((delta ?? DEFAULT_TIMEOUT) + 1000, DEFAULT_TIMEOUT); } case "paused": case "stopped": { await rpc.user?.clearActivity(); return DEFAULT_TIMEOUT; } default: throw new Error(`Unknown state: ${state}`); } } /** * Format string to specified char limits. * Will output the string with 3 chars at the end replaced by '...'. * @param s string * @param minLength * @param maxLength * @returns Formatted string */ function formatStr(s: string, minLength = 2, maxLength = 128) { return s.length <= maxLength ? s.padEnd(minLength) : `${s.slice(0, maxLength - 3)}...`; } // TypeScript type iTunesAppName = "iTunes" | "Music"; interface iTunesProps { id: number; name: string; artist: string; album: string; year: number; duration?: number; playerPosition: number; } interface TrackExtras { artworkUrl: string | null; iTunesUrl: string | null; } interface iTunesSearchResponse { resultCount: number; results: iTunesSearchResult[]; } interface iTunesSearchResult { trackName: string; collectionName: string; artworkUrl100: string; trackViewUrl: string; } interface MBReleaseLookupResponse { releases: MBRelease[]; } interface MBRelease { id: string; } ```