discord-player-applemusic
Version:
A Apple Music extractor for discord-player
348 lines (343 loc) • 12.5 kB
JavaScript
// src/AppleMusicExtractor.ts
import {
Playlist,
Track,
BaseExtractor
} from "discord-player";
// src/internal/AppleMusicAPI.ts
import { parse } from "node-html-parser";
// src/internal/helper.ts
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.49";
var appleMusicSongRegex = /^https?:\/\/music\.apple\.com\/.+?\/(song\/.+|album\/.+?\?i=)([0-9]+)$/;
var appleMusicAlbumRegex = /^https?:\/\/music\.apple\.com\/.+?\/album\/.+\/([0-9]+)$/;
var appleMusicPlaylistRegex = /^https?:\/\/music\.apple\.com\/.+?\/playlist\/.+\/pl\.(u-|pm-)?[a-zA-Z0-9]+$/;
var isUrl = (url) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
// src/internal/AppleMusicAPI.ts
function getHTML(link) {
return fetch(link, {
headers: {
"User-Agent": UA
}
}).then((r) => r.text()).then(
(txt) => parse(txt),
() => null
);
}
function makeImage({
height,
url,
width,
ext = "jpg"
}) {
return url.replace("{w}", `${width}`).replace("{h}", `${height}`).replace("{f}", ext);
}
function parseDuration(d) {
const r = (name, unit) => `((?<${name}>-?\\d*[\\.,]?\\d+)${unit})?`;
const regex = new RegExp(
[
"(?<negative>-)?P",
r("years", "Y"),
r("months", "M"),
r("weeks", "W"),
r("days", "D"),
"(T",
r("hours", "H"),
r("minutes", "M"),
r("seconds", "S"),
")?"
// end optional time
].join("")
);
const test = regex.exec(d);
if (!test || !test.groups) return "0:00";
const dur = [
test.groups.years,
test.groups.months,
test.groups.weeks,
test.groups.days,
test.groups.hours,
test.groups.minutes,
test.groups.seconds
];
return dur.filter((item, i, a) => !!item || i > a.length - 2).map((m, i) => {
if (!m) m = "0";
return i < 1 ? m : m.padStart(2, "0");
}).join(":") || "0:00";
}
var AppleMusic = class _AppleMusic {
constructor() {
return _AppleMusic;
}
/**
* Search for tracks on Apple Music
* @param query - The search query
* @returns An array of track objects matching the search query
*/
static async search(query) {
try {
const url = `https://music.apple.com/us/search?term=${encodeURIComponent(
query
)}`;
const node = await getHTML(url);
if (!node) return [];
const rawData = node.getElementById("serialized-server-data");
if (!rawData) return [];
const data = JSON.parse(rawData.innerText)[0].data.sections;
const tracks = data.find((s) => s.itemKind === "trackLockup")?.items;
if (!tracks) return [];
return tracks.map((track) => ({
id: track.contentDescriptor.identifiers.storeAdamID,
duration: track.duration || "0:00",
title: track.title,
url: track.contentDescriptor.url,
thumbnail: track?.artwork?.dictionary ? makeImage({
url: track.artwork.dictionary.url,
height: track.artwork.dictionary.height,
width: track.artwork.dictionary.width
}) : "https://music.apple.com/assets/favicon/favicon-180.png",
artist: track.subtitleLinks?.[0]?.title ?? "Unknown Artist"
}));
} catch (e) {
return [];
}
}
/**
* Gets information about a song from its Apple Music URL
* @param link - The Apple Music song URL
* @returns The song information object or null if not found
*/
static async getSongInfo(link) {
const url = new URL(link);
let id, name;
if (url.pathname.includes("/album/")) {
id = url.searchParams.get("i");
name = url.pathname.split("album/")[1]?.split("/")[0];
} else if (url.pathname.includes("/song/")) {
const parts = url.pathname.split("/song/")[1]?.split("/");
name = parts?.[0];
id = parts?.[1];
}
const songURL = `https://music.apple.com/us/song/${name}/${id}`;
if (!id || !name) return null;
const res = await getHTML(songURL);
if (!res) return null;
try {
const metaTags = res.getElementsByTagName("meta");
if (!metaTags.length) return null;
const title = metaTags.find((r) => r.getAttribute("name") === "apple:title")?.getAttribute("content") || res.querySelector("title")?.innerText || name;
const contentId = metaTags.find((r) => r.getAttribute("name") === "apple:content_id")?.getAttribute("content") || id;
const durationRaw = metaTags.find((r) => r.getAttribute("property") === "music:song:duration")?.getAttribute("content");
const song = {
id: contentId,
duration: durationRaw ? parseDuration(durationRaw) : metaTags.find((m) => m.getAttribute("name") === "apple:description")?.textContent.split("Duration: ")?.[1].split('"')?.[0] || "0:00",
title: title || "Unknown Title",
url: songURL,
thumbnail: metaTags.find(
(r) => ["og:image:secure_url", "og:image"].includes(
r.getAttribute("property")
)
)?.getAttribute("content") || "https://music.apple.com/assets/favicon/favicon-180.png",
artist: res.querySelector(".song-subtitles__artists>a")?.textContent?.trim() || "Apple Music"
};
return song;
} catch (err) {
return null;
}
}
/**
* Gets information about a playlist from its Apple Music URL
* @param link - The Apple Music playlist URL
* @returns The playlist information object or null if not found
*/
static async getPlaylistInfo(link) {
const root = await getHTML(link);
if (!root) return null;
const titleElement = root.querySelector('meta[property="og:title"]');
const title = titleElement ? titleElement.getAttribute("content") : "Unknown Title";
const descriptionElement = root.querySelector('meta[property="og:description"]');
const description = descriptionElement ? descriptionElement.getAttribute("content") : "No Description";
const artworkElement = root.querySelector('meta[property="og:image"]');
const artwork = artworkElement ? artworkElement.getAttribute("content") : "";
const tracks = [];
const jsonLdElement = root.querySelector("script#schema\\:music-playlist");
let artistNames = [];
const serializedScript = root.querySelector("script#serialized-server-data");
if (serializedScript) {
try {
const jsonData = JSON.parse(serializedScript.text);
artistNames = jsonData[0].data.sections.find((section) => section.id.includes("track-list")).items.filter((item) => item.id.includes("track-lockup")).map((i) => i.artistName);
} catch (error) {
}
}
if (jsonLdElement) {
const jsonLd = JSON.parse(jsonLdElement.text);
if (jsonLd && jsonLd.track) {
jsonLd.track.forEach((track, index) => {
const artist = artistNames[index] || track.byArtist?.name || "Unknown Artist";
tracks.push({
id: track.url.split("/").pop() || "",
title: track.name || "Unknown Title",
artist,
duration: parseDuration(track.duration || "PT0S"),
url: track.url || "",
thumbnail: "https://music.apple.com/assets/favicon/favicon-180.png"
// Default thumbnail
});
});
}
}
return {
title: title || "Unknown Album",
description,
artwork: artwork || "https://music.apple.com/assets/favicon/favicon-180.png",
tracks,
url: link
};
}
/**
* Gets information about an album from its Apple Music URL
* @param link - The Apple Music album URL
* @returns The album information object or null if not found
*/
static async getAlbumInfo(link) {
const root = await getHTML(link.split("?")[0]);
if (!root) return null;
try {
const titleElement = root.querySelector('meta[name="apple:title"]');
const title = titleElement ? titleElement.getAttribute("content") : "Unknown Album";
const artworkElement = root.querySelector('meta[property="og:image"]');
const thumbnail = artworkElement ? artworkElement.getAttribute("content") : "https://music.apple.com/assets/favicon/favicon-180.png";
const tracks = [];
const jsonLdElement = root.querySelector("script#schema\\:music-album");
let artistName = "Unknown Artist";
if (jsonLdElement) {
const jsonLd = JSON.parse(jsonLdElement.text);
artistName = jsonLd.byArtist[0].name;
if (jsonLd && jsonLd.tracks) {
jsonLd.tracks.forEach((track) => {
tracks.push({
id: track.url.split("/").pop() || "",
duration: parseDuration(track.duration || "PT0S"),
title: track.name || "Unknown Title",
url: track.url || "",
thumbnail: thumbnail || "https://music.apple.com/assets/favicon/favicon-180.png",
artist: artistName
});
});
}
}
return {
id: root.querySelector('meta[name="apple:content_id"]')?.getAttribute("content") || "",
title: title || "Unknown Album",
artwork: thumbnail || "https://music.apple.com/assets/favicon/favicon-180.png",
artist: artistName,
url: link,
tracks,
description: title
};
} catch (err) {
return null;
}
}
};
// src/AppleMusicExtractor.ts
var AppleMusicExtractor = class extends BaseExtractor {
async activate() {
this.protocols = ["amsearch", "applemusic"];
const fn = this.options.createStream;
if (typeof fn === "function")
this._stream = (q, t) => fn(this, q, t);
}
async deactivate() {
this.protocols = [];
}
async validate(query) {
return !isUrl(query) || [appleMusicAlbumRegex, appleMusicPlaylistRegex, appleMusicSongRegex].some((regex) => regex.test(query));
}
buildTrack(trackInfo, context, playlist) {
const track = new Track(this.context.player, {
author: trackInfo.artist,
description: trackInfo.title,
duration: trackInfo.duration,
thumbnail: trackInfo.thumbnail,
title: trackInfo.title,
url: trackInfo.url,
views: 0,
source: "apple_music",
requestedBy: context.requestedBy,
metadata: {
source: trackInfo,
bridge: null
},
requestMetadata: async () => ({
source: trackInfo,
bridge: null
}),
playlist
});
track.extractor = this;
return track;
}
buildPlaylist(data, context, type) {
const playlist = new Playlist(this.context.player, {
author: {
name: type === "album" ? data.artist : data.description,
url: ""
},
description: data.description || data.title,
id: data.id || "",
source: "apple_music",
thumbnail: data.artwork,
title: data.title,
tracks: [],
type,
url: data.url || "",
rawPlaylist: data
});
playlist.tracks = data.tracks.map((track) => this.buildTrack(track, context, playlist));
return playlist;
}
async handle(query, context) {
if (appleMusicAlbumRegex.test(query)) {
const info = await AppleMusic.getAlbumInfo(query);
if (!info) return this.createResponse();
const playlist = this.buildPlaylist(info, context, "album");
return this.createResponse(playlist, playlist.tracks);
}
if (appleMusicPlaylistRegex.test(query)) {
const info = await AppleMusic.getPlaylistInfo(query);
if (!info) return this.createResponse();
const playlist = this.buildPlaylist(info, context, "playlist");
return this.createResponse(playlist, playlist.tracks);
}
if (appleMusicSongRegex.test(query)) {
const info = await AppleMusic.getSongInfo(query);
if (!info) return this.createResponse();
return this.createResponse(null, [this.buildTrack(info, context)]);
}
const data = await AppleMusic.search(query);
if (!data?.length) return this.createResponse();
return this.createResponse(null, data.map((track) => this.buildTrack(track, context)));
}
async stream(info) {
if (this._stream) {
const stream = await this._stream(info.url, info);
if (typeof stream === "string") return stream;
return stream;
}
const result = await this.context.requestBridge(info, this);
if (!result?.result) throw new Error("Could not bridge this track");
return result.result;
}
};
AppleMusicExtractor.identifier = "com.discord-player.applemusicextractor";
export {
AppleMusicExtractor
};