From 41404dbc977c8c3c8205c4a01717a266be97f234 Mon Sep 17 00:00:00 2001 From: Ailcope Date: Sun, 7 Apr 2024 15:09:42 +0000 Subject: [PATCH] =?UTF-8?q?T=C3=A9l=C3=A9verser=20les=20fichiers=20vers=20?= =?UTF-8?q?"/"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 +++ music-rpc.ts | 345 ++++++++++++++++++++++++++++++++++++++++++++++++++ renovate.json | 26 ++++ 3 files changed, 391 insertions(+) create mode 100644 README.md create mode 100644 music-rpc.ts create mode 100644 renovate.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..06e9321 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# apple-music-discord-rpc + + +Afficher votre musique en cours de lecture, sur Apple Music, sur Discord. + +## Utilité + +- Peut-être démarré automatiquement. +- Aucune icône dans la barre d'état +- Rich Presence active uniquement en cas de lecture de musique +- Bouton de recherche sur Spotify (Artiste + Titre) +- Cover de l'EP/Album + +Photo de démo + + +#### Installation + +Installer l'outil [Deno](https://deno.land), clonner le repo, puis executer[`install.sh`](/scripts/install.sh) + diff --git a/music-rpc.ts b/music-rpc.ts new file mode 100644 index 0000000..8405d63 --- /dev/null +++ b/music-rpc.ts @@ -0,0 +1,345 @@ +#!/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; +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..a1b6e8f --- /dev/null +++ b/renovate.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "packageRules": [ + { + "matchUpdateTypes": ["patch", "digest"], + "automerge": true, + "automergeType": "branch" + }, + { + "matchUpdateTypes": ["minor"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "automergeType": "branch" + } + ], + "regexManagers": [ + { + "fileMatch": [".*\\.ts$"], + "matchStrings": [ + "import .*\"(?https:\\/\\/.*)@(?[^\\/]*)\\/.*\";" + ], + "datasourceTemplate": "deno" + } + ] +}