#!/usr/bin/env deno run --unstable --allow-env --allow-run --allow-net --allow-read --allow-write --allow-ffi import type { Activity } from "https://deno.land/x/discord_rpc@0.3.2/mod.ts"; import { Client } from "https://deno.land/x/discord_rpc@0.3.2/mod.ts"; import type {} from "https://raw.githubusercontent.com/NextFire/jxa/v0.0.4/run/global.d.ts"; import { run } from "https://raw.githubusercontent.com/NextFire/jxa/v0.0.4/run/mod.ts"; import type { iTunes } from "https://raw.githubusercontent.com/NextFire/jxa/v0.0.4/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("Cache dépassé"); this.#data = new Map(data.data); } catch (err) { console.error( err, `Aucun ${this.CACHE_FILE} valide trouvé, génération en cours...` ); } } 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); } } } // Partie principale 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"; start(); async function start() { await Cache.loadCache(); main(); } async function main() { try { const rpc = new Client({ id: CLIENT_ID }); await rpc.connect(); console.log(rpc); const timer = setInterval(async () => { try { await setActivity(rpc); } catch (err) { console.error(err); clearInterval(timer); rpc.close(); main(); } }, 15e3); } catch (err) { console.error(err); setTimeout(main, 15e3); } } 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; } async function _getTrackExtras( song: string, artist: string, album: string ): Promise { 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) { // S'il y a plusieurs résultats, trouver le bon album // Mettre tous en minuscules en cas de différences de capitalisation result = json.results.find( (r) => r.collectionName.toLowerCase().includes(album.toLowerCase()) && r.trackName.toLowerCase().includes(song.toLowerCase()) ); } else if (album.match(/\(.*\)$/)) { // S'il n'y a pas de résultats, essayer de supprimer la partie du nom de l'album entre parenthèses (par exemple "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 definer async function setActivity(rpc: Client) { const open = await isOpen(); console.log("isOpen:", open); if (!open) { await rpc.clearActivity(); return; } const state = await getState(); console.log("state:", state); switch (state) { case "playing": { const props = await getProps(); console.log("props:", props); let end; if (props.duration) { const delta = (props.duration - props.playerPosition) * 1000; end = Math.ceil(Date.now() + delta); } const activity: Activity = { details: formatStr(props.name), timestamps: { end }, assets: { large_image: "appicon" }, }; if (props.artist.length > 0) { activity.state = formatStr(props.artist); } if (props.album.length > 0) { const buttons = []; const infos = await getTrackExtras(props); console.log("infos:", infos); activity.assets = { large_image: infos.artworkUrl ?? "appicon", large_text: 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.setActivity(activity); break; } case "paused": case "stopped": { await rpc.clearActivity(); break; } } } function formatStr(s: string, minLength = 2, maxLength = 128) { return s.length <= maxLength ? s.padEnd(minLength) : `${s.slice(0, maxLength - 3)}...`; } 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; }