Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
maxoliinyk opened this issue Feb 29, 2024 · 7 comments · Fixed by #94
Closed

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

maxoliinyk opened this issue Feb 29, 2024 · 7 comments · Fixed by #94

Comments

@maxoliinyk
Copy link

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

image
@NextFire
Copy link
Owner

NextFire commented Feb 29, 2024

AFAIK #28 (comment) 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
Copy link
Owner

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

@NextFire
Copy link
Owner

Also see discordjs/RPC#149 (comment)

@maxoliinyk
Copy link
Author

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

@NextFire NextFire closed this as not planned Won't fix, can't repro, duplicate, stale Feb 29, 2024
@NextFire NextFire pinned this issue Feb 29, 2024
@towerofnix
Copy link

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
Copy link

Endy3032 commented Aug 9, 2024

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
Copy link

Endy3032 commented Aug 9, 2024

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
#!/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<string, TrackExtras> = 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<number> {
  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<boolean> {
  return run((appName: iTunesAppName) => {
    return Application("System Events").processes[appName].exists();
  }, APP_NAME);
}

function getState(): Promise<string> {
  return run((appName: iTunesAppName) => {
    const music = Application(appName) as unknown as iTunes;
    return music.playerState();
  }, APP_NAME);
}

function getProps(): Promise<iTunesProps> {
  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<TrackExtras> {
  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<TrackExtras> {
  // 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<string | undefined> {
  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<number> {
  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;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants