]*title="([^"]*)"/i),
artist: pick(/
\s*([\s\S]*?)<\/span>\s*<\/p>/i),
album: null,
duration: null,
cover: pick(/aplmate-downloader-left[\s\S]*?
]*src="([^"]+)"/i),
};
}
const parseHeading = (html) =>
decodeEntities((html.match(/]*>\s*
]*title="([^"]*)"/i) || [])[1] ?? "") || null;
/* ------------------------------ client ------------------------------ */
export class AplMate {
/**
* @param {object} o
* @param {string} [o.lang="en"]
* @param {string} [o.turnstile] token cf-turnstile-response (default: env APLMATE_TURNSTILE)
* @param {string} [o.cookie] header Cookie mentah (mis. "cf_clearance=...")
* @param {string} [o.userAgent] samakan dengan UA browser kalau pakai cf_clearance
* @param {number} [o.delay=5000] jeda antar request /action/track (ms)
* @param {number} [o.timeout=30000]
*/
constructor(o = {}) {
this.lang = o.lang ?? "en";
this.turnstile = o.turnstile ?? process.env.APLMATE_TURNSTILE ?? null;
this.cookie = o.cookie ?? process.env.APLMATE_COOKIE ?? null;
this.userAgent = o.userAgent ?? process.env.APLMATE_UA ?? DEFAULT_UA;
this.delay = o.delay ?? 5000;
this.timeout = o.timeout ?? 30000;
this.jar = new Map();
}
#cookieHeader() {
const parts = [...this.jar].map(([k, v]) => `${k}=${v}`);
if (this.cookie) parts.unshift(this.cookie);
return parts.join("; ");
}
#storeCookies(res) {
for (const c of res.headers.getSetCookie?.() ?? []) {
const [kv] = c.split(";");
const i = kv.indexOf("=");
if (i > 0) this.jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
}
}
async #post(path, body, extra = {}) {
const headers = {
"user-agent": this.userAgent,
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
origin: BASE,
referer: `${BASE}/`,
...extra,
};
const ck = this.#cookieHeader();
if (ck) headers.cookie = ck;
const res = await fetch(BASE + path, {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(this.timeout),
});
this.#storeCookies(res);
return { res, text: await res.text() };
}
#json({ res, text }) {
try {
return JSON.parse(text);
} catch {
const blocked = res.status === 403 || /just a moment|cloudflare/i.test(text);
throw new AplMateError(
blocked
? `Diblokir Cloudflare (HTTP ${res.status}). Pakai --cookie (cf_clearance) dan --ua dari browser yang sama.`
: `Respons bukan JSON (HTTP ${res.status}).`,
{ code: blocked ? "CLOUDFLARE_BLOCKED" : "BAD_RESPONSE", status: res.status }
);
}
}
#fail(j) {
const raw = j.error ?? j.message ?? j.msg ?? j.code ?? "";
const key = typeof raw === "string" ? raw : "";
const msg = SERVER_MESSAGES[key] ?? (key ? stripTags(key) : "Server menolak request.");
return new AplMateError(msg, { code: key || "SERVER_ERROR", raw: j });
}
/** Minta token sesi dari endpoint userverify milik situs (dipakai browser untuk link). */
async verify(url) {
try {
const r = await this.#post(
"/action/userverify",
new URLSearchParams({ url, lang: this.lang }),
{ "x-requested-with": "XMLHttpRequest" }
);
return r.text.match(/JWT\.[A-Za-z0-9_.-]+/)?.[0] ?? null;
} catch {
return null;
}
}
/** Link -> HTML daftar track (atau kartu download langsung). */
async lookup(input) {
const token = (await this.verify(input)) ?? this.turnstile;
if (!token) {
throw new AplMateError(
"Token Turnstile dibutuhkan. Ambil dari browser lalu kirim lewat --turnstile atau env APLMATE_TURNSTILE (lihat README).",
{ code: "TURNSTILE_REQUIRED" }
);
}
for (let attempt = 0; attempt < 2; attempt++) {
const fd = new FormData();
fd.append("lang", this.lang);
fd.append("url", input);
fd.append("cf-turnstile-response", token);
const j = this.#json(await this.#post("/action", fd));
if (j.success && typeof j.html === "string") return { html: j.html };
const err = this.#fail(j);
if (attempt === 0 && /rate_limit|too fast/i.test(`${err.code} ${err.message}`)) {
await sleep(5000);
continue;
}
throw err;
}
}
/** Satu track (form hidden hasil parseTracks) -> link download. */
async resolve(form) {
for (let attempt = 0; attempt < 2; attempt++) {
const fd = new FormData();
fd.append("lang", this.lang);
for (const k of ["data", "base", "token"]) fd.append(k, form[k] ?? "");
const j = this.#json(await this.#post("/action/track", fd));
const html = typeof j.html === "string" ? j.html : typeof j.data === "string" ? j.data : "";
if (j.success !== false && html) {
const dl = parseDownloads(html);
if (dl.mp3 || dl.cover_hd) return dl;
throw new AplMateError("Link download tidak ditemukan pada respons.", { code: "NO_LINK", raw: j });
}
const err = this.#fail(j);
if (attempt === 0 && /rate_limit|too fast/i.test(`${err.code} ${err.message}`)) {
await sleep(5000);
continue;
}
throw err;
}
}
}
/* ------------------------------ main API ------------------------------ */
/**
* @param {string} input link Apple Music (lagu / album / playlist)
* @param {object} [opts] { resolve: 1 | "all", ...opsi AplMate }
*/
export async function aplmate(input, opts = {}) {
input = String(input ?? "").trim();
if (!/^https?:\/\/(?:embed\.)?music\.apple\.com\//i.test(input)) {
throw new AplMateError("Input harus berupa link Apple Music (https://music.apple.com/...).", { code: "INVALID_LINK" });
}
const client = new AplMate(opts);
const { html } = await client.lookup(input);
const parsed = parseTracks(html);
const heading = parseHeading(html);
let results;
let resolved = 0;
if (parsed.length) {
results = parsed.map((t, i) => ({
index: i + 1,
id: t.meta.id ?? null,
title: t.meta.name ?? null,
artist: t.meta.artist ?? null,
album: t.meta.album ?? null,
duration: t.meta.duration ?? null,
cover: t.meta.cover ?? null,
url: t.meta.surl ?? t.form.base ?? null,
download: null,
}));
const want = opts.resolve === "all" ? results.length : Math.max(0, Math.min(Number(opts.resolve ?? 1) || 0, results.length));
for (let i = 0; i < want; i++) {
if (i > 0) await sleep(client.delay);
opts.onProgress?.(`[${i + 1}/${want}] ${results[i].title} - ${results[i].artist}`);
try {
results[i].download = await client.resolve(parsed[i].form);
resolved++;
} catch (e) {
results[i].error = e.message;
}
}
} else {
// Respons langsung berupa kartu download
const download = parseDownloads(html);
if (!download.mp3 && !download.cover_hd) {
throw new AplMateError("Tidak ada hasil untuk input tersebut.", { code: "NO_RESULTS" });
}
results = [{ index: 1, ...parseCard(html), url: input, download }];
resolved = 1;
}
return {
status: true,
input,
heading,
total: results.length,
resolved,
results,
};
}
export default aplmate;
/* ------------------------------ CLI ------------------------------ */
const HELP = `aplmate.js - Apple Music downloader wrapper (aplmate.com)
Pemakaian:
node aplmate.js [opsi]
Opsi:
-r, --resolve Album/playlist: jumlah track yang diambil link downloadnya (default: 1)
-t, --turnstile Token cf-turnstile-response (hanya jika situs memintanya)
-c, --cookie Header Cookie mentah, mis. "cf_clearance=..." (env APLMATE_COOKIE)
--ua User-Agent (env APLMATE_UA)
-l, --lang Bahasa situs (default: en)
-d, --delay Jeda antar track saat resolve banyak (default: 5000)
-o, --out Simpan JSON ke file
-h, --help Bantuan
`;
function parseArgs(argv) {
const o = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
if (a === "-r" || a === "--resolve") o.resolve = next();
else if (a === "-t" || a === "--turnstile") o.turnstile = next();
else if (a === "-c" || a === "--cookie") o.cookie = next();
else if (a === "--ua") o.userAgent = next();
else if (a === "-l" || a === "--lang") o.lang = next();
else if (a === "-d" || a === "--delay") o.delay = Number(next());
else if (a === "-o" || a === "--out") o.out = next();
else if (a === "-h" || a === "--help") o.help = true;
else o._.push(a);
}
return o;
}
async function cli() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args._.length) {
console.log(HELP);
return;
}
const { out, _, help, ...opts } = args;
opts.onProgress = (msg) => console.error(msg);
try {
const result = await aplmate(_.join(" "), opts);
const json = JSON.stringify(result, null, 2);
if (out) await writeFile(out, json + "\n");
console.log(json);
} catch (e) {
console.log(JSON.stringify({ status: false, error: e.message, code: e.code ?? "ERROR" }, null, 2));
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) cli();