/** * SpotubeDL — Node ESM * Spotify metadata (spotubedl) + multi-source audio resolve (no yt-dlp / no ffmpeg / no key) * * node spotubedl.mjs search [track|album|playlist|artist] * node spotubedl.mjs * node spotubedl.mjs download [out.mp3] * node spotubedl.mjs album|playlist */ import crypto from 'node:crypto'; import { writeFile } from 'node:fs/promises'; const BASE = 'https://spotubedl.com'; const te = new TextEncoder(), td = new TextDecoder(); const rand = (n) => crypto.randomBytes(n); const b64 = (b) => Buffer.from(b).toString('base64'); const unb64 = (s) => Buffer.from(String(s).trim(), 'base64'); const hex = (b) => Buffer.from(b).toString('hex'); const cat = (...a) => Buffer.concat(a.map((x) => (Buffer.isBuffer(x) ? x : Buffer.from(x)))); const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b; }; const rpath = () => `/${hex(rand(6))}/${hex(rand(8))}/${hex(rand(6))}`; const rhdr = () => 'x-' + hex(rand(8)); function info(kind, path, rid, ver, exp) { const p = { env: 'spdl-env:1:', req: 'spdl-req:1:', aad: 'spdl-aad:', raqd: 'spdl-req-aad:' }[kind]; if (kind === 'env' || kind === 'req') return cat(te.encode(p), te.encode(path), Buffer.from([0]), rid); return cat(te.encode(p), Buffer.from([ver, 0, 0, 0, 0]), u32(exp), te.encode(path), Buffer.from([0]), rid); } function keyPair() { return crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); } function exportPub(pub) { const j = pub.export({ format: 'jwk' }); return cat(Buffer.from([4]), Buffer.from(j.x, 'base64url'), Buffer.from(j.y, 'base64url')); } function importPub(raw) { if (raw.length !== 65 || raw[0] !== 4) throw new Error('bad pubkey'); return crypto.createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: raw.subarray(1, 33).toString('base64url'), y: raw.subarray(33).toString('base64url') }, format: 'jwk', }); } function shared(priv, peer) { return crypto.diffieHellman({ privateKey: priv, publicKey: importPub(peer) }); } function hkdf(sec, salt, i) { return crypto.hkdfSync('sha256', sec, salt, i, 32); } function gcmEnc(key, iv, pt, aad) { const c = crypto.createCipheriv('aes-256-gcm', key, iv); c.setAAD(aad); return cat(c.update(pt), c.final(), c.getAuthTag()); } function gcmDec(key, iv, ct, aad) { const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAAD(aad); d.setAuthTag(ct.subarray(-16)); return cat(d.update(ct.subarray(0, -16)), d.final()); } function pack(e) { return cat(Buffer.from([e.v, e.f]), u32(e.exp), e.rid, e.salt, e.pub, e.iv, u32(e.ct.length), e.ct); } function unpack(buf) { if (buf.length < 119) throw new Error('envelope short'); let o = 0; const v = buf[o++], f = buf[o++], exp = buf.readUInt32BE(o); o += 4; const rid = buf.subarray(o, (o += 16)); const salt = buf.subarray(o, (o += 16)); const pub = buf.subarray(o, (o += 65)); const iv = buf.subarray(o, (o += 12)); const len = buf.readUInt32BE(o); o += 4; const ct = buf.subarray(o); if (ct.length !== len) throw new Error('ct mismatch'); return { v, f, exp, rid, salt, pub, iv, ct }; } /* endpoints dipecah + di-xor biar gak kebaca string plain di source */ function _x(arr, k = 0x5a) { return String.fromCharCode(...arr.map((c) => c ^ k)); } // "https://loader.to" dll const _EP = [ _x([0x32,0x2e,0x2e,0x2a,0x29,0x60,0x75,0x75,0x36,0x35,0x3b,0x3e,0x3f,0x28,0x74,0x2e,0x35]), _x([0x32,0x2e,0x2e,0x2a,0x29,0x60,0x75,0x75,0x2d,0x2d,0x2d,0x74,0x36,0x35,0x3b,0x3e,0x3f,0x28,0x74,0x2e,0x35]), _x([0x32,0x2e,0x2e,0x2a,0x29,0x60,0x75,0x75,0x2a,0x74,0x29,0x3b,0x2c,0x3f,0x34,0x35,0x2d,0x74,0x2e,0x35]), _x([0x32,0x2e,0x2e,0x2a,0x29,0x60,0x75,0x75,0x3f,0x34,0x74,0x36,0x35,0x3b,0x3e,0x3f,0x28,0x74,0x2e,0x35]), ]; const _PATH = _x([0x75,0x3b,0x30,0x3b,0x22,0x75,0x3e,0x35,0x2d,0x34,0x36,0x35,0x3b,0x3e,0x74,0x2a,0x32,0x2a]); // /ajax/download.php const UA = 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36'; async function resolveFromMirror(youtubeUrl, format = 'mp3') { let last; for (const host of _EP) { try { const url = `${host}${_PATH}?format=${format}&url=${encodeURIComponent(youtubeUrl)}`; const init = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', Referer: host + '/', Origin: host, }, signal: AbortSignal.timeout(25000), }); if (!init.ok) throw new Error(`http ${init.status}`); const j = await init.json(); if (!j.success || !j.progress_url) throw new Error('bad init'); for (let i = 0; i < 40; i++) { await sleep(1100); const pr = await fetch(j.progress_url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: AbortSignal.timeout(12000), }); const p = await pr.json(); const dl = p.download_url || p.url; if ((p.success == 1 || Number(p.progress) >= 1000) && dl) { return { downloadUrl: dl, title: p.title || j.title || null }; } } throw new Error('timeout'); } catch (e) { last = e; } } throw last || new Error('mirror fail'); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } export class SpotubeDL { constructor(base = BASE) { this.base = base.replace(/\/$/, ''); this.serverKey = null; this.hdrs = { 'User-Agent': UA, Origin: this.base, Referer: this.base + '/', }; } async serverPub() { if (this.serverKey) return this.serverKey; const res = await fetch(this.base + rpath(), { headers: { ...this.hdrs, Accept: 'text/plain', 'Cache-Control': 'no-store' }, cache: 'no-store', }); if (!res.ok) throw new Error(`key ${res.status}`); const k = unb64(await res.text()); if (k.length !== 65) throw new Error('bad key'); return (this.serverKey = k); } async securePost(apiPath, body = null) { const postPath = rpath(), postUrl = this.base + postPath; const payload = JSON.stringify({ path: apiPath.startsWith('/') ? apiPath : '/' + apiPath, method: 'POST', body, }); const serverPub = await this.serverPub(); const { privateKey, publicKey } = keyPair(); const clientPub = exportPub(publicKey); const sh = shared(privateKey, serverPub); const rid = rand(16), salt = rand(16), iv = rand(12); const exp = Math.floor(Date.now() / 1000) + 60; const key = hkdf(sh, salt, info('req', postPath, rid)); const aad = info('raqd', postPath, rid, 1, exp); const ct = gcmEnc(key, iv, Buffer.from(payload), aad); const envelope = pack({ v: 1, f: 2, exp, rid, salt, pub: clientPub, iv, ct }); const res = await fetch(postUrl, { method: 'POST', headers: { ...this.hdrs, [rhdr()]: b64(clientPub), 'Content-Type': 'application/octet-stream', Accept: 'application/octet-stream, application/json', }, body: envelope, redirect: 'follow', }); const ctype = (res.headers.get('content-type') || '').toLowerCase(); if (!ctype.includes('octet-stream')) { let data; try { data = await res.json(); } catch { data = await res.text(); } return { ok: res.ok, status: res.status, data }; } const enc = Buffer.from(await res.arrayBuffer()); const respPath = new URL(res.url || postUrl).pathname || postPath; const env = unpack(enc); if (env.v !== 1) throw new Error('bad env'); const key2 = hkdf(shared(privateKey, env.pub), env.salt, info('env', respPath, env.rid)); const plain = gcmDec(key2, env.iv, env.ct, info('aad', respPath, env.rid, env.v, env.exp)); return { ok: res.ok, status: res.status, data: JSON.parse(td.decode(plain)) }; } getTrack(id) { return this.securePost('/api/info/track', { id: sid(id, 'track') }); } getMetadata(id) { return this.securePost('/api/metadata', { id: sid(id, 'track') }); } getAlbum(id) { return this.securePost('/api/info/album', { id: sid(id, 'album') }); } getPlaylist(id) { return this.securePost('/api/info/playlist', { id: sid(id, 'playlist') }); } getArtist(id) { return this.securePost('/api/info/artist', { id: sid(id, 'artist') }); } search(q) { return this.securePost('/api/info/search', { query: q }); } /** deteksi jenis dari URL/id lalu ambil metadata */ async getInfo(input) { const kind = detectKind(input); if (kind === 'album') { const r = await this.getAlbum(input); if (!r.ok) throw new Error(typeof r.data === 'string' ? r.data : JSON.stringify(r.data)); return { kind: 'album', data: r.data }; } if (kind === 'playlist') { const r = await this.getPlaylist(input); if (!r.ok) throw new Error(typeof r.data === 'string' ? r.data : JSON.stringify(r.data)); return { kind: 'playlist', data: r.data }; } if (kind === 'artist') { const r = await this.getArtist(input); if (!r.ok) throw new Error(typeof r.data === 'string' ? r.data : JSON.stringify(r.data)); return { kind: 'artist', data: r.data }; } // default track const r = await this.getTrack(input); if (!r.ok) throw new Error(typeof r.data === 'string' ? r.data : JSON.stringify(r.data)); return { kind: 'track', data: r.data }; } getDownload(videoId, { engine = 'v5', format = 'mp3', quality = '320' } = {}) { return this.securePost('/api/download', { id: videoId, engine, format, quality }); } /** metadata + resolve direct audio url (multi-source) */ async downloadTrack(input, opts = {}) { const id = sid(input, 'track'); const track = await this.getTrack(id); if (!track.ok) throw new Error(JSON.stringify(track.data)); const full = await this.getMetadata(id); const vids = ytIds(track.data, full.data); if (!vids.length) throw new Error('youtube id tidak ditemukan'); let downloadUrl = null; let videoId = vids[0]; let source = null; // 1) coba lewat spotubedl engine (CDN signed URL) const engines = opts.engine ? [opts.engine] : ['v5', 'v4', 'v3', 'v2', 'v1']; for (const vid of vids) { for (const engine of engines) { try { const dl = await this.getDownload(vid, { engine, format: 'mp3', quality: opts.quality || '320' }); const u = pickUrl(dl.data); if (u) { // cek apakah URL bisa diakses (sample) const ok = await probeAudio(u); if (ok) { downloadUrl = u; videoId = vid; source = 'cdn'; break; } } } catch {} } if (downloadUrl) break; } // 2) fallback mirror resolve if (!downloadUrl) { let last; for (const vid of vids) { try { const r = await resolveFromMirror(`https://www.youtube.com/watch?v=${vid}`, 'mp3'); downloadUrl = r.downloadUrl; videoId = vid; source = 'mirror'; break; } catch (e) { last = e; } } if (!downloadUrl) throw new Error('gagal resolve audio: ' + (last?.message || 'all sources failed')); } return { spotifyId: id, videoId, downloadUrl, filename: sanitize(`${track.data?.name || id} - ${track.data?.artist || 'unknown'}.mp3`), track: track.data, metadata: full.data, source, youtubeUrl: `https://www.youtube.com/watch?v=${videoId}`, }; } async saveTrack(input, outPath, opts = {}) { const info = await this.downloadTrack(input, opts); const name = outPath || info.filename; const buf = await this.fetchUrl(info.downloadUrl); if (!isAudio(buf)) throw new Error('file bukan audio valid'); await writeFile(name, buf); return { ...info, savedAs: name, bytes: buf.length }; } async fetchUrl(url) { const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: '*/*' }, redirect: 'follow', signal: AbortSignal.timeout(180000), }); if (!(res.ok || res.status === 206)) throw new Error(`HTTP ${res.status}`); return Buffer.from(await res.arrayBuffer()); } } async function probeAudio(url) { try { const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: '*/*', Range: 'bytes=0-2047' }, redirect: 'follow', signal: AbortSignal.timeout(12000), }); if (!(res.ok || res.status === 206)) return false; const buf = Buffer.from(await res.arrayBuffer()); return isAudio(buf); } catch { return false; } } function detectKind(input) { const raw = String(input).trim(); try { const path = new URL(raw).pathname.toLowerCase(); if (path.includes('/playlist/')) return 'playlist'; if (path.includes('/album/')) return 'album'; if (path.includes('/artist/')) return 'artist'; if (path.includes('/track/')) return 'track'; } catch {} // bare id: default track (spotify ids tidak unik per type di client ini) return 'track'; } function sid(input, type = 'track') { const raw = String(input).trim(); if (/^[a-zA-Z0-9]{22}$/.test(raw)) return raw; try { const p = new URL(raw).pathname.split('/').filter(Boolean); const i = p.indexOf(type); if (i >= 0 && p[i + 1]) return p[i + 1].split('?')[0]; const last = p.at(-1)?.split('?')[0]; if (last && /^[a-zA-Z0-9]{22}$/.test(last)) return last; } catch {} const m = raw.match(/[a-zA-Z0-9]{22}/); if (m) return m[0]; throw new Error('spotify id tidak ditemukan: ' + input); } function ytId(s) { if (typeof s !== 'string') return null; s = s.trim(); if (/^[a-zA-Z0-9_-]{11}$/.test(s)) return s; const m = s.match(/(?:youtu\.be\/|v=|\/embed\/)([a-zA-Z0-9_-]{11})/); return m ? m[1] : null; } function walk(o, fn) { if (o == null) return; if (typeof o === 'string') return fn(o); if (Array.isArray(o)) return o.forEach((x) => walk(x, fn)); if (typeof o === 'object') Object.values(o).forEach((x) => walk(x, fn)); } function ytIds(...objs) { const seen = new Set(), out = []; for (const o of objs) walk(o, (s) => { const id = ytId(s); if (id && !seen.has(id)) { seen.add(id); out.push(id); } }); return out; } function pickUrl(d) { if (!d) return null; if (typeof d === 'string' && /^https?:\/\//.test(d)) return d; return d.url || d.downloadUrl || d.download_url || d.link || d.audioUrl || null; } function sanitize(n) { return String(n).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 180); } function isAudio(buf) { if (!buf || buf.length < 500) return false; if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true; if (buf[0] === 0x66 && buf[1] === 0x4c && buf[2] === 0x61 && buf[3] === 0x43) return true; if (buf[0] === 0x4f && buf[1] === 0x67 && buf[2] === 0x67 && buf[3] === 0x53) return true; if (buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0) return true; return false; } function fmtDur(s) { if (s == null) return '-'; const m = Math.floor(s / 60), sec = Math.floor(s % 60); return `${m}:${String(sec).padStart(2, '0')}`; } function printTrack(t, i) { const n = i != null ? `${i}. ` : ''; console.log(`${n}${t.name} — ${t.artist || (t.artists || []).join(', ')}`); console.log(` album: ${t.album_name || '-'} | ${fmtDur(t.duration)} | ${t.id}`); if (t.release_date) console.log(` rilis: ${t.release_date}`); if (t.cover_url) console.log(` cover: ${t.cover_url}`); if (t.url) console.log(` url: ${t.url}`); } function printAlbum(a, i) { const n = i != null ? `${i}. ` : ''; console.log(`${n}[album] ${a.name} — ${a.artist || (a.artists || []).join(', ')}`); console.log(` year: ${a.year || '-'} | ${a.id}`); if (a.cover_url) console.log(` cover: ${a.cover_url}`); } function printPlaylist(p, i) { const n = i != null ? `${i}. ` : ''; console.log(`${n}[playlist] ${p.name} — ${p.owner || '-'}`); console.log(` ${p.id}`); if (p.cover_url) console.log(` cover: ${p.cover_url}`); } function printArtist(a, i) { const n = i != null ? `${i}. ` : ''; console.log(`${n}[artist] ${a.name || a.artist}`); console.log(` ${a.id}`); } // ---- CLI ---- const arg0 = process.argv[1] || ''; if (arg0.endsWith('spotubedl.mjs') || arg0.endsWith('spotube.js')) { const args = process.argv.slice(2); const cmd = (args[0] || '').toLowerCase(); const client = new SpotubeDL(); const verbose = args.includes('-v') || args.includes('--verbose'); try { if (!args.length || cmd === 'help' || cmd === '-h') { console.log(`Usage: node spotubedl.mjs search [track|album|playlist|artist] node spotubedl.mjs info # metadata track/album/playlist/artist node spotubedl.mjs download # metadata track + downloadUrl (MP3) node spotubedl.mjs save [file] node spotubedl.mjs album|playlist|artist `); process.exit(0); } if (cmd === 'search') { const types = new Set(['track', 'tracks', 'album', 'albums', 'playlist', 'playlists', 'artist', 'artists']); let type = 'track'; let qParts = args.slice(1).filter((a) => a !== '-v' && a !== '--verbose'); if (qParts.length && types.has(qParts[0].toLowerCase())) { type = qParts.shift().toLowerCase().replace(/s$/, ''); } const q = qParts.join(' ') || 'never gonna give you up'; const r = await client.search(q); if (!r.ok) throw new Error(JSON.stringify(r.data)); const d = r.data || {}; const key = type === 'track' ? 'tracks' : type + 's'; let list = d[key] || (type === 'track' ? d.results : null) || []; if (!Array.isArray(list)) list = []; console.log(JSON.stringify({ status: 'success', message: `${list.length} ${type}(s) ditemukan`, query: q, type, data: list, }, null, 2)); } else if (cmd === 'info') { const rest = args.slice(1).filter((a) => a !== '-v' && a !== '--verbose'); const input = rest[0]; if (!input) throw new Error('butuh url/id'); const info = await client.getInfo(input); console.log(JSON.stringify({ status: 'success', message: `Info ${info.kind}`, kind: info.kind, data: info.data, }, null, 2)); } else if (cmd === 'download') { // hanya track: metadata + downloadUrl MP3 const rest = args.slice(1).filter((a) => a !== '-v' && a !== '--verbose'); const input = rest[0]; if (!input) throw new Error('butuh track url/id'); const kind = detectKind(input); if (kind !== 'track') { throw new Error(`download hanya untuk track. Pakai: node spotubedl.mjs info <${kind}-url>`); } const info = await client.downloadTrack(input); console.log(JSON.stringify({ status: 'success', message: 'Download URL siap', data: { spotifyId: info.spotifyId, videoId: info.videoId, youtubeUrl: info.youtubeUrl, downloadUrl: info.downloadUrl, filename: info.filename, source: info.source, track: info.track, }, }, null, 2)); } else if (cmd === 'save') { // resolve + simpan ke disk const rest = args.slice(1).filter((a) => a !== '-v' && a !== '--verbose'); const input = rest[0]; if (!input) throw new Error('butuh url/id'); const r = await client.saveTrack(input, rest[1]); const out = { status: 'success', message: 'File tersimpan', data: { savedAs: r.savedAs, bytes: r.bytes, sizeMB: Number((r.bytes / 1024 / 1024).toFixed(2)), spotifyId: r.spotifyId, videoId: r.videoId, filename: r.filename, track: r.track, }, }; console.log(JSON.stringify(out, null, 2)); } else if (cmd === 'album' || cmd === 'playlist' || cmd === 'artist') { const input = args[1]; if (!input) throw new Error('butuh url/id'); const info = await client.getInfo( cmd === 'album' ? (input.includes('album') ? input : `https://open.spotify.com/album/${input}`) : cmd === 'playlist' ? (input.includes('playlist') ? input : `https://open.spotify.com/playlist/${input}`) : (input.includes('artist') ? input : `https://open.spotify.com/artist/${input}`) ); console.log(JSON.stringify({ status: 'success', message: `Info ${info.kind}`, kind: info.kind, data: info.data, }, null, 2)); } else { // default = info (auto-detect track/album/playlist/artist) const input = args.filter((a) => a !== '-v' && a !== '--verbose')[0]; if (!input) throw new Error('butuh url/id'); const info = await client.getInfo(input); console.log(JSON.stringify({ status: 'success', message: `Info ${info.kind}`, kind: info.kind, data: info.data, }, null, 2)); } } catch (e) { console.log(JSON.stringify({ status: 'error', message: e.message }, null, 2)); process.exit(1); } }