Téléverser les fichiers vers "/"
This commit is contained in:
commit
41404dbc97
20
README.md
Normal file
20
README.md
Normal file
@ -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
|
||||
|
||||
<img width="364" alt="Photo de démo" src="https://cdn.discordapp.com/attachments/1014569055593701437/1226532199357087837/Capture_decran_2024-04-07_a_16.00.48.png?ex=66251c20&is=6612a720&hm=6de24bc6c84ade53a5d1bba1e97d77e57224b1c775da223f1c806dc806f79b6f&">
|
||||
|
||||
|
||||
#### Installation
|
||||
|
||||
Installer l'outil [Deno](https://deno.land), clonner le repo, puis executer[`install.sh`](/scripts/install.sh)
|
||||
|
345
music-rpc.ts
Normal file
345
music-rpc.ts
Normal file
@ -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<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("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<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;
|
||||
}
|
||||
|
||||
|
||||
async function _getTrackExtras(
|
||||
song: string,
|
||||
artist: string,
|
||||
album: string
|
||||
): Promise<TrackExtras> {
|
||||
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<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 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;
|
||||
}
|
26
renovate.json
Normal file
26
renovate.json
Normal file
@ -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 .*\"(?<depName>https:\\/\\/.*)@(?<currentValue>[^\\/]*)\\/.*\";"
|
||||
],
|
||||
"datasourceTemplate": "deno"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user